From 27a550f61bf959e8222988cc544cc30e32db618f Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Fri, 2 Oct 2026 03:40:31 +0800 Subject: [PATCH] =?UTF-8?q?godot=E8=A1=A8=E9=9D=A2=E5=90=B8=E9=99=84?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/surface_snap/LICENSE | 21 ++ addons/surface_snap/README.md | 52 ++++ addons/surface_snap/plugin.cfg | 7 + addons/surface_snap/plugin.gd | 60 ++++ addons/surface_snap/plugin.gd.uid | 1 + addons/surface_snap/surface_snap_tool.gd | 280 +++++++++++++++++++ addons/surface_snap/surface_snap_tool.gd.uid | 1 + project.godot | 2 +- 8 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 addons/surface_snap/LICENSE create mode 100644 addons/surface_snap/README.md create mode 100644 addons/surface_snap/plugin.cfg create mode 100644 addons/surface_snap/plugin.gd create mode 100644 addons/surface_snap/plugin.gd.uid create mode 100644 addons/surface_snap/surface_snap_tool.gd create mode 100644 addons/surface_snap/surface_snap_tool.gd.uid diff --git a/addons/surface_snap/LICENSE b/addons/surface_snap/LICENSE new file mode 100644 index 0000000..3aaca81 --- /dev/null +++ b/addons/surface_snap/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/addons/surface_snap/README.md b/addons/surface_snap/README.md new file mode 100644 index 0000000..e6624b4 --- /dev/null +++ b/addons/surface_snap/README.md @@ -0,0 +1,52 @@ +# 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`. diff --git a/addons/surface_snap/plugin.cfg b/addons/surface_snap/plugin.cfg new file mode 100644 index 0000000..3e7b53b --- /dev/null +++ b/addons/surface_snap/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Surface Snap" +description="Snap objects directly onto the surface of another object" +author="Adrm00" +version="1.0.1" +script="plugin.gd" diff --git a/addons/surface_snap/plugin.gd b/addons/surface_snap/plugin.gd new file mode 100644 index 0000000..56d5ba5 --- /dev/null +++ b/addons/surface_snap/plugin.gd @@ -0,0 +1,60 @@ +@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, + }) diff --git a/addons/surface_snap/plugin.gd.uid b/addons/surface_snap/plugin.gd.uid new file mode 100644 index 0000000..97f58a1 --- /dev/null +++ b/addons/surface_snap/plugin.gd.uid @@ -0,0 +1 @@ +uid://c41uif1g5stoa diff --git a/addons/surface_snap/surface_snap_tool.gd b/addons/surface_snap/surface_snap_tool.gd new file mode 100644 index 0000000..5450146 --- /dev/null +++ b/addons/surface_snap/surface_snap_tool.gd @@ -0,0 +1,280 @@ +@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 diff --git a/addons/surface_snap/surface_snap_tool.gd.uid b/addons/surface_snap/surface_snap_tool.gd.uid new file mode 100644 index 0000000..2931da3 --- /dev/null +++ b/addons/surface_snap/surface_snap_tool.gd.uid @@ -0,0 +1 @@ +uid://bq3nx5ukyhb1e diff --git a/project.godot b/project.godot index dde7b6e..c32fc55 100644 --- a/project.godot +++ b/project.godot @@ -36,7 +36,7 @@ project/assembly_name="agame" [editor_plugins] -enabled=PackedStringArray("res://addons/csg_blockout/plugin.cfg", "res://addons/godot_ai/plugin.cfg", "res://addons/godotsteam/plugin.cfg") +enabled=PackedStringArray("res://addons/csg_blockout/plugin.cfg", "res://addons/godot_ai/plugin.cfg", "res://addons/godotsteam/plugin.cfg", "res://addons/surface_snap/plugin.cfg") [input]