385 lines
12 KiB
GDScript
385 lines
12 KiB
GDScript
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)
|