This commit is contained in:
2026-10-04 15:01:12 +08:00
parent 3d23804fa5
commit 1648d6d9b1
9 changed files with 1831 additions and 0 deletions
@@ -0,0 +1,424 @@
@tool
extends EditorPlugin
## Model Localizer - editor integration.
##
## Drop an FBX/GLB into the 3D viewport and the plugin:
## 1. writes a standalone local `.tscn` next to the model,
## 2. bakes convex collision from its meshes (unless it already has some),
## 3. optionally replaces the dropped instance with the local scene.
##
## Model files dropped in from outside the project are moved into a configurable
## folder first, so nothing has to live in `res://` root.
const Core := preload("model_localizer_core.gd")
const Settings := preload("localizer_settings.gd")
const MENU_SETTINGS := "Model Localizer: 设置 / Settings"
const MENU_PROCESS_SELECTED := "Model Localizer: 处理选中模型 / Process selected models"
const MENU_CLEAN_CACHE := "Model Localizer: 重置记录 / Reset tracked state"
## How often the edited scene is scanned for freshly dropped models.
const SCAN_INTERVAL_SEC := 0.4
var _scan_accum := 0.0
var _needs_scan := false
## Instances already handled in this session (instance id -> true), so a model is
## processed once and never re-processed when the scene is re-saved or reloaded.
var _handled: Dictionary = {}
## Model files seen so far: res path -> last modified time.
var _known_files: Dictionary = {}
## Model paths waiting to be processed on the next idle frame.
var _pending: Dictionary = {}
func _enter_tree() -> void:
Settings.install()
add_tool_menu_item(MENU_SETTINGS, _open_settings)
add_tool_menu_item(MENU_PROCESS_SELECTED, _process_selected_pressed)
add_tool_menu_item(MENU_CLEAN_CACHE, _reset_pressed)
get_tree().node_added.connect(_on_node_added)
_prime_file_cache()
_needs_scan = true
print("[ModelLocalizer] ready. Settings in Project Settings > model_localizer.")
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_SETTINGS)
remove_tool_menu_item(MENU_PROCESS_SELECTED)
remove_tool_menu_item(MENU_CLEAN_CACHE)
func _on_node_added(node: Node) -> void:
# Node3D only; anything else cannot be a model root we care about.
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
_scan_filesystem()
if Settings.get_bool(Settings.KEY_AUTO_PROCESS):
_scan_scene()
func _handles(_object: Object) -> bool:
return false # drive everything from _process, not from selection
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
## Remember every model file that already exists, so the first scan after
## enabling the plugin does not treat the whole project as "just dropped".
func _prime_file_cache() -> void:
_known_files.clear()
_walk_files("res://", func(path: String) -> void:
_known_files[path] = FileAccess.get_modified_time(path))
## Watch for model files that appeared (or changed) since the last scan. A file
## that shows up in `res://` root is what Godot creates when you drop an external
## FBX/GLB onto the editor, so this is how "dragged in from outside" is detected.
func _scan_filesystem() -> void:
_walk_files("res://", func(path: String) -> void:
var modified := FileAccess.get_modified_time(path)
var seen = _known_files.get(path, -1)
if seen == modified:
return
_known_files[path] = modified
if seen == -1:
# Loading a model and saving a scene re-enters the resource system, so
# it is not safe to do in the middle of a directory walk. Queue it for
# the next idle frame instead.
_queue(path)
else:
# Re-imported or replaced: keep any generated scene in sync.
_queue(path)
)
## Queue a model path for processing on the next idle frame, at most once.
func _queue(path: String) -> void:
if _pending.has(path):
return
_pending[path] = true
_flush_pending.call_deferred()
## Process everything queued by `_scan_filesystem`, away from the dir walk.
func _flush_pending() -> void:
if _pending.is_empty():
return
if not is_inside_tree() or get_editor_interface() == null:
_pending.clear()
return
var paths: Array = _pending.keys()
_pending.clear()
for path in paths:
if FileAccess.file_exists(path):
_on_new_model_file(String(path))
func _on_new_model_file(path: String) -> void:
if Settings.should_skip(path):
return
var final_path := path
if Settings.get_bool(Settings.KEY_MOVE_DROPPED) and path.get_base_dir() == "res://":
final_path = _move_into_drop_dir(path)
if final_path.is_empty():
final_path = path
print("[ModelLocalizer] new model detected: %s" % final_path)
_regenerate_for(final_path)
## Move a freshly dropped model (and its `.import` sidecar) into the configured
## folder. Returns the new res:// path, or "" if it could not be moved.
func _move_into_drop_dir(path: String) -> String:
var dir := Settings.get_string(Settings.KEY_DROP_DIR).replace("\\", "/")
if dir.is_empty():
return path
if not dir.begins_with("res://"):
dir = "res://" + dir.trim_prefix("/")
if not dir.ends_with("/"):
dir += "/"
# Never move the destination into itself, and never touch our own output.
if path.begins_with(dir) or Settings.should_skip(path):
return path
var abs_dir := ProjectSettings.globalize_path(dir)
var fs := DirAccess.open("res://")
if fs == null:
push_warning("[ModelLocalizer] cannot open res:// to move %s" % path)
return path
var err := fs.make_dir_recursive(dir.trim_prefix("res://"))
if err != OK and not DirAccess.dir_exists_absolute(abs_dir):
push_warning("[ModelLocalizer] cannot create %s (err %d)" % [dir, err])
return path
var target := dir + path.get_file()
# Avoid clobbering a different asset that happens to share the name.
var stem := target.get_basename()
var ext := target.get_extension()
var n := 1
while FileAccess.file_exists(target) or ResourceLoader.exists(target):
target = "%s_%d.%s" % [stem, n, ext]
n += 1
err = fs.rename(path, target)
if err != OK:
push_warning("[ModelLocalizer] could not move %s -> %s (err %d)" % [path, target, err])
return path
# Rename the import sidecar too, otherwise Godot re-imports as a new asset.
if FileAccess.file_exists(path + ".import"):
fs.rename(path + ".import", target + ".import")
_known_files.erase(path)
_known_files[target] = FileAccess.get_modified_time(target)
get_editor_interface().get_resource_filesystem().scan()
print("[ModelLocalizer] moved dropped file to %s" % target)
return target
## Scan the edited scene for model instances that have not been handled yet.
func _scan_scene() -> void:
var root := get_editor_interface().get_edited_scene_root()
if root == null:
return
var roots: Array[Node] = []
_collect_model_roots(root, root, roots)
for node in roots:
if _handled.has(node.get_instance_id()):
continue
_handle_instance(node)
## Direct children of the edited scene root that are direct instances of a model
## file are treated as "dropped in", which matches how the 3D viewport and the
## Scene dock instantiate a dropped scene.
func _collect_model_roots(root: Node, scene_root: Node, out: Array[Node]) -> void:
for child in root.get_children():
if child is Node3D and Core.is_model_root(child):
out.append(child)
_collect_model_roots(child, scene_root, out)
func _handle_instance(node: Node) -> void:
if not is_instance_valid(node):
return
_handled[node.get_instance_id()] = true
var source: String = node.scene_file_path
if source.is_empty():
return
var result := _generate(source, node)
if not result["ok"]:
push_warning("[ModelLocalizer] %s: %s" % [source, result["error"]])
print("[ModelLocalizer] failed for %s: %s" % [source, result["error"]])
return
# IMPORTANT: `Core.make_local()` detaches and FREES the node it is given, so
# `node` is dangling from here on. Everything below must use the replacement
# root that `_generate` handed back.
var local_root: Node = result.get("root")
if not is_instance_valid(local_root):
return
_handled[local_root.get_instance_id()] = true
print("[ModelLocalizer] %s -> %s (%d collision shapes)"
% [source, result["path"], result["shapes"]])
if Settings.get_bool(Settings.KEY_SWAP):
# The drop placement is applied to the INSTANCE, not baked into the
# generated scene's root (which stays at identity, as Godot expects).
_swap_instance(local_root, String(result["path"]),
result.get("source_transform", Transform3D.IDENTITY))
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
## Build (or refresh) the local scene for `model_path`.
##
## Returns `Core.localize_and_save`'s result dictionary; its `root` entry is the
## new local root, already living where the model instance was. The node passed
## in as `existing_instance` is freed by localisation, so callers must use `root`
## from then on.
func _generate(model_path: String, existing_instance: Node = null) -> Dictionary:
var out_path := Settings.output_path_for(model_path)
# The core creates the output folder; report early if it cannot be made.
var dir_err := Core.ensure_dir_for(out_path)
if dir_err != OK:
return {"ok": false, "path": "", "shapes": 0, "root": null,
"error": "cannot create folder for %s (err %d)" % [out_path, dir_err]}
var instance: Node = existing_instance
var owned := false
if instance == null or not is_instance_valid(instance):
var ps: PackedScene = load(model_path)
if ps == null:
return {"ok": false, "path": "", "shapes": 0, "root": null,
"error": "cannot load " + model_path}
instance = ps.instantiate()
owned = true
var result := Core.localize_and_save(
instance, out_path,
Settings.shape_type(), Settings.body_type(),
Settings.get_bool(Settings.KEY_FORCE_CONVEX),
Settings.get_int(Settings.KEY_LAYER),
Settings.get_int(Settings.KEY_MASK),
Settings.get_bool(Settings.KEY_COLLISION))
if owned and is_instance_valid(instance):
# We instantiated this one purely to read it, so it is ours to release.
# When the caller owned it, make_local already freed it in place.
instance.free()
return result
## Refresh a generated scene after its model was re-imported, but only for models
## that have been through the plugin at least once.
func _regenerate_for(model_path: String) -> void:
var result := _generate(model_path)
if result["ok"]:
print("[ModelLocalizer] refreshed %s" % result["path"])
else:
print("[ModelLocalizer] could not generate for %s: %s" % [model_path, result["error"]])
# ---------------------------------------------------------------------------
# Swapping the instance in the open scene
# ---------------------------------------------------------------------------
## Replace `placeholder` with an instance of the generated `local_path`,
## preserving the node's identity (name, parent, index, metadata) and placing it
## at `instance_transform`. Fully undoable.
func _swap_instance(placeholder: Node, local_path: String,
instance_transform: Transform3D = Transform3D.IDENTITY) -> void:
if not is_instance_valid(placeholder):
return
var parent := placeholder.get_parent()
if parent == null or not ResourceLoader.exists(local_path):
return
var local_scene: PackedScene = load(local_path)
if local_scene == null:
return
var replacement := local_scene.instantiate()
if not (replacement is Node3D):
replacement.free()
return
var keep_name := placeholder.name
var keep_owner: Node = placeholder.owner
var keep_index := placeholder.get_index()
var keep_visible := true
if placeholder is CanvasItem:
keep_visible = (placeholder as CanvasItem).visible
var keep_meta: Dictionary = {}
for k in placeholder.get_meta_list():
keep_meta[k] = placeholder.get_meta(k)
replacement.name = keep_name
# Placement lives on the instance. The generated scene's own root is identity,
# so this is the only transform that takes effect - and it keeps the saved
# scene free of the root-transform warning.
(replacement as Node3D).transform = instance_transform
if replacement is CanvasItem:
(replacement as CanvasItem).visible = keep_visible
for k in keep_meta:
replacement.set_meta(k, keep_meta[k])
# Remove the placeholder BEFORE registering the do-method, so undo can
# re-insert the exact same instance object afterwards.
parent.remove_child(placeholder)
var undo := get_undo_redo()
undo.create_action("Localize model (standalone scene + collision)")
undo.add_do_method(parent, "add_child", replacement, true)
undo.add_do_method(parent, "move_child", replacement, keep_index)
undo.add_do_method(replacement, "set_owner", keep_owner)
undo.add_do_reference(replacement)
undo.add_undo_method(parent, "remove_child", replacement)
undo.add_undo_method(parent, "add_child", placeholder, true)
undo.add_undo_method(parent, "move_child", placeholder, keep_index)
undo.add_undo_method(placeholder, "set_owner", keep_owner)
undo.add_undo_reference(placeholder)
undo.commit_action()
# `commit_action()` ran the do-methods; the placeholder is now orphaned, so
# release it here (the undo action keeps it alive via its reference).
if not placeholder.is_inside_tree():
placeholder.free()
_handled[replacement.get_instance_id()] = true
var selection := get_editor_interface().get_selection()
selection.clear()
selection.add_node(replacement)
get_editor_interface().mark_scene_as_unsaved()
print("[ModelLocalizer] swapped instance to %s" % local_path)
# ---------------------------------------------------------------------------
# Menu actions
# ---------------------------------------------------------------------------
func _open_settings() -> void:
# Focusing the project settings page is the least surprising thing a plugin
# can do here; the values are ordinary project settings.
get_editor_interface().call_deferred("set_main_screen_editor", "Project")
print("[ModelLocalizer] settings live under Project Settings > model_localizer")
func _process_selected_pressed() -> void:
var selection := get_editor_interface().get_selection().get_selected_nodes()
var done := 0
# Collect first: handling a node frees it, which would invalidate a live loop
# over the editor's selection array.
var targets: Array[Node] = []
for node in selection:
if node is Node3D and Core.is_model_root(node):
targets.append(node)
for node in targets:
if is_instance_valid(node):
_handle_instance(node)
done += 1
if done == 0:
print("[ModelLocalizer] select a dropped FBX/GLB instance first")
func _reset_pressed() -> void:
_handled.clear()
_prime_file_cache()
print("[ModelLocalizer] tracking state reset")
# ---------------------------------------------------------------------------
# Filesystem walking
# ---------------------------------------------------------------------------
## Depth-first walk of `res://`, calling `visit` for every importable model file.
func _walk_files(dir_path: String, visit: Callable) -> void:
var dir := DirAccess.open(dir_path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if name.begins_with("."):
name = dir.get_next()
continue
var full := dir_path.path_join(name)
if dir.current_is_dir():
if not Settings.should_skip(full):
_walk_files(full, visit)
elif Core.is_model_file(name) and not Settings.should_skip(full):
visit.call(full)
name = dir.get_next()
dir.list_dir_end()