Download scripts/modules/sculpt.gd from geter62/monkeystudio: direct link, hf CLI and curl.
- Browser
- Download file 8.18 kB
-
https://huggingface.co/geter62/monkeystudio/resolve/main/scripts/modules/sculpt.gd
- Command line
-
hf download hf://geter62/monkeystudio/scripts/modules/sculpt.gd
-
curl -L -o sculpt.gd https://huggingface.co/geter62/monkeystudio/resolve/main/scripts/modules/sculpt.gd
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)) | |