class_name TransformGizmo3D extends Node3D ## Interactive 3D manipulator gizmo supporting Translate, Rotate, and Scale modes with snapping. ## Renders visually clear axis handles (arrows for move, rings for rotate, cubes for scale) ## with priority depth rendering and geometric raycast picking. signal transform_started signal transform_updated(mode: String, delta: Variant) signal transform_finished(final_pos: Vector3, final_rot: Vector3, final_scale: Vector3) enum Mode { TRANSLATE, ROTATE, SCALE, NONE } enum Axis { NONE, X, Y, Z, XY, XZ, YZ } var current_mode: Mode = Mode.TRANSLATE var active_axis: Axis = Axis.NONE var is_dragging: bool = false # Snapping configuration var snap_enabled: bool = true var snap_translate: float = 1.0 var snap_rotate_deg: float = 15.0 var snap_scale: float = 0.5 var target_node: Node3D = null var _drag_start_pos: Vector3 = Vector3.ZERO var _drag_start_rot: Vector3 = Vector3.ZERO var _drag_start_scale: Vector3 = Vector3.ONE # Mode visual root nodes var _translate_root: Node3D var _rotate_root: Node3D var _scale_root: Node3D func _ready() -> void: _build_gizmo_visuals() _update_visual_mode() func attach_to_node(node: Node3D) -> void: target_node = node if target_node != null and is_instance_valid(target_node): visible = (current_mode != Mode.NONE) position = target_node.position if current_mode == Mode.ROTATE or current_mode == Mode.SCALE: quaternion = target_node.quaternion else: quaternion = Quaternion.IDENTITY else: visible = false func set_mode(new_mode: Mode) -> void: current_mode = new_mode _update_visual_mode() if target_node != null and is_instance_valid(target_node): attach_to_node(target_node) func set_snap(enabled: bool, translate_step: float = 1.0, rotate_step: float = 15.0) -> void: snap_enabled = enabled snap_translate = translate_step snap_rotate_deg = rotate_step func _update_visual_mode() -> void: if _translate_root != null: _translate_root.visible = (current_mode == Mode.TRANSLATE) if _rotate_root != null: _rotate_root.visible = (current_mode == Mode.ROTATE) if _scale_root != null: _scale_root.visible = (current_mode == Mode.SCALE) visible = (target_node != null and current_mode != Mode.NONE) # ── Visual Construction ─────────────────────────────────────────────── func _make_gizmo_mat(color: Color) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = color mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.no_depth_test = true mat.render_priority = 10 return mat func _build_gizmo_visuals() -> void: var x_mat := _make_gizmo_mat(Color(0.95, 0.22, 0.22)) var y_mat := _make_gizmo_mat(Color(0.22, 0.90, 0.25)) var z_mat := _make_gizmo_mat(Color(0.25, 0.50, 0.98)) var center_mat := _make_gizmo_mat(Color(1.0, 0.88, 0.20)) # 1. TRANSLATE Visuals (Arrows + Center Ground Plane Quad) _translate_root = Node3D.new() _translate_root.name = "TranslateRoot" add_child(_translate_root) _translate_root.add_child(_create_arrow(Vector3(1, 0, 0), x_mat, "ArrowX")) _translate_root.add_child(_create_arrow(Vector3(0, 1, 0), y_mat, "ArrowY")) _translate_root.add_child(_create_arrow(Vector3(0, 0, 1), z_mat, "ArrowZ")) # Center ground plane indicator var plane_inst := MeshInstance3D.new() var plane_mesh := BoxMesh.new() plane_mesh.size = Vector3(0.4, 0.02, 0.4) plane_inst.mesh = plane_mesh plane_inst.material_override = center_mat plane_inst.position = Vector3(0.2, 0.01, 0.2) _translate_root.add_child(plane_inst) # 2. ROTATE Visuals (Rings) _rotate_root = Node3D.new() _rotate_root.name = "RotateRoot" add_child(_rotate_root) _rotate_root.add_child(_create_torus(Vector3.UP, y_mat, "RingYaw")) _rotate_root.add_child(_create_torus(Vector3.RIGHT, x_mat, "RingPitch")) _rotate_root.add_child(_create_torus(Vector3.FORWARD, z_mat, "RingRoll")) # 3. SCALE Visuals (Lines + End Cubes) _scale_root = Node3D.new() _scale_root.name = "ScaleRoot" add_child(_scale_root) _scale_root.add_child(_create_scale_handle(Vector3(1, 0, 0), x_mat, "ScaleX")) _scale_root.add_child(_create_scale_handle(Vector3(0, 1, 0), y_mat, "ScaleY")) _scale_root.add_child(_create_scale_handle(Vector3(0, 0, 1), z_mat, "ScaleZ")) # Center uniform scale cube var center_cube := MeshInstance3D.new() var c_box := BoxMesh.new() c_box.size = Vector3(0.25, 0.25, 0.25) center_cube.mesh = c_box center_cube.material_override = center_mat _scale_root.add_child(center_cube) func _create_arrow(dir: Vector3, mat: Material, node_name: String) -> Node3D: var container := Node3D.new() container.name = node_name # Shaft var shaft := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = 0.035 cyl.bottom_radius = 0.035 cyl.height = 1.3 shaft.mesh = cyl shaft.material_override = mat # Cone Head var head := MeshInstance3D.new() var cone := CylinderMesh.new() cone.top_radius = 0.0 cone.bottom_radius = 0.12 cone.height = 0.35 head.mesh = cone head.material_override = mat if dir == Vector3(1, 0, 0): shaft.rotation_degrees = Vector3(0, 0, -90) shaft.position = Vector3(0.65, 0, 0) head.rotation_degrees = Vector3(0, 0, -90) head.position = Vector3(1.45, 0, 0) elif dir == Vector3(0, 1, 0): shaft.position = Vector3(0, 0.65, 0) head.position = Vector3(0, 1.45, 0) elif dir == Vector3(0, 0, 1): shaft.rotation_degrees = Vector3(90, 0, 0) shaft.position = Vector3(0, 0, 0.65) head.rotation_degrees = Vector3(90, 0, 0) head.position = Vector3(0, 0, 1.45) container.add_child(shaft) container.add_child(head) return container func _create_torus(normal: Vector3, mat: Material, node_name: String) -> Node3D: var inst := MeshInstance3D.new() inst.name = node_name var torus := TorusMesh.new() torus.inner_radius = 1.25 torus.outer_radius = 1.35 torus.rings = 32 torus.ring_segments = 8 inst.mesh = torus inst.material_override = mat if normal == Vector3.RIGHT: inst.rotation_degrees = Vector3(0, 0, 90) elif normal == Vector3.FORWARD: inst.rotation_degrees = Vector3(90, 0, 0) return inst func _create_scale_handle(dir: Vector3, mat: Material, node_name: String) -> Node3D: var container := Node3D.new() container.name = node_name # Shaft var shaft := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = 0.035 cyl.bottom_radius = 0.035 cyl.height = 1.3 shaft.mesh = cyl shaft.material_override = mat # Cube Tip var tip := MeshInstance3D.new() var box := BoxMesh.new() box.size = Vector3(0.2, 0.2, 0.2) tip.mesh = box tip.material_override = mat if dir == Vector3(1, 0, 0): shaft.rotation_degrees = Vector3(0, 0, -90) shaft.position = Vector3(0.65, 0, 0) tip.position = Vector3(1.35, 0, 0) elif dir == Vector3(0, 1, 0): shaft.position = Vector3(0, 0.65, 0) tip.position = Vector3(0, 1.35, 0) elif dir == Vector3(0, 0, 1): shaft.rotation_degrees = Vector3(90, 0, 0) shaft.position = Vector3(0, 0, 0.65) tip.position = Vector3(0, 0, 1.35) container.add_child(shaft) container.add_child(tip) return container # ── Geometric Axis Picking ──────────────────────────────────────────── func pick_axis(ray_origin: Vector3, ray_dir: Vector3) -> Axis: if not visible or target_node == null: return Axis.NONE var origin := position # 1. Center / Ground plane handle test (radius 0.45) var d_center := _point_to_ray_dist(origin, ray_origin, ray_dir) if d_center < 0.45: return Axis.XY match current_mode: Mode.TRANSLATE: var d_x := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(1.6, 0, 0)) var d_y := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 1.6, 0)) var d_z := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 0, 1.6)) var min_d := minf(d_x, minf(d_y, d_z)) if min_d < 0.35: if min_d == d_y: return Axis.Y if min_d == d_x: return Axis.X if min_d == d_z: return Axis.Z Mode.ROTATE: var d_yaw := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.UP, 1.3) var d_pitch := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.RIGHT, 1.3) var d_roll := _ray_ring_distance(ray_origin, ray_dir, origin, Vector3.FORWARD, 1.3) var min_r := minf(d_yaw, minf(d_pitch, d_roll)) if min_r < 0.40: if min_r == d_yaw: return Axis.Y if min_r == d_pitch: return Axis.X if min_r == d_roll: return Axis.Z Mode.SCALE: var d_x := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(1.5, 0, 0)) var d_y := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 1.5, 0)) var d_z := get_ray_segment_distance(ray_origin, ray_dir, origin, origin + Vector3(0, 0, 1.5)) var min_d := minf(d_x, minf(d_y, d_z)) if min_d < 0.35: if min_d == d_y: return Axis.Y if min_d == d_x: return Axis.X if min_d == d_z: return Axis.Z return Axis.NONE static func _point_to_ray_dist(p: Vector3, ray_origin: Vector3, ray_dir: Vector3) -> float: var u := ray_dir.normalized() var to_p := p - ray_origin var proj := to_p.dot(u) if proj < 0.0: return 1e9 var closest := ray_origin + u * proj return p.distance_to(closest) static func _ray_ring_distance(ray_origin: Vector3, ray_dir: Vector3, ring_center: Vector3, ring_normal: Vector3, ring_radius: float) -> float: var denom := ray_dir.dot(ring_normal) if absf(denom) < 1e-5: return 1e9 var t := (ring_center - ray_origin).dot(ring_normal) / denom if t < 0.0: return 1e9 var hit := ray_origin + ray_dir * t var d_from_center := hit.distance_to(ring_center) return absf(d_from_center - ring_radius) static func get_ray_segment_distance(ray_origin: Vector3, ray_dir: Vector3, seg_a: Vector3, seg_b: Vector3) -> float: var u := ray_dir.normalized() var v := seg_b - seg_a var w0 := ray_origin - seg_a var a := u.dot(u) var b := u.dot(v) var c := v.dot(v) var d := u.dot(w0) var e := v.dot(w0) var denom := a * c - b * b var s: float = 0.0 var t: float = 0.0 if denom > 1e-6: s = (b * e - c * d) / denom t = (a * e - b * d) / denom else: s = 0.0 t = e / c if c > 1e-6 else 0.0 s = maxf(0.0, s) t = clampf(t, 0.0, 1.0) var pt_ray := ray_origin + u * s var pt_seg := seg_a + v * t return pt_ray.distance_to(pt_seg) # ── Drag Lifecycle (Backwards Compatibility) ────────────────────────── func start_drag(axis: Axis) -> void: if target_node == null: return active_axis = axis is_dragging = true _drag_start_pos = target_node.position _drag_start_rot = target_node.rotation_degrees _drag_start_scale = target_node.scale transform_started.emit() func apply_drag_delta(delta_vec: Vector3) -> void: if not is_dragging or target_node == null: return match current_mode: Mode.TRANSLATE: var new_pos := target_node.position + delta_vec if snap_enabled and snap_translate > 0.0: new_pos.x = snappedf(new_pos.x, snap_translate) new_pos.y = snappedf(new_pos.y, snap_translate) new_pos.z = snappedf(new_pos.z, snap_translate) target_node.position = new_pos global_position = target_node.global_position transform_updated.emit("translate", new_pos) Mode.ROTATE: var rot := target_node.rotation_degrees + delta_vec * 10.0 if snap_enabled and snap_rotate_deg > 0.0: rot.x = snappedf(rot.x, snap_rotate_deg) rot.y = snappedf(rot.y, snap_rotate_deg) rot.z = snappedf(rot.z, snap_rotate_deg) target_node.rotation_degrees = rot transform_updated.emit("rotate", rot) Mode.SCALE: var sc := target_node.scale + delta_vec sc.x = maxf(0.1, sc.x) sc.y = maxf(0.1, sc.y) sc.z = maxf(0.1, sc.z) if snap_enabled and snap_scale > 0.0: sc.x = snappedf(sc.x, snap_scale) sc.y = snappedf(sc.y, snap_scale) sc.z = snappedf(sc.z, snap_scale) target_node.scale = sc transform_updated.emit("scale", sc) func finish_drag() -> void: if not is_dragging: return is_dragging = false active_axis = Axis.NONE if target_node != null: transform_finished.emit(target_node.position, target_node.rotation_degrees, target_node.scale)