425 lines
15 KiB
GDScript
425 lines
15 KiB
GDScript
@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()
|