删除surface snap

This commit is contained in:
2026-10-04 16:38:56 +08:00
parent 8a5ba71ecf
commit 03b16e226e
8 changed files with 1 additions and 423 deletions
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Surface Snap Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-52
View File
@@ -1,52 +0,0 @@
# Surface Snap
An editor plugin for Godot 4.7+ that lets you drag a `Node3D` directly onto
the **surface** of another object — unlike the built-in vertex snap, which
only snaps to specific vertices.
## Usage
1. Enable the plugin in **Project → Project Settings → Plugins**.
2. Select the object you want to
position.
3. Hold **Alt** and drag with the left mouse button over the target surface.
4. Release the button to confirm (the action is added to the editor's
Undo/Redo, `Ctrl+Z` works normally).
The object's Y axis automatically aligns to the surface normal, and the
object's **edge** (computed from its AABB) touches the contact point — not
its origin/pivot.
## Requirements
- The **target (surface)** object needs real physics collision:
`CollisionShape3D` (under a `StaticBody3D`, `Area3D`, etc.) or a
`CSGShape3D` with `use_collision` enabled in the inspector.
- The **dragged** object does not need a collider.
- Only one selected `Node3D` at a time (multi-selection is not supported in
this version).
## Configuration
In **Project Settings → Surface Snap → Collision Mask**, choose which
physics layers count as a valid snap surface (all layers by default).
## Known limitations
- The "object edge" offset is based on the AABB (bounding box), not the
actual mesh — for very irregular or non-convex shapes, the contact point
may not be the true closest point on the surface.
- Dragging multiple selected objects at once is not supported.
## AI usage disclosure
The GDScript code for this plugin was written with the assistance of an AI
language model (Claude, by Anthropic), based on requirements, testing, and
bug reports provided by the developer throughout an iterative development
process. The developer directed the design decisions, tested every
iteration in the Godot editor, and reported issues that were then fixed
collaboratively with the AI.
## License
MIT — see `LICENSE`.
-7
View File
@@ -1,7 +0,0 @@
[plugin]
name="Surface Snap"
description="Snap objects directly onto the surface of another object"
author="Adrm00"
version="1.0.1"
script="plugin.gd"
-60
View File
@@ -1,60 +0,0 @@
@tool
extends EditorPlugin
## Snaps a 3D object against the surface of another collidable object.
##
## Usage: Select a single Node3D, hold Alt
## and drag with the left mouse button over a collidable surface. Releasing
## the button confirms the move (registered in the editor's Undo/Redo).
##
## Configuration: Project Settings -> "surface_snap/collision_mask" controls
## which physics layers count as a valid snap surface.
var _snap_tool: SurfaceSnapTool = SurfaceSnapTool.new()
func _enter_tree() -> void:
_register_project_settings()
func _exit_tree() -> void:
if _snap_tool.is_dragging():
_snap_tool.cancel_drag()
func _handles(object: Object) -> bool:
return object is Node3D
func _edit(object: Object) -> void:
pass
func _forward_3d_gui_input(camera: Camera3D, event: InputEvent) -> int:
var selected_nodes: Array[Node] = EditorInterface.get_selection().get_selected_nodes()
var target: Node3D = null
if selected_nodes.size() == 1 and selected_nodes[0] is Node3D:
target = selected_nodes[0] as Node3D
var consumed: bool = _snap_tool.handle_input(camera, event, target, get_undo_redo())
if consumed:
return EditorPlugin.AFTER_GUI_INPUT_STOP
return EditorPlugin.AFTER_GUI_INPUT_PASS
func _register_project_settings() -> void:
var setting_name: String = SurfaceSnapTool.SETTING_COLLISION_MASK
var default_value: int = SurfaceSnapTool.DEFAULT_COLLISION_MASK
if not ProjectSettings.has_setting(setting_name):
ProjectSettings.set_setting(setting_name, default_value)
ProjectSettings.set_initial_value(setting_name, default_value)
ProjectSettings.add_property_info({
"name": setting_name,
"type": TYPE_INT,
"hint": PROPERTY_HINT_LAYERS_3D_PHYSICS,
})
-1
View File
@@ -1 +0,0 @@
uid://c41uif1g5stoa
-280
View File
@@ -1,280 +0,0 @@
@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
@@ -1 +0,0 @@
uid://bq3nx5ukyhb1e