extends Node ## ## SculptModule - vertex-based sculpting brushes (Features 21-40). ## ## Works directly on a MeshInstance3D's surface arrays. A stroke is: ## begin(pos) -> apply(pos, delta) x N -> end() ## All applies accumulate into CPU PackedArrays; a single GPU upload happens in ## end() (and optionally on a timer) so we do NOT upload per touch-move event. ## ## Brushes: ## draw - push vertices along the surface normal / view dir ## grab - drag vertices inside the radius with the pointer ## pinch - pull vertices toward the brush center ## smooth - laplacian relax toward neighbour average ## flatten - move vertices toward the local average plane ## @export var radius: float = 0.5 @export var strength: float = 0.5 @export var invert: bool = false # hold shift / tap toggle to reverse var _host: Node = null var _target: MeshInstance3D = null var _verts: PackedVector3Array = PackedVector3Array() var _indices: PackedInt32Array = PackedInt32Array() var _uvs: PackedVector2Array = PackedVector2Array() var _base_verts: PackedVector3Array = PackedVector3Array() var _dirty := false var _stroke_active := false var _brush_id := "draw" var _grab_origin := Vector3.ZERO var _grab_delta := Vector3.ZERO func _activate(host: Node) -> void: _host = host StudioBus.brush_changed.connect(_on_brush_changed) StudioBus.brush_radius_changed.connect(func(r: float): radius = r) StudioBus.brush_strength_changed.connect(func(s: float): strength = s) StudioBus.object_selected.connect(_on_object_selected) print("[Sculpt] activated") func _deactivate() -> void: flush() if StudioBus.brush_changed.is_connected(_on_brush_changed): StudioBus.brush_changed.disconnect(_on_brush_changed) if StudioBus.object_selected.is_connected(_on_object_selected): StudioBus.object_selected.disconnect(_on_object_selected) _host = null _target = null func _on_brush_changed(id: String) -> void: _brush_id = id func _on_object_selected(node: Node3D) -> void: set_target(node as MeshInstance3D) ## Bind a MeshInstance3D and cache its arrays. func set_target(mi: MeshInstance3D) -> void: _target = mi if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0: return var arr := mi.mesh.surface_get_arrays(0) _verts = arr[Mesh.ARRAY_VERTEX] _uvs = arr[Mesh.ARRAY_TEX_UV] if arr.size() > Mesh.ARRAY_TEX_UV and arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array() _indices = arr[Mesh.ARRAY_INDEX] if arr.size() > Mesh.ARRAY_INDEX and arr[Mesh.ARRAY_INDEX] != null else PackedInt32Array() _base_verts = _verts.duplicate() func begin(world_pos: Vector3) -> void: if _verts.is_empty(): return _stroke_active = true _dirty = false _grab_origin = world_pos _grab_delta = Vector3.ZERO StudioBus.stroke_begin.emit(world_pos) func apply(world_pos: Vector3, move_delta: Vector3 = Vector3.ZERO) -> void: if not _stroke_active or _verts.is_empty(): return match _brush_id: "draw": _brush_draw(world_pos, move_delta) "grab": _brush_grab(world_pos, move_delta) "pinch": _brush_pinch(world_pos) "smooth": _brush_smooth(world_pos) "flatten": _brush_flatten(world_pos) _dirty = true StudioBus.stroke_apply.emit(world_pos, move_delta) func end() -> void: if not _stroke_active: return _stroke_active = false flush() StudioBus.stroke_end.emit() ## Upload the accumulated CPU edits to the GPU in one shot. func flush() -> void: if not _dirty or _target == null or _target.mesh == null: return VertexBufferManager.commit(_verts, _indices, _uvs, _target.mesh) _base_verts = _verts.duplicate() _dirty = false StudioBus.scene_modified.emit() ## --- Brush implementations -------------------------------------------------- func _falloff(dist: float) -> float: if dist >= radius: return 0.0 var t := 1.0 - (dist / radius) return t * t * (3.0 - 2.0 * t) # smoothstep func _brush_draw(center: Vector3, move_delta: Vector3) -> void: var dir := move_delta.normalized() if move_delta.length_squared() > 0.000001 else Vector3.UP var sign := -1.0 if invert else 1.0 var amp := strength * radius * 0.25 * sign var r2 := radius * radius for i in range(_verts.size()): var d2 := _verts[i].distance_squared_to(center) if d2 < r2: var w := _falloff(sqrt(d2)) _verts[i] = _verts[i] + dir * amp * w func _brush_grab(center: Vector3, move_delta: Vector3) -> void: _grab_delta += move_delta var r2 := radius * radius # Move relative to where the stroke started for a stable, non-drifting feel. for i in range(_verts.size()): var src := _base_verts[i] var d2 := src.distance_squared_to(_grab_origin) if d2 < r2: var w := _falloff(sqrt(d2)) _verts[i] = src + _grab_delta * w func _brush_pinch(center: Vector3) -> void: var sign := -1.0 if invert else 1.0 var r2 := radius * radius for i in range(_verts.size()): var v := _verts[i] var d2 := v.distance_squared_to(center) if d2 < r2: var w := _falloff(sqrt(d2)) var to_center := (center - v) _verts[i] = v + to_center * strength * 0.15 * w * sign func _brush_smooth(center: Vector3) -> void: # Approximate neighbour average using the vertex's triangle neighbours. var sums := PackedVector3Array() var counts := PackedInt32Array() sums.resize(_verts.size()) counts.resize(_verts.size()) var tri := _indices.size() / 3 for t in range(tri): var a := _indices[t * 3] var b := _indices[t * 3 + 1] var c := _indices[t * 3 + 2] for pair in [[a, b], [b, c], [c, a]]: var k: int = pair[1] sums[k] = sums[k] + _verts[pair[0]] counts[k] = counts[k] + 1 var k2: int = pair[0] sums[k2] = sums[k2] + _verts[k] counts[k2] = counts[k2] + 1 var r2 := radius * radius for i in range(_verts.size()): if _verts[i].distance_squared_to(center) < r2 and counts[i] > 0: var avg := sums[i] / float(counts[i]) var w := _falloff(_verts[i].distance_to(center)) _verts[i] = _verts[i].lerp(avg, clampf(strength * w, 0.0, 1.0)) func _brush_flatten(center: Vector3) -> void: var r2 := radius * radius # Find the average normal of affected verts -> local plane. var avg_normal := Vector3.ZERO var affected: Array[int] = [] for i in range(_verts.size()): if _verts[i].distance_squared_to(center) < r2: affected.append(i) if affected.is_empty(): return for i in affected: avg_normal = avg_normal + (_base_verts[i] - center) avg_normal = avg_normal.normalized() if avg_normal.length_squared() > 0.000001 else Vector3.UP for i in affected: var v := _verts[i] var dist_to_plane := v.dot(avg_normal) - center.dot(avg_normal) var w := _falloff(v.distance_to(center)) _verts[i] = v - avg_normal * dist_to_plane * clampf(strength * w, 0.0, 1.0) ## Ray from camera through screen point; returns closest approach to mesh AABB. ## A physics collider is preferred; this fallback keeps sculpting usable even ## when the mesh has no collision shape (common right after generation). func raycast_screen(camera: Camera3D, screen_pos: Vector2) -> Dictionary: if camera == null or _target == null: return {"hit": false} var origin := camera.project_ray_origin(screen_pos) var dir := camera.project_ray_normal(screen_pos) var space := _host.get_viewport().world_3d.direct_space_state var q := PhysicsRayQueryParameters3D.create(origin, origin + dir * 1000.0) q.collide_with_areas = true var res := space.intersect_ray(q) if res.is_empty(): # Fallback: intersect ray with the mesh AABB as an approximation. var aabb := _target.mesh.get_aabb() var box_pos := _target.global_transform * aabb.position var t := _ray_box(origin, dir, _target.global_transform * (aabb.position + aabb.size * 0.5), aabb.size.length() * 0.5) if t > 0.0: return {"hit": true, "position": origin + dir * t, "normal": Vector3.UP} return {"hit": false} return {"hit": true, "position": res["position"], "normal": res["normal"]} func _ray_box(origin: Vector3, dir: Vector3, center: Vector3, r: float) -> float: # Sphere approximation of the mesh bounds for the fallback ray test. var oc := origin - center var b := oc.dot(dir) var c := oc.dot(oc) - r * r var disc := b * b - c if disc < 0.0: return -1.0 var t := -b - sqrt(disc) return t if t > 0.0 else (-b + sqrt(disc))