File size: 8,177 Bytes
84e8204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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))