class_name TransformGizmo3D extends Node3D ## Interactive 3D manipulator gizmo supporting Translate, Rotate, and Scale modes with snapping. 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 func _ready() -> void: _build_gizmo_visuals() func attach_to_node(node: Node3D) -> void: target_node = node if target_node != null: visible = true global_position = target_node.global_position global_rotation = target_node.global_rotation else: visible = false func set_mode(new_mode: Mode) -> void: current_mode = new_mode _update_visual_mode() 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 _build_gizmo_visuals() -> void: # Build colored 3D axis handles for X (Red), Y (Green), Z (Blue) var x_mat := StandardMaterial3D.new() x_mat.albedo_color = Color(0.9, 0.2, 0.2) x_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED var y_mat := StandardMaterial3D.new() y_mat.albedo_color = Color(0.2, 0.85, 0.2) y_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED var z_mat := StandardMaterial3D.new() z_mat.albedo_color = Color(0.2, 0.4, 0.95) z_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED # X axis line + arrow var x_axis := _create_axis_cylinder(Vector3(1, 0, 0), x_mat) x_axis.name = "AxisX" add_child(x_axis) # Y axis line + arrow var y_axis := _create_axis_cylinder(Vector3(0, 1, 0), y_mat) y_axis.name = "AxisY" add_child(y_axis) # Z axis line + arrow var z_axis := _create_axis_cylinder(Vector3(0, 0, 1), z_mat) z_axis.name = "AxisZ" add_child(z_axis) func _create_axis_cylinder(dir: Vector3, mat: Material) -> Node3D: var container := Node3D.new() var shaft := MeshInstance3D.new() var cyl := CylinderMesh.new() cyl.top_radius = 0.03 cyl.bottom_radius = 0.03 cyl.height = 1.5 cyl.material = mat shaft.mesh = cyl # Point cylinder along direction if dir == Vector3(1, 0, 0): shaft.rotation_degrees = Vector3(0, 0, -90) shaft.position = Vector3(0.75, 0, 0) elif dir == Vector3(0, 1, 0): shaft.position = Vector3(0, 0.75, 0) elif dir == Vector3(0, 0, 1): shaft.rotation_degrees = Vector3(90, 0, 0) shaft.position = Vector3(0, 0, 0.75) container.add_child(shaft) return container func _update_visual_mode() -> void: visible = (target_node != null and current_mode != Mode.NONE) 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)