491 lines
19 KiB
GDScript
491 lines
19 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 - 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"
|
|
|
|
## The editor's own "group selected nodes" flag - the toggle next to the lock
|
|
## icon in the Scene dock, bound to Ctrl+G.
|
|
##
|
|
## A node carrying it is what gets selected when any of its children is clicked
|
|
## in the 2D/3D view, which is exactly what a model with a CollisionBody and a
|
|
## pile of CollisionShape3D under it needs. It is a plain node meta, and Godot
|
|
## serialises it into the scene as `metadata/_edit_group_ = true`, so setting it
|
|
## here is all it takes - no editor-only state, nothing to re-apply on load.
|
|
const META_GROUP := &"_edit_group_"
|
|
|
|
|
|
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)
|
|
# Group the model, so clicking it in the viewport selects the model rather
|
|
# than the body or a shape we just hung under it. Only when it is not already
|
|
# grouped, so a choice made by hand is never overwritten - and the undo entry
|
|
# is only added in that same case, so undoing an injection cannot un-group
|
|
# something the user had grouped themselves.
|
|
var grouping := not model.has_meta(META_GROUP)
|
|
if grouping:
|
|
undo.add_do_method(model, "set_meta", META_GROUP, true)
|
|
undo.add_undo_method(model, "remove_meta", META_GROUP)
|
|
undo.commit_action()
|
|
|
|
get_editor_interface().mark_scene_as_unsaved()
|
|
print("[MeshCollider] %s (%s): added %s with %d shape(s)%s"
|
|
% [model.name, source.get_file(), body.get_class(), Collider.count_shapes(body),
|
|
" [已编组]" if grouping else ""])
|
|
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")
|