@tool extends EditorPlugin ## Mesh Collider - editor integration. ## ## Drag an FBX/GLB into a scene and the plugin adds a physics body with baked ## collision shapes to that instance. Nothing is written to disk and no copy of ## the model is made: the added nodes belong to the scene you are editing, so the ## model file stays referenced exactly once. ## ## Everything the plugin does goes through the editor's undo system, so Ctrl+Z ## reverts an injection and Ctrl+Shift+Z re-applies it. const Collider := preload("mesh_collider.gd") const Settings := preload("mesh_collider_settings.gd") const MENU_ADD := "Mesh Collider: 给选中模型加碰撞 / Add collision to selected" const MENU_REMOVE := "Mesh Collider: 移除选中模型的碰撞 / Remove collision" const MENU_SETTINGS := "Mesh Collider: 设置 / Settings" ## How often the edited scene is scanned for freshly dropped models. Detection ## needs polling because Godot exposes no "a model was dragged in" signal. const SCAN_INTERVAL_SEC := 0.4 var _scan_accum := 0.0 var _needs_scan := false ## Instances already handled this session (instance id -> true), so a model is ## processed once and never re-processed while the editor stays open. var _handled: Dictionary = {} func _enter_tree() -> void: Settings.install() add_tool_menu_item(MENU_ADD, _add_pressed) add_tool_menu_item(MENU_REMOVE, _remove_pressed) add_tool_menu_item(MENU_SETTINGS, _open_settings) get_tree().node_added.connect(_on_node_added) _needs_scan = true print("[MeshCollider] ready. Settings live in Project Settings > mesh_collider.") func _exit_tree() -> void: if get_tree() != null and get_tree().node_added.is_connected(_on_node_added): get_tree().node_added.disconnect(_on_node_added) remove_tool_menu_item(MENU_ADD) remove_tool_menu_item(MENU_REMOVE) remove_tool_menu_item(MENU_SETTINGS) func _on_node_added(node: Node) -> void: # Cheap pre-filter: only a 3D node can be a model root worth scanning for. if node is Node3D: _needs_scan = true func _process(delta: float) -> void: _scan_accum += delta if not _needs_scan and _scan_accum < SCAN_INTERVAL_SEC: return _scan_accum = 0.0 _needs_scan = false if Settings.get_bool(Settings.KEY_AUTO): _scan_scene() # --------------------------------------------------------------------------- # Detection # --------------------------------------------------------------------------- ## Walk the edited scene for model instances that have not been handled yet. func _scan_scene() -> void: var scene_root := get_editor_interface().get_edited_scene_root() if scene_root == null: return var found: Array[Node] = [] _collect_model_instances(scene_root, found) for node in found: if _handled.has(node.get_instance_id()): continue _handle(node) func _collect_model_instances(node: Node, out: Array[Node]) -> void: for child in node.get_children(): if child is Node3D and _is_configured_model_instance(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_configured_model_instance(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) func _handle(node: Node) -> void: if not is_instance_valid(node): return _handled[node.get_instance_id()] = true _inject(node) # --------------------------------------------------------------------------- # Injection # --------------------------------------------------------------------------- ## Add collision to `model` through the undo system. func _inject(model: Node, source: String = "") -> void: if not (model is Node3D): return if Collider.has_injected_collision(model): return 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 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 # 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) var body := Collider.build_collision_body( model, spaces, model_xform, Settings.shape_type(), Settings.body_type(), 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 var scene_root := get_editor_interface().get_edited_scene_root() var undo := 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)]) ## 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 := get_editor_interface().get_edited_scene_root() var undo := 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: _handled[model.get_instance_id()] = true _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 := 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: # The settings are ordinary project settings; point the user at them rather # than inventing a second place to configure the same values. get_editor_interface().call_deferred("set_main_screen_editor", "Project") print("[MeshCollider] settings are under Project Settings > mesh_collider")