geter62's picture
Upload folder using huggingface_hub
84e8204 verified
Raw History Blame Contribute Delete
8.18 kB
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))