Files
agame/addons/mesh_collider/mesh_collider_plugin.gd
T
2026-10-04 18:32:58 +08:00

260 lines
9.8 KiB
GDScript

@tool
extends EditorPlugin
## Mesh Collider - editor integration.
##
## Adds a physics body with baked collision shapes to a model instance in the
## scene you are editing. Nothing is written to disk and no copy of the model is
## made: the added nodes belong to your scene, so the model file stays referenced
## exactly once.
##
## Every change goes through the editor's undo system, so Ctrl+Z reverts an
## injection and Ctrl+Shift+Z re-applies it.
##
## SCOPE - this plugin touches no editor UI beyond its own menu entries.
##
## There is no dock panel, no dialog, no overlay and no per-frame work. Earlier
## versions had all four, and each of them caused trouble that could not be
## reproduced or diagnosed from here:
##
## * a per-frame scan of the whole edited scene (heavy on a large scene), and a
## SceneTree.node_added hook that made it run even more often;
## * `AcceptDialog` / `PopupPanel` / a hand-built overlay, every one of which
## opened thousands of pixels too tall;
## * a dock panel whose wrapping labels widened its dock column and pushed the
## editor's own docks and the viewport out of the window.
##
## So discovery is now explicit (a menu action) and reports to the Output panel,
## and all configuration lives in Project Settings > mesh_collider. Nothing here
## participates in the editor's layout.
const Collider := preload("mesh_collider.gd")
const Settings := preload("mesh_collider_settings.gd")
const MENU_SCAN := "Mesh Collider: 扫描场景中的模型 / Scan scene for models"
const MENU_ADD := "Mesh Collider: 给选中模型加碰撞 / Add collision to selected"
const MENU_REMOVE := "Mesh Collider: 移除选中模型的碰撞 / Remove collision"
const MENU_SETTINGS := "Mesh Collider: 打开项目设置 / Open project settings"
func _enter_tree() -> void:
Settings.install()
add_tool_menu_item(MENU_SCAN, _scan_pressed)
add_tool_menu_item(MENU_ADD, _add_pressed)
add_tool_menu_item(MENU_REMOVE, _remove_pressed)
add_tool_menu_item(MENU_SETTINGS, _open_settings)
print("[MeshCollider] ready. All actions are under 项目 > 工具; settings are in "
+ "Project Settings > mesh_collider. No dock panel, no dialogs.")
func _exit_tree() -> void:
remove_tool_menu_item(MENU_SCAN)
remove_tool_menu_item(MENU_ADD)
remove_tool_menu_item(MENU_REMOVE)
remove_tool_menu_item(MENU_SETTINGS)
# ---------------------------------------------------------------------------
# Detection - only ever runs when the user asks for it
# ---------------------------------------------------------------------------
## Collect the model instances in the edited scene that have no collision yet.
func _find_pending_models() -> Array[Node]:
var out: Array[Node] = []
var scene_root: Node = get_editor_interface().get_edited_scene_root()
if scene_root == null:
return out
_collect_model_instances(scene_root, out)
return out
func _collect_model_instances(node: Node, out: Array[Node]) -> void:
for child in node.get_children():
if child is Node3D and _is_pending_model(child):
out.append(child)
_collect_model_instances(child, out)
## A model instance we should act on: instantiated straight from a configured
## model file, and not already carrying one of our bodies.
func _is_pending_model(node: Node) -> bool:
if node.scene_file_path.is_empty():
return false
if not Settings.is_configured_model(node.scene_file_path):
return false
if not ResourceLoader.exists(node.scene_file_path):
return false # not imported yet
return not Collider.has_injected_collision(node)
## Report what a scan found, and act if the user has auto mode on. The Output
## panel is the only feedback channel - nothing is drawn over the editor.
func _scan_pressed() -> void:
var pending := _find_pending_models()
if pending.is_empty():
print("[MeshCollider] 当前场景里没有待处理的模型 / no pending models in this scene")
return
var names: Array[String] = []
for node in pending:
if is_instance_valid(node):
names.append(String(node.name))
print("[MeshCollider] 发现 %d 个未添加碰撞的模型:" % names.size())
for n in names:
print(" • " + n)
if Settings.get_bool(Settings.KEY_AUTO):
_add_all(pending)
else:
print("[MeshCollider] 未改动场景。选中模型后用 项目 > 工具 > "
+ "『给选中模型加碰撞』处理,或打开设置里的『扫描后自动添加』。")
func _add_all(pending: Array[Node]) -> void:
var added := 0
for node in pending:
if is_instance_valid(node) and _inject(node):
added += 1
print("[MeshCollider] 已为 %d 个模型添加碰撞。" % added)
# ---------------------------------------------------------------------------
# Injection
# ---------------------------------------------------------------------------
## Add collision to `model` through the undo system. Returns true when a body was
## added.
func _inject(model: Node, source: String = "") -> bool:
if not is_instance_valid(model):
return false
if not (model is Node3D):
return false
if Collider.has_injected_collision(model):
return false
if source.is_empty():
source = String(model.scene_file_path)
# Everything below reads `global_transform`, which only resolves inside the
# tree. Without this check Godot logs
# `Condition "!is_inside_tree()" is true. Returning: Transform3D()` and the
# shapes would be baked at the origin.
if not model.is_inside_tree():
push_warning("[MeshCollider] %s is not in the scene tree; skipped."
% model.name)
return false
var skip_existing := Settings.get_bool(Settings.KEY_SKIP_EXISTING)
if skip_existing and Collider.has_any_collision(model):
print("[MeshCollider] %s already has collision; skipped." % model.name)
return false
# Resolve the mesh transforms first, while tree membership is certain, then
# build the body from that snapshot. The body itself is built detached on
# purpose: the editor's undo system must own every tree change in this plugin,
# so inserting it here would mean undoing a change that had already been
# applied behind undo's back.
var model_xform: Transform3D = (model as Node3D).global_transform
var spaces := Collider.collect_mesh_spaces(model, model_xform, skip_existing)
# The settings script returns plain ints (it deliberately does not reference
# the MeshCollider class), so convert to the core's enum types explicitly
# rather than relying on an implicit int-to-enum conversion.
var shape: Collider.ShapeType = Settings.shape_type() as Collider.ShapeType
var kind: Collider.BodyType = Settings.body_type() as Collider.BodyType
var body := Collider.build_collision_body(
model,
spaces,
model_xform,
shape,
kind,
skip_existing,
Settings.get_int(Settings.KEY_LAYER),
Settings.get_int(Settings.KEY_MASK))
if body == null:
print("[MeshCollider] %s: no usable mesh geometry found." % model.name)
return false
var scene_root: Node = get_editor_interface().get_edited_scene_root()
var undo: EditorUndoRedoManager = get_undo_redo()
undo.create_action("Mesh Collider: add collision to %s" % model.name)
undo.add_do_method(model, "add_child", body, true)
# `owner` can only be assigned once the body is inside the tree - without it
# Godot would not serialise the body or its shapes into the saved scene.
undo.add_do_method(Collider, "prepare_for_scene", body, scene_root)
undo.add_do_reference(body)
undo.add_undo_method(model, "remove_child", body)
undo.commit_action()
get_editor_interface().mark_scene_as_unsaved()
print("[MeshCollider] %s (%s): added %s with %d shape(s)"
% [model.name, source.get_file(), body.get_class(), Collider.count_shapes(body)])
return true
## Remove the collision this plugin added to `model`, undoably.
func _remove(model: Node) -> void:
if not is_instance_valid(model):
return
var body := _find_added_body(model)
if body == null:
print("[MeshCollider] %s has no collision added by this plugin." % model.name)
return
var scene_root: Node = get_editor_interface().get_edited_scene_root()
var undo: EditorUndoRedoManager = get_undo_redo()
undo.create_action("Mesh Collider: remove collision from %s" % model.name)
undo.add_do_method(model, "remove_child", body)
undo.add_undo_method(model, "add_child", body, true)
undo.add_undo_method(body, "set_owner", scene_root)
# Keep the object alive so redo can put the same body back.
undo.add_undo_reference(body)
undo.commit_action()
get_editor_interface().mark_scene_as_unsaved()
print("[MeshCollider] %s: collision removed." % model.name)
func _find_added_body(model: Node) -> Node:
if not is_instance_valid(model):
return null
for child in model.get_children():
if child.has_meta(Collider.META_OWNED):
return child
return null
# ---------------------------------------------------------------------------
# Menu actions
# ---------------------------------------------------------------------------
func _add_pressed() -> void:
var targets := _selected_models()
for model in targets:
_inject(model)
if targets.is_empty():
print("[MeshCollider] 先选中一个拖入的 FBX/GLB 实例 / "
+ "select a model instance first")
func _remove_pressed() -> void:
var targets := _selected_models()
for model in targets:
_remove(model)
if targets.is_empty():
print("[MeshCollider] 先选中一个模型实例 / select a model instance first")
## Selected nodes that are model instances. Copied into a fresh array, because
## injection modifies the tree while the selection array is still live.
func _selected_models() -> Array[Node]:
var out: Array[Node] = []
var selection: Array[Node] = get_editor_interface().get_selection().get_selected_nodes()
for node in selection:
if node is Node3D and not String(node.scene_file_path).is_empty() \
and Settings.is_configured_model(node.scene_file_path):
out.append(node)
return out
func _open_settings() -> void:
get_editor_interface().call_deferred("set_main_screen_editor", "Project")
print("[MeshCollider] 设置项在 项目设置 > mesh_collider / "
+ "settings are under Project Settings > mesh_collider")