@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 - a tool menu and one confirmation dialog. No dock panel, no overlay. ## ## Earlier versions did more, and every extra piece 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, which brought its own layout problems and only ever mirrored ## Project Settings > mesh_collider anyway. ## ## So configuration has exactly one home - Project Settings > mesh_collider, with ## the menu's "打开项目设置" to get there - and the scene is watched on a 0.4 s ## timer whose walk is skipped outright whenever the tree has not changed at all ## (see `POLL_SECONDS`). The one dialog left is given an explicit size instead of ## being allowed to size itself to its label. const Collider := preload("mesh_collider.gd") const Settings := preload("mesh_collider_settings.gd") ## Seconds between two looks at the edited scene. ## ## Godot gives a plugin no signal for "a model was just dragged in" - the editor ## handles the drop internally and never tells anyone - so the scene has to be ## watched. An earlier version of this plugin scanned every frame and made the ## editor sluggish on a large scene; this one is bounded two ways: ## ## * `SceneTree.get_node_count()` is O(1), and an unchanged count means nothing ## was added or removed anywhere, so the walk is skipped outright - an idle ## editor does no work at all; ## * the walk only ever visits the edited scene. const POLL_SECONDS := 0.4 var _timer: Timer = null ## Instance ids of the model instances already present in the edited scene. ## Anything in here has been seen, and will not be asked about again. var _known: Dictionary = {} ## The scene the entries in `_known` belong to. var _scene_root: Node = null var _last_node_count := -1 var _dialog: ConfirmationDialog = null var _message: Label = null var _remember: CheckBox = null ## Models waiting for the dialog's answer. A further drop while the dialog is ## open joins this batch instead of opening a second dialog. var _pending: Array[Node] = [] 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) _timer = Timer.new() _timer.wait_time = POLL_SECONDS _timer.autostart = true _timer.timeout.connect(_poll) add_child(_timer) print("[MeshCollider] ready. All actions are under 项目 > 工具; settings are in " + "Project Settings > mesh_collider.") 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) if _timer != null: _timer.stop() _timer.queue_free() _timer = null if _dialog != null: _dialog.queue_free() _dialog = null # --------------------------------------------------------------------------- # 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] 未改动场景。选中模型后用 项目 > 工具 > " + "『给选中模型加碰撞』处理,或把 项目设置 → mesh_collider 里的 " + "auto_add_collision 打开。") 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 # --------------------------------------------------------------------------- # Drag-in detection # --------------------------------------------------------------------------- func _poll() -> void: var root: Node = get_editor_interface().get_edited_scene_root() if root != _scene_root: # A different scene is open. Whatever we knew belonged to the old one, and # what is already in the new one is taken as the baseline: opening a scene # is not "dragging a model in", and prompting for every model of a freshly # opened scene would be intolerable. _scene_root = root _known.clear() if root != null: var ignored: Array[Node] = [] _collect_new_models(root, ignored) return if root == null: return # Nothing was added or removed anywhere in the editor, so the scene cannot # have gained a model. This is the O(1) check that makes the timer free when # the editor is idle. var count := get_tree().get_node_count() if count == _last_node_count: return _last_node_count = count var fresh: Array[Node] = [] _collect_new_models(root, fresh) if fresh.is_empty(): return if Settings.get_bool(Settings.KEY_ASK): _ask(fresh) elif Settings.get_bool(Settings.KEY_AUTO): _add_all(fresh) # Neither: asking was turned off and auto is off too, so a drop is left alone # and stays reachable under 项目 > 工具. ## Record every model instance under `node` we have not seen before, and append ## the ones that still need collision to `out`. Recording happens whether or not ## a model is acted on, so a model the user declined is not offered twice. func _collect_new_models(node: Node, out: Array[Node]) -> void: for child in node.get_children(): if child is Node3D and not _known.has(child.get_instance_id()): _known[child.get_instance_id()] = true if _is_pending_model(child): out.append(child) _collect_new_models(child, out) # --------------------------------------------------------------------------- # The drag-in dialog # --------------------------------------------------------------------------- ## Ask about a batch of freshly dragged-in models. The answer applies to the whole ## batch, so dropping five files asks once. func _ask(models: Array[Node]) -> void: if _dialog == null: _build_dialog() if _dialog.visible: _pending.append_array(models) return _pending = models.duplicate() _remember.button_pressed = false _message.text = _ask_text(_pending) # An explicit width, because a dialog that sizes itself to its label is how an # earlier version of this plugin ended up opening thousands of pixels tall. _dialog.popup_centered(Vector2i(440, 0)) func _ask_text(models: Array[Node]) -> String: if models.size() == 1: return ("拖入的模型『%s』还没有碰撞体。\n现在给它加上吗?" % models[0].name) return ("拖入的 %d 个模型还没有碰撞体。\n现在给它们都加上吗?" % models.size()) func _build_dialog() -> void: _dialog = ConfirmationDialog.new() _dialog.title = "Mesh Collider" _dialog.ok_button_text = "添加碰撞" _dialog.cancel_button_text = "不添加" _dialog.exclusive = true # Godot draws control text through the editor's translation domain, which # would rewrite our own strings with whatever the editor's locale calls them. # Off for the dialog, so it always says what this file says. _dialog.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED # The message and the checkbox go into a container of our own rather than into # `dialog_text` plus a bare `add_child`. An `AcceptDialog` stretches any extra # child over its whole content area instead of laying it out under the label, # so the checkbox ends up drawn on top of the message. # # The cost is one empty line: `dialog_text` stays empty but the dialog's own # label still reserves its height, and hiding it does not help - AcceptDialog # computes its minimum size without looking at visibility. var body := VBoxContainer.new() body.name = "Body" body.add_theme_constant_override("separation", 10) _message = Label.new() _message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _message.custom_minimum_size = Vector2(360, 0) _message.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED body.add_child(_message) _remember = CheckBox.new() _remember.text = "不再询问" _remember.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED _remember.tooltip_text = ("记住这次的选择:选『添加碰撞』以后直接加," + "选『不添加』以后不再加也不再问。\n" + "想恢复询问,去 项目设置 → mesh_collider 把 ask_before_adding 重新打开。") body.add_child(_remember) _dialog.add_child(body) add_child(_dialog) _dialog.confirmed.connect(_on_ask_confirmed) _dialog.canceled.connect(_on_ask_canceled) func _on_ask_confirmed() -> void: _finish_ask(true) func _on_ask_canceled() -> void: _finish_ask(false) func _finish_ask(accepted: bool) -> void: var batch := _pending _pending = [] if accepted: _add_all(batch) if not _remember.button_pressed: return # "不再询问" is an explicit decision that has to outlive the editor session, so # this one is written out now; everything else is left to the editor's own # save. Two settings are enough to remember it - stop asking, and let # `auto_add_collision` carry the answer that was given. Settings.set_value(Settings.KEY_ASK, false) Settings.set_value(Settings.KEY_AUTO, accepted) var err := ProjectSettings.save() if err == OK: print("[MeshCollider] 已记住选择:%s,以后不再询问。" % ("拖入模型时直接加碰撞" if accepted else "拖入模型时不加碰撞")) else: push_warning("[MeshCollider] 保存设置失败,错误码 %d。" % err) # --------------------------------------------------------------------------- # 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 ## Open Project Settings > mesh_collider. ## ## 4.7 has no API for this. `EditorInterface` offers nothing, and the obvious ## `set_main_screen_editor("Project")` fails outright - "The editor name ## 'Project' was not found" - because Project Settings is a dialog, not one of ## the main screens. So the editor's own dialog node is looked up by class and ## popped up. If a future editor renames or moves it, this degrades to printing ## where the settings live rather than breaking. func _open_settings() -> void: var stack: Array[Node] = [get_tree().root] while not stack.is_empty(): var node: Node = stack.pop_back() if node.get_class() == "ProjectSettingsEditor": node.call("popup_centered_clamped", Vector2i(1100, 760)) return for child in node.get_children(): stack.append(child) print("[MeshCollider] 设置项在 项目设置 > mesh_collider / " + "settings are under Project Settings > mesh_collider")