@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()