插件
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user