161 lines
5.3 KiB
GDScript
161 lines
5.3 KiB
GDScript
class_name SnappingTool extends RefCounted
|
|
|
|
const SNAP_DISTANCE = 2.0 # Meters
|
|
const SNAP_ANGLE_THRESHOLD = deg_to_rad(45.0)
|
|
|
|
# Define the collision mask for mounts/structure.
|
|
const SNAP_COLLISION_MASK = 1 << 14
|
|
|
|
## Performs a shape cast to find the best candidate for snapping.
|
|
## Returns a Dictionary with { "position": Vector3, "normal": Vector3, "collider": Node } or null.
|
|
static func find_snap_target(
|
|
space_state: PhysicsDirectSpaceState3D,
|
|
ray_origin: Vector3,
|
|
ray_direction: Vector3,
|
|
reach_distance: float = 10.0,
|
|
radius: float = 0.2
|
|
) -> Dictionary:
|
|
|
|
var shape = SphereShape3D.new()
|
|
shape.radius = radius
|
|
|
|
var params = PhysicsShapeQueryParameters3D.new()
|
|
params.shape = shape
|
|
params.transform = Transform3D(Basis(), ray_origin)
|
|
params.motion = ray_direction * reach_distance
|
|
params.collision_mask = SNAP_COLLISION_MASK
|
|
params.collide_with_areas = true
|
|
params.collide_with_bodies = true
|
|
|
|
# 2. Cast the shape
|
|
var result = space_state.cast_motion(params)
|
|
# cast_motion returns [safe_fraction, unsafe_fraction]
|
|
# If safe_fraction is 1.0, we hit nothing.
|
|
|
|
if result[1] >= 1.0:
|
|
return {} # No hit
|
|
|
|
# 3. Get the collision details
|
|
# cast_motion doesn't return the collider, so we need to use get_rest_info
|
|
# at the collision point to find WHAT we hit.
|
|
|
|
# Calculate hit position
|
|
var hit_fraction = result[1]
|
|
var hit_pos = ray_origin + (ray_direction * reach_distance * hit_fraction)
|
|
|
|
params.transform.origin = hit_pos
|
|
params.motion = Vector3.ZERO
|
|
|
|
var intersection = space_state.intersect_shape(params, 1)
|
|
if intersection.is_empty():
|
|
return {}
|
|
|
|
var collider = intersection[0]["collider"]
|
|
|
|
# --- NEW: Filter for PieceMount class ---
|
|
# If we hit a PieceMount, we return it directly.
|
|
if collider is PieceMount:
|
|
return {
|
|
"position": hit_pos,
|
|
"collider": collider,
|
|
"mount": collider # Pass the strong reference
|
|
}
|
|
|
|
return {
|
|
"position": hit_pos,
|
|
"collider": collider
|
|
}
|
|
|
|
## Returns a Transform3D for the 'piece_to_place' that aligns one of its mounts
|
|
## with a compatible mount on 'target_module'.
|
|
static func get_best_snap_transform(
|
|
piece_data: StructureData,
|
|
target_module: Module,
|
|
cursor_pos: Vector3,
|
|
target_mount_node: PieceMount = null # OPTIONAL: Specific target mount
|
|
) -> Transform3D:
|
|
|
|
var best_dist = INF
|
|
var best_transform = Transform3D()
|
|
var found_snap = false
|
|
|
|
# 1. Harvest world-space mounts from the module
|
|
var world_target_mounts = []
|
|
|
|
# Optimization: If we already know the target mount node (from find_snap_target), use ONLY that one.
|
|
if target_mount_node:
|
|
world_target_mounts.append(_get_mount_data_from_node(target_mount_node))
|
|
else:
|
|
# Fallback: Search all
|
|
for child in target_module.get_structural_pieces():
|
|
if child is StructuralPiece and child.structure_data:
|
|
world_target_mounts.append_array(child.structure_data.get_mounts_transformed(child.global_transform))
|
|
|
|
# 2. Find the BEST pair of mounts
|
|
for new_mount in piece_data.mounts:
|
|
|
|
# Construct the Local Transform of the NEW mount
|
|
var local_pos = new_mount.get("position", Vector3.ZERO)
|
|
var local_norm = new_mount.get("normal", Vector3.BACK)
|
|
var local_up = new_mount.get("up", Vector3.UP)
|
|
var local_type = new_mount.get("type", 0)
|
|
|
|
var mount_local_transform = Transform3D(Basis.looking_at(local_norm, local_up), local_pos)
|
|
|
|
for target_mount in world_target_mounts:
|
|
|
|
# A. Type Compatibility Check
|
|
if target_mount.type != local_type:
|
|
continue
|
|
|
|
# B. Distance Check
|
|
# If we provided a specific target mount, distance is less relevant (we already aimed at it),
|
|
# but we still check to ensure the preview doesn't jump wildly if the mounts are far apart.
|
|
var dist_to_cursor = cursor_pos.distance_to(target_mount.position)
|
|
if dist_to_cursor > SNAP_DISTANCE: continue
|
|
|
|
if dist_to_cursor < best_dist:
|
|
# C. Construct Target World Transform
|
|
# We want the NEW mount to face OPPOSITE to the TARGET mount.
|
|
var target_basis = Basis.looking_at(-target_mount.normal, target_mount.up)
|
|
var target_world_transform = Transform3D(target_basis, target_mount.position)
|
|
|
|
# D. Calculate Final Piece Transform
|
|
# Piece_World = Target_Mount_World * Mount_Local.inverse()
|
|
|
|
best_transform = target_world_transform * mount_local_transform.affine_inverse()
|
|
|
|
best_dist = dist_to_cursor
|
|
found_snap = true
|
|
|
|
if found_snap:
|
|
return best_transform
|
|
else:
|
|
return Transform3D(Basis(), cursor_pos)
|
|
|
|
# Helper to extract data dictionary from a runtime PieceMount node
|
|
static func _get_mount_data_from_node(mount_node: PieceMount) -> Dictionary:
|
|
# We extract the transform from the node itself
|
|
var t = mount_node.global_transform
|
|
# Forward (Normal) is -Z, Up is +Y
|
|
var normal = -t.basis.z
|
|
var up = t.basis.y
|
|
|
|
return {
|
|
"position": t.origin,
|
|
"normal": normal,
|
|
"up": up,
|
|
"type": 0 # TODO: PieceMount needs a 'type' property!
|
|
}
|
|
|
|
static func _get_rotation_between_vectors(v1: Vector3, v2: Vector3) -> Quaternion:
|
|
v1 = v1.normalized(); v2 = v2.normalized()
|
|
if v1.dot(v2) > 0.999: return Quaternion.IDENTITY
|
|
if v1.dot(v2) < -0.999:
|
|
var axis = v1.cross(Vector3.UP)
|
|
if axis.length_squared() < 0.01: axis = v1.cross(Vector3.RIGHT)
|
|
return Quaternion(axis.normalized(), PI)
|
|
var cross = v1.cross(v2)
|
|
var dot = v1.dot(v2)
|
|
var w = sqrt(v1.length_squared() * v2.length_squared()) + dot
|
|
return Quaternion(cross.x, cross.y, cross.z, w).normalized() |