Files
agame/addons/surface_snap/surface_snap_tool.gd
T
2026-10-02 03:40:31 +08:00

281 lines
9.5 KiB
GDScript

@tool
class_name SurfaceSnapTool
extends RefCounted
## Drag logic for Surface Snap.
##
## Flow: Hold Alt and drag (left mouse
## button) a selected Node3D. The object is repositioned via a physics raycast
## against colliders (CollisionShape3D / CSGShape3D with "use_collision"
## enabled), with:
## - The object's Y axis aligned to the surface normal (original yaw preserved).
## - The object pushed along the normal just far enough that its AABB
## doesn't clip into the surface, while its origin tracks the mouse
## laterally (in the surface's tangent plane).
## Releasing the button confirms the move and registers it in the editor's Undo/Redo.
##
## Target (surface) requirements: it needs real physics collision
## (an own or inherited CollisionShape3D, or a CSGShape3D with use_collision=true).
## The dragged object itself does not need a collider — and if it has one, its
## collision_layer is temporarily zeroed out during the drag so it can't snap
## against itself.
const SETTING_COLLISION_MASK: String = "surface_snap/collision_mask"
const DEFAULT_COLLISION_MASK: int = 0xFFFFFFFF
const RAY_LENGTH: float = 10000.0
var _dragging: bool = false
var _target: Node3D = null
var _original_transform: Transform3D = Transform3D.IDENTITY
var _original_basis: Basis = Basis.IDENTITY
var _original_scale: Vector3 = Vector3.ONE
var _local_aabb_corners: Array[Vector3] = []
var _disabled_collision_layers: Dictionary = {}
func is_dragging() -> bool:
return _dragging
## Called by the EditorPlugin for every 3D viewport input event.
## Returns true when the event was consumed by this tool.
func handle_input(camera: Camera3D, event: InputEvent, target: Node3D, undo_redo: EditorUndoRedoManager) -> bool:
if event is InputEventMouseButton:
return _handle_mouse_button(camera, event as InputEventMouseButton, target, undo_redo)
elif event is InputEventMouseMotion:
return _handle_mouse_motion(camera, event as InputEventMouseMotion)
return false
## Called externally (e.g. plugin being disabled) to abort an ongoing drag.
func cancel_drag() -> void:
if _is_target_valid():
_target.global_transform = _original_transform
_reset_state()
# ---------------------------------------------------------------------------
# Input handling
# ---------------------------------------------------------------------------
func _handle_mouse_button(camera: Camera3D, event: InputEventMouseButton, target: Node3D, undo_redo: EditorUndoRedoManager) -> bool:
if event.button_index != MOUSE_BUTTON_LEFT:
return false
if event.pressed and event.alt_pressed and target != null:
_start_drag(camera, event.position, target)
return true
if not event.pressed and _dragging:
_finish_drag(undo_redo)
return true
return false
func _handle_mouse_motion(camera: Camera3D, event: InputEventMouseMotion) -> bool:
if not _dragging:
return false
_update_drag(camera, event.position)
return true
# ---------------------------------------------------------------------------
# Drag lifecycle
# ---------------------------------------------------------------------------
func _start_drag(camera: Camera3D, mouse_pos: Vector2, target: Node3D) -> void:
_target = target
_original_transform = target.global_transform
_original_scale = target.scale
_original_basis = Basis(_original_transform.basis).orthonormalized()
_local_aabb_corners = _compute_local_aabb_corners(target)
_dragging = true
_disable_target_collision(target)
var hit: Dictionary = _raycast_surface(camera, mouse_pos)
if not hit.is_empty():
_apply_hit(hit)
func _update_drag(camera: Camera3D, mouse_pos: Vector2) -> void:
if not _is_target_valid():
_reset_state()
return
var hit: Dictionary = _raycast_surface(camera, mouse_pos)
if hit.is_empty():
return
_apply_hit(hit)
func _apply_hit(hit: Dictionary) -> void:
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var normal_n: Vector3 = hit_normal.normalized()
var new_basis: Basis = _align_up_to_normal(_original_basis, hit_normal)
# Only push the object along the normal, by exactly how far its AABB
# extends past the origin in that direction — this keeps the origin's
# lateral (tangent-plane) position matching the mouse ray, instead of
# pinning a specific AABB corner to the hit point.
var min_projection: float = _find_min_normal_projection(_local_aabb_corners, new_basis, normal_n)
var new_origin: Vector3 = hit_position - min_projection * normal_n
# Re-apply the original scale, which is stripped out by orthonormalization.
var scaled_basis: Basis = Basis(
new_basis.x * _original_scale.x,
new_basis.y * _original_scale.y,
new_basis.z * _original_scale.z
)
_target.global_transform = Transform3D(scaled_basis, new_origin)
func _finish_drag(undo_redo: EditorUndoRedoManager) -> void:
if not _is_target_valid():
_reset_state()
return
var affected_target: Node3D = _target
var final_transform: Transform3D = affected_target.global_transform
var previous_transform: Transform3D = _original_transform
undo_redo.create_action("Surface Snap")
undo_redo.add_do_property(affected_target, "global_transform", final_transform)
undo_redo.add_undo_property(affected_target, "global_transform", previous_transform)
undo_redo.commit_action(false)
_reset_state()
func _reset_state() -> void:
_restore_target_collision()
_dragging = false
_target = null
_original_scale = Vector3.ONE
_local_aabb_corners = []
func _is_target_valid() -> bool:
return _target != null and is_instance_valid(_target) and _target.is_inside_tree()
# ---------------------------------------------------------------------------
# Temporarily disable the dragged object's own collision, so it can't be hit
# by its own raycast (e.g. a CSGShape3D with use_collision enabled).
# ---------------------------------------------------------------------------
func _disable_target_collision(node: Node) -> void:
if node is CollisionObject3D:
var collider := node as CollisionObject3D
_disabled_collision_layers[collider] = collider.collision_layer
collider.collision_layer = 0
elif node is CSGShape3D:
var csg := node as CSGShape3D
_disabled_collision_layers[csg] = csg.collision_layer
csg.collision_layer = 0
for child in node.get_children():
_disable_target_collision(child as Node)
func _restore_target_collision() -> void:
for node: Object in _disabled_collision_layers.keys():
if is_instance_valid(node):
var original_layer: int = _disabled_collision_layers[node]
if node is CollisionObject3D:
(node as CollisionObject3D).collision_layer = original_layer
elif node is CSGShape3D:
(node as CSGShape3D).collision_layer = original_layer
_disabled_collision_layers.clear()
# ---------------------------------------------------------------------------
# Physics raycast
# ---------------------------------------------------------------------------
func _raycast_surface(camera: Camera3D, mouse_pos: Vector2) -> Dictionary:
var world_3d: World3D = camera.get_world_3d()
var space_state: PhysicsDirectSpaceState3D = world_3d.direct_space_state
var from: Vector3 = camera.project_ray_origin(mouse_pos)
var dir: Vector3 = camera.project_ray_normal(mouse_pos)
var to: Vector3 = from + dir * RAY_LENGTH
var collision_mask: int = ProjectSettings.get_setting(SETTING_COLLISION_MASK, DEFAULT_COLLISION_MASK)
var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to, collision_mask)
return space_state.intersect_ray(query)
# ---------------------------------------------------------------------------
# Rotation alignment (Y axis -> surface normal)
# ---------------------------------------------------------------------------
func _align_up_to_normal(reference_basis: Basis, normal: Vector3) -> Basis:
var normal_n: Vector3 = normal.normalized()
var forward: Vector3 = -reference_basis.z
# Project the reference forward vector onto the surface's tangent plane
# to preserve the object's original yaw as much as possible.
var forward_proj: Vector3 = forward - normal_n * forward.dot(normal_n)
if forward_proj.length() < 0.001:
forward_proj = reference_basis.x - normal_n * reference_basis.x.dot(normal_n)
if forward_proj.length() < 0.001:
forward_proj = Vector3.FORWARD - normal_n * Vector3.FORWARD.dot(normal_n)
forward_proj = forward_proj.normalized()
var right: Vector3 = forward_proj.cross(normal_n).normalized()
var true_forward: Vector3 = normal_n.cross(right).normalized()
var new_basis: Basis = Basis(right, normal_n, -true_forward)
return new_basis.orthonormalized()
# ---------------------------------------------------------------------------
# Grounding offset via local AABB (object edge, not origin, along the normal)
# ---------------------------------------------------------------------------
func _compute_local_aabb_corners(node: Node3D) -> Array[Vector3]:
var aabb: AABB = _get_node_aabb(node)
var corners: Array[Vector3] = []
for i in range(8):
corners.append(aabb.position + Vector3(
aabb.size.x * float(i & 1),
aabb.size.y * float((i >> 1) & 1),
aabb.size.z * float((i >> 2) & 1)
))
return corners
func _get_node_aabb(node: Node3D) -> AABB:
if node is VisualInstance3D:
return (node as VisualInstance3D).get_aabb()
return AABB(Vector3.ZERO, Vector3.ZERO)
func _find_min_normal_projection(corners_local: Array[Vector3], rotation_basis: Basis, normal: Vector3) -> float:
if corners_local.is_empty():
return 0.0
var min_dot: float = INF
for corner_local in corners_local:
var corner_world: Vector3 = rotation_basis * corner_local
var d: float = corner_world.dot(normal)
if d < min_dot:
min_dot = d
return min_dot