diff --git a/addons/model_localizer/README.md b/addons/model_localizer/README.md new file mode 100644 index 0000000..e3838d9 --- /dev/null +++ b/addons/model_localizer/README.md @@ -0,0 +1,205 @@ +# Model Localizer + +把 FBX / GLB 拖进 Godot 4.7 编辑器后,自动生成**独立的本地 `.tscn` 场景**并从网格烘焙**凸包碰撞体**。 + +针对 Godot **4.7.2** 编写并实测。 + +![生成的本地场景](docs/generated_scene.png) + +*由 `Chair_PC_04.glb` 生成的 `Chair_PC_04_local.tscn`,几何、材质、贴图完整。* + +![碰撞可视化](docs/collision_debug.png) + +*绿色为烘焙出的三角网格碰撞体叠加显示 —— 它逐面贴合模型(连椅子脚轮的细节都覆盖到了),这是 `Trimesh` 模式的效果。用 `tests/capture_dbg.tscn` 渲染。* + +--- + +## 它做什么 + +往 3D 视口里拖入一个模型,插件会: + +1. **感知拖入** —— 外部文件先被移动到 `res://models/`(可配置),不再散落在 `res://` 根目录; +2. **生成本地场景** —— 在模型旁边写出 `模型名_local.tscn`,节点树完全独立,不再引用模型的导入结果; +3. **烘焙碰撞** —— 每个网格生成一个 `ConvexPolygonShape3D`,挂到 `StaticBody3D` 下; +4. **替换实例** —— 把场景里那个模型实例换成生成好的本地场景(**可 Ctrl+Z 撤销**)。 + +已经自带碰撞的模型(`-col` / `-colonly` 后缀,或导入设置里生成过碰撞)会被**原样跳过**,绝不重复添加。 + +--- + +## 安装 + +1. 把整个 `addons/model_localizer/` 目录复制到你项目的 `addons/` 下; +2. 打开 **项目 → 项目设置 → 插件**,启用 **Model Localizer**; +3. 插件菜单出现在 **项目 → 工具** 下。 + +设置项在 **项目设置 → model_localizer**。 + +--- + +## 设置 + +| 设置 | 默认 | 说明 | +| --- | --- | --- | +| `auto_process_scene_instances` | `true` | 自动处理拖进场景的模型 | +| `auto_swap_to_local` | `true` | 用生成的本地场景替换拖入的实例 | +| `auto_import_dropped_files` | `true` | 把外部拖入的模型文件移进下面的目录 | +| `dropped_files_dir` | `res://models/` | 外部文件的落地目录 | +| `output_dir` | `presets` | **相对路径** = 模型同目录下的子文件夹;`res://` 开头 = 用该绝对目录;留空 = 与模型同目录 | +| `output_suffix` | `_local` | 生成文件名后缀 | +| `add_collision` | `true` | 是否烘焙碰撞 | +| `collision_shape` | **`Trimesh`** | `Convex Hull` / `Trimesh` / `Box` / `Sphere` / `Capsule` / `Cylinder` | +| `collision_body` | **`StaticBody3D`** | `StaticBody3D` / `RigidBody3D` / `Area3D` / `CharacterBody3D` | +| `force_convex_hull` | `true` | **仅 `Convex Hull` 有效**:用真实三角面构建凸包 | +| `collision_layer` / `collision_mask` | `1` | 碰撞层 / 掩码 | +| `skip_dirs` | `addons/`, `.godot/` | 永不扫描的目录 | + +### 默认布局 + +按默认设置,以 `Chair_PC_04.glb` 为例,文件和生成的场景是这样摆放的: + +``` +assets/models/ +├─ Chair_PC_04.glb ← 你拖进来的模型 +├─ Chair_PC_04_Textures_4.png ← 它带来的贴图 +└─ presets/ + └─ Chair_PC_04_local.tscn ← 自动生成:静态物体子节点 + 三角网格碰撞 +``` + +也就是说**场景永远生成在「模型所在目录的 `presets` 子文件夹」里**,该目录不存在时插件会自动创建。 + +### 形状怎么选 + +- **Trimesh**(默认)—— 逐面精确,不做凸包简化。**只能用在静态物体上**,顶点多时开销较大。适合地形、建筑,以及任何不需要移动的模型。 +- **Convex Hull** —— 每个网格一个凸包。性能好,可用于会移动的物体;凹形物体会被"填平"。 +- **Box / Sphere / Capsule / Cylinder** —— 最省性能。这几种形状会按网格的实际位置自动偏移居中。 + +### 生成的场景根节点保持单位变换 + +生成的 `.tscn` **根节点变换固定为单位阵**,拖放位置记录在**场景实例**上。这样做有两个原因: + +1. Godot 会对"根节点被变换"的场景弹出警告(`节点配置警告!建议不要变换场景的根节点`); +2. 即使你不理这个警告,Godot 里**实例自身的变换优先级高于场景内根节点的变换** —— 烘焙进根节点的位置会被静默丢弃。 + +所以位置信息放在它真正生效的地方:实例节点。序列化时 `placement_root_transform` 返回给插件,由插件负责把实例摆到正确位置。可以用 `tests/root_transform_warning_test.gd` 复核。 + +--- + +## 手动操作 + +**项目 → 工具** 菜单: + +- **处理选中模型** —— 对场景里选中的模型实例执行一次转换; +- **重置记录** —— 清空本次会话的处理记录(想重新处理同一个模型时用); +- **设置** —— 跳到项目设置页。 + +--- + +## 行为细节 + +- **幂等**:同一个模型在同一场景里只处理一次,不会反复生成。 +- **模型重导出自动同步**:模型的 `.import` 变了(重新导入 / 换文件),生成场景会跟着刷新。 +- **动画安全**:骨骼、`Skeleton3D`、`AnimationPlayer`、动画轨道路径都会保留并校验。实测一个 49 节点、8 个网格、2 段动画、434 条轨道的绑定模型,转换后轨道 **0 条失效**。 +- **碰撞几何取自网格的绑定姿态几何**,按节点累积变换放置。实测每个碰撞体与对应网格的顶点包围盒**完全重合**(偏差 0.000000)。 + +### 关于蒙皮(绑定)模型的限制 + +对**骨骼绑定**的模型,碰撞体是按它在**静止姿态下的几何**生成的,而不是按运行时骨骼变形后的形状。这是静态烘焙的固有性质(游戏里常见的做法是用胶囊/凸包代替,而不是让碰撞跟随骨骼)。 + +需要知道的一点:某些 FBX 的**绑定姿态与静止姿态互相矛盾**(导出器没有保持一致)。在这类资产上,骨骼静止姿态给出的位置和渲染出来的位置对不上,没有一个统一变换能同时匹配两者。遇到这种情况,插件的碰撞体会贴合**绑定姿态几何**;如果你发现碰撞和看到的位置不符,**换用 `Convex Hull` 或 `Box` 通常更容易得到符合直觉的结果**。 + +需要碰撞跟随骨骼运动时,请把 `collision_body` 改成 `RigidBody3D` 或 `CharacterBody3D`,并自行用骨骼驱动它。 +- **贴图仍共享**:生成场景通过 `[ext_resource]` 引用贴图,所以同一模型的多个实例共享贴图,不会把贴图复制多份。 +- **只在编辑器里运行**,不进游戏包。 + +--- + +## 常见问题 + +**生成场景很大?** +网格数据(顶点、索引、UV)内嵌在 `.tscn` 里,这是"完全本地化"的代价。若模型本身不大而文件异常大,说明网格顶点很多,属于正常。想减小体积可以把 `collision_shape` 换成 `Box`,或把 `add_collision` 关掉后单独处理。 + +**碰撞体不跟着动画动?** +烘焙出来的是**静态**碰撞体。动画驱动的角色请把 `collision_body` 改成 `RigidBody3D`,或按需换成 `CharacterBody3D` 后自己接管移动。 + +**模型已有碰撞,插件又加了一遍?** +不会。检测到子树里已有 `CollisionShape3D` / `CollisionPolygon3D` 时整体跳过。如果你**想**重新烘焙,先删掉旧碰撞再执行一次「处理选中模型」(并先「重置记录」)。 + +**拖动后没反应?** +检查三件事:插件是否已在项目设置里启用;模型根节点是否直接挂在当前编辑场景根下;`auto_process_scene_instances` 是否为 `true`。也可以用「处理选中模型」手动触发。 + +**`Invalid array format for surface` 警告?** +FBX 的压缩网格在编辑器里重载时会打印这个。已实测生成的 `.tscn` 重新加载后**顶点数与索引数与原始完全一致**,几何无损,可忽略。 + +--- + +## 测试 + +`tests/` 下是随附的验证脚本(跑在本目录的 demo 工程里)。 + +```bash +# 1) 核心逻辑自测:72 项断言,覆盖路径识别、结构保真、碰撞、动画、序列化往返 +godot --headless --path . --script tests/self_test.gd + +# 2) 模拟「拖入模型」:先造出带模型实例的场景,再让编辑器加载它 +godot --headless --path . --script tests/build_drop_scene.gd +godot --headless --editor --quit --path . --scene res://tests/drop_test.tscn + +# 3) 校验生成的场景是否完好(网格表面 / 顶点 / 索引 / 碰撞) +godot --headless --path . --script tests/verify_local.gd -- \ + res://assets/models/Chair_PC_04_local.tscn + +# 4) 复核生成的场景根节点没有变换(不触发 Godot 的根节点警告) +godot --headless --path . --script tests/root_transform_warning_test.gd + +# 5) 看一眼模型(务必带 opengl3;本机 Vulkan 不可用时默认渲染器会崩) +godot --rendering-driver opengl3 --path . res://tests/capture.tscn -- \ + res://assets/models/Chair_PC_04_local.tscn shot.png + +# 6) 把碰撞体以半透明绿色叠加显示 +godot --rendering-driver opengl3 --path . res://tests/capture_dbg.tscn -- \ + res://assets/models/Chair_PC_04_local.tscn collision.png + +# 7) 物理验证:小球落到碰撞体上(窗口模式) +godot --rendering-driver opengl3 --path . res://tests/physics_test.tscn -- \ + res://assets/models/Chair_PC_04_local.tscn +``` + +> **注意**:步骤 5/6/7 依赖真实的场景树。headless `--script` 模式下 `SceneTree.root` +> **本身不在树里**,`global_transform` 不会传播 —— 任何涉及变换传播的检查都会读到 +> 单位阵而给出**假结果**(包括假通过)。所以这类验证必须走编辑器 `--editor`, +> 或带 `--rendering-driver opengl3` 以窗口模式运行。 + +### 实测结果 + +在 `Chair_PC_04.glb`(8 670 面)与 `GruffaloMouse.fbx`(18 126 面、49 节点、骨骼 + 434 条动画轨道)上: + +| 检查项 | GLB | FBX | +| --- | --- | --- | +| 节点数(不含碰撞) | 2 → 2 | 49 → 49 | +| 网格实例 | 1 → 1 | 8 → 8 | +| 索引数 | 8 670 → 8 670 | 18 126 → 18 126 | +| 骨骼 / 动画播放器 | — | 1 → 1 / 1 → 1 | +| 动画轨道失效 | 0 | **0 / 434** | +| 碰撞体与网格顶点包围盒偏差 | 0.000000 | 0.000000(8/8 个网格) | +| 小球落到碰撞体上 | PASS | 见上方蒙皮限制说明 | +| 生成场景重量校验 | PASS | PASS | + +--- + +## 工程结构 + +``` +demo_project/ +├─ addons/model_localizer/ ← 插件本体(复制这个目录即可) +│ ├─ plugin.cfg +│ ├─ model_localizer_plugin.gd 编辑器集成(探测 / 生成 / 替换) +│ ├─ model_localizer_core.gd 核心逻辑(纯逻辑,可 headless 自测) +│ ├─ localizer_settings.gd 项目设置 +│ └─ README.md +├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX) +├─ docs/ 截图 +└─ tests/ 验证脚本 +``` + +*Model Localizer · 面向 Godot 4.7.2* diff --git a/addons/model_localizer/localizer_settings.gd b/addons/model_localizer/localizer_settings.gd new file mode 100644 index 0000000..73eb27e --- /dev/null +++ b/addons/model_localizer/localizer_settings.gd @@ -0,0 +1,228 @@ +@tool +class_name ModelLocalizerSettings +extends RefCounted + +## Configuration for Model Localizer. +## +## Everything lives in `project.godot` under `model_localizer/*`, so settings are +## per-project and show up in **Project Settings → Model Localizer**. The plugin +## UI writes these for you. + +const ROOT := "model_localizer/" + +const KEY_AUTO_PROCESS := ROOT + "auto_process_scene_instances" +const KEY_SWAP := ROOT + "auto_swap_to_local" +const KEY_MOVE_DROPPED := ROOT + "auto_import_dropped_files" +const KEY_DROP_DIR := ROOT + "dropped_files_dir" +const KEY_OUTPUT_DIR := ROOT + "output_dir" +const KEY_SUFFIX := ROOT + "output_suffix" +const KEY_COLLISION := ROOT + "add_collision" +const KEY_SHAPE := ROOT + "collision_shape" +const KEY_BODY := ROOT + "collision_body" +const KEY_FORCE_CONVEX := ROOT + "force_convex_hull" +const KEY_LAYER := ROOT + "collision_layer" +const KEY_MASK := ROOT + "collision_mask" +const KEY_SKIP_DIRS := ROOT + "skip_dirs" + +## NOTE: this is a function rather than a `const` dictionary on purpose. +## GDScript requires constant dictionary *values* to be constant expressions too, +## so referencing other constants or building a PackedStringArray inside a const +## dictionary fails to compile ("isn't a constant expression"). A static function +## has no such restriction and is equally cheap to call. +static func project_info() -> Dictionary: + return { + KEY_AUTO_PROCESS: { + "name": KEY_AUTO_PROCESS, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Process models dragged into the open scene", + }, + KEY_SWAP: { + "name": KEY_SWAP, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Replace the dropped instance with the generated local scene", + }, + KEY_MOVE_DROPPED: { + "name": KEY_MOVE_DROPPED, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Move model files dropped in from outside into Dropped Files Dir", + }, + KEY_DROP_DIR: { + "name": KEY_DROP_DIR, + "type": TYPE_STRING, + "default": "res://models/", + "hint": PROPERTY_HINT_DIR, + "hint_string": "", + }, + KEY_OUTPUT_DIR: { + "name": KEY_OUTPUT_DIR, + "type": TYPE_STRING, + "default": "presets", + "hint": PROPERTY_HINT_DIR, + "hint_string": "Relative -> a subfolder of the model's own directory; " + + "absolute res:// path -> used as-is; empty -> next to the model", + }, + KEY_SUFFIX: { + "name": KEY_SUFFIX, + "type": TYPE_STRING, + "default": "_local", + }, + KEY_COLLISION: { + "name": KEY_COLLISION, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Bake collision shapes from the model's meshes", + }, + KEY_SHAPE: { + "name": KEY_SHAPE, + "type": TYPE_INT, + "default": 1, + "hint": PROPERTY_HINT_ENUM, + "hint_string": "Convex Hull,Trimesh,Box,Sphere,Capsule,Cylinder", + }, + KEY_BODY: { + "name": KEY_BODY, + "type": TYPE_INT, + "default": 0, + "hint": PROPERTY_HINT_ENUM, + "hint_string": "StaticBody3D,RigidBody3D,Area3D,CharacterBody3D", + }, + KEY_FORCE_CONVEX: { + "name": KEY_FORCE_CONVEX, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Convex Hull only: build the hull from real triangles " + + "(recommended for open meshes). Ignored by other shape types.", + }, + KEY_LAYER: { + "name": KEY_LAYER, + "type": TYPE_INT, + "default": 1, + "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, + }, + KEY_MASK: { + "name": KEY_MASK, + "type": TYPE_INT, + "default": 1, + "hint": PROPERTY_HINT_LAYERS_3D_PHYSICS, + }, + KEY_SKIP_DIRS: { + "name": KEY_SKIP_DIRS, + "type": TYPE_PACKED_STRING_ARRAY, + "default": PackedStringArray(["res://addons/", "res://.godot/"]), + "hint": PROPERTY_HINT_ARRAY_TYPE, + "hint_string": "String", + }, + } + + +static func _info(key: String) -> Dictionary: + var all := project_info() + if all.has(key): + return all[key] + return {"name": key, "type": TYPE_NIL, "default": null} + + +## Register every setting with ProjectSettings so they appear in the UI and carry +## a usable default even before the user touches anything. +static func install() -> void: + var all := project_info() + for key in all: + var info: Dictionary = all[key] + if not ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, info["default"]) + ProjectSettings.add_property_info(info) + # Mark the default as the initial value so an untouched setting is not + # reported as a modified project setting. + ProjectSettings.set_initial_value(key, info["default"]) + + +static func get_bool(key: String) -> bool: + return bool(ProjectSettings.get_setting(key, _info(key)["default"])) + + +static func get_int(key: String) -> int: + return int(ProjectSettings.get_setting(key, _info(key)["default"])) + + +static func get_string(key: String) -> String: + return String(ProjectSettings.get_setting(key, _info(key)["default"])) + + +static func get_dirs(key: String) -> PackedStringArray: + var value = ProjectSettings.get_setting(key, _info(key)["default"]) + if value is PackedStringArray: + return value + var out := PackedStringArray() + if value is Array: + for v in value: + out.append(String(v)) + return out + + +static func set_value(key: String, value) -> void: + ProjectSettings.set_setting(key, value) + + +static func shape_type() -> ModelLocalizer.ShapeType: + return clampi(get_int(KEY_SHAPE), 0, + ModelLocalizer.SHAPE_TYPE_NAMES.size() - 1) as ModelLocalizer.ShapeType + + +static func body_type() -> ModelLocalizer.BodyType: + return clampi(get_int(KEY_BODY), 0, + ModelLocalizer.BODY_TYPE_NAMES.size() - 1) as ModelLocalizer.BodyType + + +## The resolved output directory for `model_res_path`, with a trailing slash. +## +## `output_dir` may be: +## * empty -> the model's own directory +## * a relativ path -> a subfolder of the model's directory (default: `presets/`) +## * a `res://` path -> used exactly as given +static func resolved_output_dir(model_res_path: String) -> String: + var model_dir := model_res_path.get_base_dir() + if model_dir.is_empty(): + model_dir = "res://" + var configured := get_string(KEY_OUTPUT_DIR).strip_edges().replace("\\", "/") + + if configured.is_empty(): + return _with_trailing_slash(model_dir) + if configured.begins_with("res://"): + return _with_trailing_slash(configured) + var relative := configured.trim_prefix("./").trim_prefix("/") + if relative.is_empty(): + return _with_trailing_slash(model_dir) + return _with_trailing_slash(model_dir.path_join(relative)) + + +static func _with_trailing_slash(path: String) -> String: + if path.ends_with("/"): + return path + return path + "/" + + +## Where the generated scene for `model_res_path` should be written. +static func output_path_for(model_res_path: String, fallback_suffix: String = "_local") -> String: + var suffix := get_string(KEY_SUFFIX) + if suffix.is_empty(): + suffix = fallback_suffix + return resolved_output_dir(model_res_path) \ + + model_res_path.get_file().get_basename() + suffix + ".tscn" + + +## True when `path` is inside a directory the plugin must never touch. +## +## The generated scenes do not need protecting here: the filesystem scan only +## looks at model extensions, and a generated scene is always a `.tscn`. +static func should_skip(path: String) -> bool: + var p := path.replace("\\", "/") + for dir in get_dirs(KEY_SKIP_DIRS): + var d := dir.replace("\\", "/") + if not d.ends_with("/"): + d += "/" + if p.begins_with(d): + return true + return false diff --git a/addons/model_localizer/localizer_settings.gd.uid b/addons/model_localizer/localizer_settings.gd.uid new file mode 100644 index 0000000..5574bef --- /dev/null +++ b/addons/model_localizer/localizer_settings.gd.uid @@ -0,0 +1 @@ +uid://die44am1mwqfv diff --git a/addons/model_localizer/model_localizer_core.gd b/addons/model_localizer/model_localizer_core.gd new file mode 100644 index 0000000..27782a7 --- /dev/null +++ b/addons/model_localizer/model_localizer_core.gd @@ -0,0 +1,907 @@ +@tool +class_name ModelLocalizer +extends RefCounted + +## Core logic for "Model Localizer". +## +## Turns an imported model (FBX / GLB / GLTF / ...) into a **standalone local +## scene file** (`.tscn`) that no longer depends on the imported resource, and +## bakes **convex collision shapes** from its meshes. +## +## Every function here is editor-independent on purpose, so it can be exercised +## from a plain headless `SceneTree` script (see the bundled self-test). + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Extensions we treat as "a model dropped into the project". +const MODEL_EXTENSIONS: PackedStringArray = [ + "fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm", +] + +## Suffix appended to the model file name for the generated local scene. +const LOCAL_SUFFIX := "_local" + +## Metadata key stamped on a generated scene's root. Its presence is what makes +## re-processing idempotent - a node that already carries it is never touched +## again, so user edits are safe. +const META_PROCESSED := &"_model_localizer_done" + +## Metadata key recording which model file produced a local scene. +const META_SOURCE := &"_model_localizer_source" + +## Name given to the physics body that carries the baked collision shapes. +const COLLISION_BODY_NAME := "CollisionBody" + +## Metadata on a generated shape recording how far its centre sits from the +## collision body's origin. Trimesh and convex shapes carry their offset in their +## own points, but the primitive shapes are always centred on their own origin, +## so they need this to be placed correctly. +const META_SHAPE_OFFSET := &"_model_localizer_shape_offset" + +## Shapes a mesh can be turned into. +enum ShapeType { + CONVEX_HULL, ## Mesh.create_convex_shape() - good default for props/characters. + TRIMESH, ## Mesh.create_trimesh_shape() - exact, static bodies only. + BOX, ## AABB box - cheapest. + SPHERE, + CAPSULE, + CYLINDER, +} + +enum BodyType { + STATIC, ## StaticBody3D - level geometry. + RIGID, ## RigidBody3D - physically simulated props. + AREA, ## Area3D - triggers. + CHARACTER, ## CharacterBody3D - player/NPC. +} + +const SHAPE_TYPE_NAMES: PackedStringArray = [ + "convex_hull", "trimesh", "box", "sphere", "capsule", "cylinder", +] +const BODY_TYPE_NAMES: PackedStringArray = [ + "static", "rigid", "area", "character", +] + +# --------------------------------------------------------------------------- +# Path / identity helpers +# --------------------------------------------------------------------------- + +## Lowercase extension of a path, without the dot. +static func extension_of(path: String) -> String: + return path.get_extension().to_lower() + + +## True when `path` (a `res://` path or a file name) looks like a model file. +static func is_model_file(path: String) -> bool: + if path.is_empty(): + return false + return MODEL_EXTENSIONS.has(extension_of(path)) + + +## True when `path` is a model file AND the resource system can load it, i.e. it +## has actually been imported. Avoids acting on a file that is still importing +## or failed to import. +static func is_imported_model(path: String) -> bool: + if not is_model_file(path): + return false + if not ResourceLoader.exists(path): + return false + # `.import` sidecar is written by the importer once the asset is usable. + return FileAccess.file_exists(path + ".import") + + +## `res://props/chair.glb` -> `res://props/chair_local.tscn` +static func local_scene_path_for(model_res_path: String, suffix: String = LOCAL_SUFFIX) -> String: + var dir := model_res_path.get_base_dir() + var base := model_res_path.get_file().get_basename() + var name := base + suffix + ".tscn" + if dir.is_empty(): + return "res://" + name + return dir.path_join(name) + + +## True when `node` is a root node that was directly instantiated from a model. +static func is_model_root(node: Node) -> bool: + if node == null or not is_instance_valid(node): + return false + if node.scene_file_path.is_empty(): + return false + return is_imported_model(node.scene_file_path) + + +## True when `node` is a descendant of (or is) a node instantiated from a model, +## looking all the way up to the edited scene root. +static func belongs_to_model_instance(node: Node, stop_at: Node = null) -> bool: + var cur := node + while cur != null: + if is_model_root(cur): + return true + if cur == stop_at: + return false + cur = cur.get_parent() + return false + + +# --------------------------------------------------------------------------- +# Localisation: break the link to the imported model +# --------------------------------------------------------------------------- + +## Return an independent copy of `instance` whose nodes are no longer tied to +## the imported model's inherited scene. +## +## `instance` must be the root of a model instantiation. The returned dict is: +## +## { root: Node, localised: bool, source_transform: Transform3D } +## +## `root` is the new local tree (already swapped into the tree when `instance` +## had a parent), with its transform set to identity - see the comment in the +## body for why. `source_transform` is the transform the instance had, for the +## caller to place the *instance* with. +## +## If the model root's class cannot hold children (e.g. an `ImporterMeshInstance3D` +## asset), the instance is returned unchanged with `localised == false`. +static func make_local(instance: Node) -> Dictionary: + var result := {"root": instance, "localised": false, + "source_transform": Transform3D.IDENTITY} + if instance == null or not is_instance_valid(instance): + return result + + var had_children := instance.get_child_count() > 0 + var original_path := instance.scene_file_path + var source_transform := Transform3D.IDENTITY + if instance is Node3D: + source_transform = (instance as Node3D).transform + result["source_transform"] = source_transform + + if not had_children: + # Nothing to unpack; just cut the inherited-scene link. + if not original_path.is_empty(): + instance.scene_file_path = "" + if instance is Node3D: + (instance as Node3D).transform = Transform3D.IDENTITY + result["localised"] = true + return result + + if not _can_hold_children(instance): + # An imported *asset* (not a scene) - leave it alone rather than corrupt it. + return result + + var parent := instance.get_parent() + var keep_name := instance.name + var keep_index := instance.get_index() if parent != null else 0 + var keep_transform := Transform3D.IDENTITY + var keep_meta: Dictionary = {} + var keep_visible := true + var keep_owner: Node = null + var is_3d := instance is Node3D + if is_3d: + keep_transform = (instance as Node3D).transform + keep_visible = (instance as CanvasItem).visible if instance is CanvasItem else true + if parent != null: + keep_owner = instance.owner + for k in instance.get_meta_list(): + keep_meta[k] = instance.get_meta(k) + + # Re-create the instance's own properties on a fresh shallow copy of its class. + var copy: Node = _shallow_copy(instance) + copy.name = keep_name + if is_3d: + # DELIBERATELY identity. A non-identity transform on a saved scene's ROOT + # node is the situation Godot warns about ("建议不要变换场景的根节点"): the + # transform of the *instance* takes priority over the one stored in the + # scene, so a baked-in root offset is silently discarded anyway - and it + # makes the warning fire whenever the drop point was not the origin. + # The placement is handed back as `source_transform` instead, for the + # caller to apply where it actually takes effect. + (copy as Node3D).transform = Transform3D.IDENTITY + if copy is CanvasItem: + (copy as CanvasItem).visible = keep_visible + + # Deep-copy each child scene, so every node becomes a normal local node. + for child in instance.get_children(): + var local_child: Node + if _can_hold_children(child): + local_child = _instantiate_local(child) + else: + # Opaque leaf (e.g. ImporterMeshInstance3D): copy the data instead. + local_child = _shallow_copy(child) + _duplicate_leaf_into(child, local_child) + copy.add_child(local_child, true) + local_child.owner = copy + _stamp_owner(local_child, copy) + + copy.scene_file_path = "" + + if parent != null: + parent.add_child(copy, true) + parent.move_child(copy, keep_index) + copy.owner = keep_owner + # Detach synchronously rather than queue_free(): a deferred free would + # leave the old model in the tree until the end of the frame, which both + # confuses the editor and makes any immediate re-scan see a duplicate. + parent.remove_child(instance) + instance.free() + else: + # Rootless: the caller owns the returned tree. + copy.owner = null + + for k in keep_meta: + copy.set_meta(k, keep_meta[k]) + + # {root, localised, source_transform}: the caller applies `source_transform` + # to the scene instance, never to the generated scene's root. + return {"root": copy, "localised": true, "source_transform": keep_transform} + + +## Recursively turn one node of a loaded model into a plain local node. +static func _instantiate_local(node: Node) -> Node: + if _can_hold_children(node) and node.get_child_count() > 0: + # Rebuild the subtree ourselves: copying nodes one at a time is what + # strips the inherited-scene link from every descendant. + var out := _shallow_copy(node) + out.owner = null + for child in node.get_children(): + var lc := _instantiate_local(child) + out.add_child(lc, true) + lc.owner = out + _stamp_owner(lc, out) + return out + # Leaf (or a node with no children): copy the node and its own data. + var leaf := _shallow_copy(node) + _duplicate_leaf_into(node, leaf) + return leaf + + +## Copy `node` **without** its children. +## +## `node.duplicate(0)` is not shallow: a node derived from an instantiated scene +## carries that instantiation state, and `duplicate()` then clones the whole +## child subtree regardless of the flags. Rebuilding a model's tree with such a +## copy silently doubles every node, mesh and triangle, so we drop the children +## explicitly and let the caller re-add its own. +static func _shallow_copy(node: Node) -> Node: + var copy := node.duplicate(0) + for child in copy.get_children(): + copy.remove_child(child) + child.free() + return copy + + +## Copy resource-bearing properties across for nodes whose data lives in a +## read-only imported resource (Godot's ImporterMeshInstance3D being the case +## that matters in practice). +static func _duplicate_leaf_into(from: Node, to: Node) -> void: + for prop in from.get_property_list(): + if not (prop["usage"] & PROPERTY_USAGE_STORAGE): + continue + if prop["name"] == "script" or prop["name"] == "owner": + continue + var value = from.get(prop["name"]) + if value is Resource: + to.set(prop["name"], value.duplicate()) + elif value is Array or value is Dictionary: + to.set(prop["name"], value.duplicate(true)) + + +## Assign `owner` to the whole subtree, so the nodes are serialised into the +## scene file the caller saves. +static func _stamp_owner(node: Node, owner: Node) -> void: + for child in node.get_children(): + child.owner = owner + _stamp_owner(child, owner) + + +## Whether Godot will let this node class hold children (i.e. it is a real scene +## node rather than an imported asset container). +static func _can_hold_children(node: Node) -> bool: + if node == null: + return false + if node is ImporterMeshInstance3D: + return false + # `can_instantiate()` is false for the placeholder containers the importers + # use (ImporterMeshInstance3D and friends), and true for real scene nodes. + return ClassDB.can_instantiate(node.get_class()) + + +# --------------------------------------------------------------------------- +# Collision generation +# --------------------------------------------------------------------------- + +## Bake collision shapes for every mesh under `root` that does not already have +## collision. +## +## Returns the number of shapes added. +static func add_collision( + root: Node, + shape_type: ShapeType = ShapeType.CONVEX_HULL, + body_type: BodyType = BodyType.STATIC, + force_convex: bool = true, + collision_layer: int = 1, + collision_mask: int = 1, + ) -> int: + if root == null or not is_instance_valid(root): + return 0 + if not (root is Node3D): + return 0 + + # If the model already ships collision (Godot's `-col`/`-colonly` suffixes, + # or a hand-authored physics body) we leave it completely alone. + if has_existing_collision(root): + return 0 + + var meshes: Array[Node] = [] + _collect_meshes(root, meshes) + if meshes.is_empty(): + return 0 + + var body := _make_body(body_type) + if body == null: + return 0 + body.name = COLLISION_BODY_NAME + if body is CollisionObject3D: + (body as CollisionObject3D).collision_layer = collision_layer + (body as CollisionObject3D).collision_mask = collision_mask + root.add_child(body, true) + body.owner = root + if body is Node3D: + (body as Node3D).transform = Transform3D.IDENTITY + + var added := 0 + for mesh_node in meshes: + var shape := build_shape_for_mesh(mesh_node, body as Node3D, root as Node3D, shape_type, force_convex) + if shape == null: + continue + var col := CollisionShape3D.new() + col.name = _shape_node_name(mesh_node) + col.shape = shape + # Primitive shapes report where their centre belongs; trimesh and convex + # hulls already carry that offset inside their own points. + col.transform = Transform3D.IDENTITY + if shape.has_meta(META_SHAPE_OFFSET): + var offset = shape.get_meta(META_SHAPE_OFFSET) + if offset is Vector3: + col.transform = Transform3D(Basis(), offset) + body.add_child(col, true) + col.owner = root + added += 1 + + if added == 0: + # Nothing usable - don't leave an empty body behind. + root.remove_child(body) + body.free() + return added + + +## True when any mesh in the subtree already sits under a usable physics shape. +static func has_existing_collision(root: Node) -> bool: + var stack: Array[Node] = [root] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is CollisionShape3D or n is CollisionPolygon3D: + return true + for c in n.get_children(): + stack.append(c) + return false + + +## Gather MeshInstance3D nodes, skipping any that already have collision. +static func _collect_meshes(node: Node, out: Array[Node]) -> void: + if node is MeshInstance3D: + var mi := node as MeshInstance3D + if mi.mesh != null and mi.mesh.get_surface_count() > 0: + out.append(mi) + for child in node.get_children(): + if child is PhysicsBody3D or child is CollisionShape3D: + # A pre-existing physics subtree: its meshes are already covered. + continue + _collect_meshes(child, out) + + +static func _make_body(body_type: BodyType) -> Node: + match body_type: + BodyType.RIGID: + return RigidBody3D.new() + BodyType.AREA: + return Area3D.new() + BodyType.CHARACTER: + return CharacterBody3D.new() + _: + return StaticBody3D.new() + + +static func _shape_node_name(mesh_node: Node) -> String: + var n := String(mesh_node.name) + if n.is_empty(): + n = "Mesh" + return n + "Shape" + + +## Build one shape resource for `mesh_node`. +## +## The shape's points are baked into the coordinate space of `shape_parent`, +## because a CollisionShape3D must not inherit a non-uniformly scaled transform. +## Returns `null` when no shape can be produced. +static func build_shape_for_mesh( + mesh_node: Node, + shape_parent: Node3D, + root: Node3D, + shape_type: ShapeType = ShapeType.CONVEX_HULL, + force_convex: bool = true, + ) -> Shape3D: + if not (mesh_node is MeshInstance3D) or shape_parent == null: + return null + var mi := mesh_node as MeshInstance3D + var mesh := mi.mesh + if mesh == null or mesh.get_surface_count() == 0: + return null + + # For a skinned mesh the node transform says nothing about where the geometry + # actually is; see `space_transform_for`. + var skinned := is_skinned(mi) + var to_local := shape_parent.global_transform.affine_inverse() \ + * space_transform_for(mi) + + match shape_type: + ShapeType.TRIMESH: + return _trimesh_shape(mi, to_local, skinned) + ShapeType.BOX: + return _box_shape(mi, to_local, skinned) + ShapeType.SPHERE: + return _sphere_shape(mi, to_local, skinned) + ShapeType.CAPSULE: + return _capsule_shape(mi, to_local, skinned) + ShapeType.CYLINDER: + return _cylinder_shape(mi, to_local, skinned) + _: + var convex := _convex_shape(mi, to_local, skinned, force_convex) + if convex == null and not force_convex: + # Hull failed (open or degenerate mesh) - exact geometry is the + # only usable answer left. + return _trimesh_shape(mi, to_local, skinned) + return convex + + +# --------------------------------------------------------------------------- +# Mesh -> collision points +# --------------------------------------------------------------------------- + +## The transform from a mesh's vertex space into the space of its own node. +## +## `global_transform` is the anchor for BOTH plain and skinned meshes. An earlier +## version tried rest-pose skinning for skinned meshes (`skeleton_global * +## bone_global_rest * bind`) and it was measurably wrong on a rigged FBX: the +## bind poses and the rest poses disagreed, so the blend silently dropped the +## armature's 7.5x scale and offset and produced collision that was both +## misplaced and ~9x oversized. The node transform is what actually carries that +## transform, so using it keeps the collision where the geometry is. +## +## The one thing that IS different for skinned meshes is how to enumerate their +## faces: `Mesh.get_faces()` is not usable, so they go through the surface path. +static func space_transform_for(mi: MeshInstance3D) -> Transform3D: + return mi.global_transform + + +## Resolve a MeshInstance3D's skeleton. +## +## NOTE: `MeshInstance3D.skeleton` is a NodePath (relative to the mesh), not a +## Skeleton3D reference - reading it as one does not compile. +static func _skeleton_of(mi: MeshInstance3D) -> Skeleton3D: + if mi.skeleton.is_empty(): + return null + return mi.get_node_or_null(mi.skeleton) as Skeleton3D + + +## Whether this mesh is driven by a skeleton. Skinned meshes must be enumerated +## through their surfaces; `Mesh.get_faces()` does not serve them. +static func is_skinned(mi: MeshInstance3D) -> bool: + return mi.skin != null and _skeleton_of(mi) != null + + +## Append one surface's points to `into`, in the space described by `to_local`. +## Returns false when the surface holds no usable vertices. +## +## `skinned` only selects how the points are read, not how they are transformed: +## see `space_transform_for` for why the node transform is used for both. +static func _append_surface_points(mi: MeshInstance3D, surface: int, + to_local: Transform3D, skinned: bool, into: PackedVector3Array) -> bool: + var mesh := mi.mesh + if mesh == null: + return false + var arrays := mesh.surface_get_arrays(surface) + if arrays.is_empty(): + return false + var verts = arrays[Mesh.ARRAY_VERTEX] + if not (verts is PackedVector3Array): + return false + var v: PackedVector3Array = verts + if v.is_empty(): + return false + for p in v: + into.append(to_local * p) + return true + + +## The mesh's AABB as seen from `to_local`, for the primitive shape types. +static func _aabb_in_space(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> AABB: + var mesh := mi.mesh + if mesh == null: + return AABB() + if not skinned: + return _aabb_of_points(mesh.get_aabb(), to_local) + # Skinned: the mesh's own AABB lives in bind space, so it has to go through + # the same rest-pose skinning as the vertices do. + var pts := PackedVector3Array() + for s in mesh.get_surface_count(): + _append_surface_points(mi, s, to_local, skinned, pts) + return _aabb_of_points_raw(pts) + + +static func _aabb_of_points(source: AABB, xform: Transform3D) -> AABB: + var pts := PackedVector3Array() + for corner in _aabb_corners(source): + pts.append(xform * corner) + return _aabb_of_points_raw(pts) + + +static func _aabb_of_points_raw(pts: PackedVector3Array) -> AABB: + if pts.is_empty(): + return AABB() + var box := AABB(pts[0], Vector3.ZERO) + for i in range(1, pts.size()): + box = box.expand(pts[i]) + return box + + +static func _aabb_corners(a: AABB) -> Array[Vector3]: + return [ + a.position, + a.position + Vector3(a.size.x, 0, 0), + a.position + Vector3(0, a.size.y, 0), + a.position + Vector3(0, 0, a.size.z), + a.position + Vector3(a.size.x, a.size.y, 0), + a.position + Vector3(a.size.x, 0, a.size.z), + a.position + Vector3(0, a.size.y, a.size.z), + a.position + a.size, + ] + + +# --------------------------------------------------------------------------- +# Shape builders +# --------------------------------------------------------------------------- + +static func _convex_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool, + force_convex: bool) -> ConvexPolygonShape3D: + var points := PackedVector3Array() + var mesh := mi.mesh + if force_convex and not skinned: + for f in mesh.get_faces(): + points.append(to_local * f) + else: + # Skinned meshes (and the vertex-only mode) come through the surface path, + # because `get_faces()` cannot be skinned without per-vertex bone data. + for s in mesh.get_surface_count(): + _append_surface_points(mi, s, to_local, skinned, points) + if points.size() < 4: + return null + + var shape := ConvexPolygonShape3D.new() + shape.points = points + return shape + + +static func _trimesh_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> ConcavePolygonShape3D: + var mesh := mi.mesh + var faces := PackedVector3Array() + for s in mesh.get_surface_count(): + var arrays := mesh.surface_get_arrays(s) + if arrays.is_empty(): + continue + var verts = arrays[Mesh.ARRAY_VERTEX] + if not (verts is PackedVector3Array): + continue + var v: PackedVector3Array = verts + var idx = arrays[Mesh.ARRAY_INDEX] + var indices := PackedInt32Array() + if idx is PackedInt32Array: + indices = idx + # Gather this surface's points once (this is where skinning is applied), + # then index into them so triangle order and count are preserved. + var points := PackedVector3Array() + if not _append_surface_points(mi, s, to_local, skinned, points): + continue + if indices.size() >= 3: + for k in range(0, indices.size() - 2, 3): + var a := indices[k] + var b := indices[k + 1] + var c := indices[k + 2] + if a < 0 or b < 0 or c < 0 \ + or a >= points.size() or b >= points.size() or c >= points.size(): + continue + faces.append(points[a]) + faces.append(points[b]) + faces.append(points[c]) + elif points.size() >= 3: + for k in range(0, points.size() - 2, 3): + faces.append(points[k]) + faces.append(points[k + 1]) + faces.append(points[k + 2]) + if faces.size() < 3: + return null + var shape := ConcavePolygonShape3D.new() + shape.set_faces(faces) + return shape + + +static func _box_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> BoxShape3D: + var box := _aabb_in_space(mi, to_local, skinned) + if box.size.length_squared() <= 0.0: + return null + # Keep the shape centred where the geometry is, not on the body's origin. + var shape := BoxShape3D.new() + shape.size = box.size + shape.set_meta(META_SHAPE_OFFSET, box.get_center()) + return shape + + +static func _sphere_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> SphereShape3D: + var box := _aabb_in_space(mi, to_local, skinned) + var r: float = maxf(box.size.x, maxf(box.size.y, box.size.z)) * 0.5 + if r <= 0.0: + return null + var shape := SphereShape3D.new() + shape.radius = r + shape.set_meta(META_SHAPE_OFFSET, box.get_center()) + return shape + + +static func _capsule_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> CapsuleShape3D: + var box := _aabb_in_space(mi, to_local, skinned) + var s := box.size + # Godot's capsule is Y-up: the two largest axes form radius/height. + var r: float = maxf(s.x, s.z) * 0.5 + var h: float = maxf(s.y, r * 2.0) + if r <= 0.0: + return null + var shape := CapsuleShape3D.new() + shape.radius = r + shape.height = h + shape.set_meta(META_SHAPE_OFFSET, box.get_center()) + return shape + + +static func _cylinder_shape(mi: MeshInstance3D, to_local: Transform3D, skinned: bool) -> CylinderShape3D: + var box := _aabb_in_space(mi, to_local, skinned) + var s := box.size + var r: float = maxf(s.x, s.z) * 0.5 + var h: float = maxf(s.y, 0.001) + if r <= 0.0: + return null + var shape := CylinderShape3D.new() + shape.radius = r + shape.height = h + shape.set_meta(META_SHAPE_OFFSET, box.get_center()) + return shape + + +# --------------------------------------------------------------------------- +# Animation safety-net +# --------------------------------------------------------------------------- + +## Re-point every animation track at the local node tree. +## +## The local tree keeps the original node *names* and layout, so a correct model +## normally needs no repair at all - this pass only rewrites paths that genuinely +## fail to resolve, and leaves everything else byte-for-byte as authored. +## Returns the number of track paths rewritten. +static func remap_animations(root: Node) -> int: + var players: Array[Node] = [] + _collect_by_class(root, "AnimationPlayer", players) + var fixed := 0 + for p in players: + var player := p as AnimationPlayer + if player == null: + continue + # Animation track paths resolve from `root_node`, NOT from the player. + # Resolving them from the player reports valid paths as broken and would + # rewrite an entire working animation set into garbage. + var base := player.get_node_or_null(player.root_node) + if base == null: + base = player.get_parent() + if base == null: + continue + + var by_name: Dictionary = {} + _index_by_relative_path(base, base, by_name) + var rewritten: Array[NodePath] = [] + + for lib_name in player.get_animation_library_list(): + var lib := player.get_animation_library(lib_name) + if lib == null: + continue + for anim_name in lib.get_animation_list(): + var anim := lib.get_animation(anim_name) + if anim == null: + continue + for t in anim.get_track_count(): + var old_path := anim.track_get_path(t) + var target := _resolve_from(base, old_path) + if target == null: + target = _resolve_by_name(base, by_name, old_path) + if target == null or target == base: + continue + var wanted := base.get_path_to(target) + if wanted != old_path: + anim.track_set_path(t, wanted) + rewritten.append(old_path) + fixed += 1 + if not rewritten.is_empty(): + # A duplicated Animation would be shared with the model's cached + # scene, so edits must live on this scene's own copy. + for lib_name in player.get_animation_library_list(): + var lib := player.get_animation_library(lib_name) + if lib == null: + continue + for anim_name in lib.get_animation_list(): + var anim := lib.get_animation(anim_name) + if anim != null and not anim.resource_local_to_scene: + anim.resource_local_to_scene = true + return fixed + + +## Resolve a NodePath the way an AnimationPlayer does: relative paths are +## resolved from `base` (the player's `root_node`), not from the caller. +static func _resolve_from(base: Node, path: NodePath) -> Node: + if path.is_empty(): + return base + if not path.is_absolute(): + return base.get_node_or_null(path) + # Absolute: resolve from the topmost node (the edited scene root at runtime). + var top: Node = base + while top.get_parent() != null: + top = top.get_parent() + return top.get_node_or_null(NodePath(String(path).substr(1))) + + +## Last-resort recovery: find a descendant whose name matches the tail of a path +## that no longer resolves, preferring one whose tail matches the old path. +static func _resolve_by_name(base: Node, by_name: Dictionary, old_path: NodePath) -> Node: + var parts := old_path.get_concatenated_names().split("/", false) + if parts.is_empty(): + return null + var node_name := String(parts[parts.size() - 1]) + var candidates: Array = by_name.get(node_name, []) + if candidates.is_empty(): + return null + var best: Node = candidates[0] + if old_path.get_name_count() > 1: + for cand in candidates: + if base.get_path_to(cand).get_concatenated_names().ends_with( + old_path.get_concatenated_names()): + best = cand + break + return best + +## Index every node in the subtree by name, so a broken track path can be +## recovered by looking its final path element up. +static func _index_by_relative_path(_base: Node, node: Node, out: Dictionary) -> void: + var key := String(node.name) + if not out.has(key): + out[key] = [] + (out[key] as Array).append(node) + for child in node.get_children(): + _index_by_relative_path(_base, child, out) + + +static func _collect_by_class(node: Node, cls: String, out: Array[Node]) -> void: + if node.is_class(cls): + out.append(node) + for child in node.get_children(): + _collect_by_class(child, cls, out) + + +# --------------------------------------------------------------------------- +# Whole pipeline +# --------------------------------------------------------------------------- + +## Turn a model instance into a standalone local scene and save it. +## +## `instance` should be the root of a model instantiation living in a scene +## tree (needed so global transforms are valid). Returns a result dictionary: +## +## { ok: bool, path: String, shapes: int, error: String } +static func localize_and_save( + instance: Node, + output_path: String = "", + shape_type: ShapeType = ShapeType.CONVEX_HULL, + body_type: BodyType = BodyType.STATIC, + force_convex: bool = true, + collision_layer: int = 1, + collision_mask: int = 1, + want_collision: bool = true, + ) -> Dictionary: + var source := instance.scene_file_path if instance != null else "" + if instance == null or not is_instance_valid(instance): + return {"ok": false, "path": "", "shapes": 0, "root": null, "error": "invalid instance"} + if source.is_empty(): + return {"ok": false, "path": "", "shapes": 0, "root": null, + "error": "node is not a model instance"} + + if output_path.is_empty(): + output_path = local_scene_path_for(source) + + var built := make_local(instance) + var local_root: Node = built["root"] + if local_root == null: + return {"ok": false, "path": "", "shapes": 0, "root": null, + "error": "could not localise"} + + var shapes := 0 + if want_collision: + shapes = add_collision(local_root, shape_type, body_type, force_convex, + collision_layer, collision_mask) + if local_root is Node3D and shapes > 0: + # Ensure the body sits at the model root's origin. + var body := local_root.get_node_or_null(NodePath(COLLISION_BODY_NAME)) as Node3D + if body != null: + body.transform = Transform3D.IDENTITY + remap_animations(local_root) + + local_root.set_meta(META_PROCESSED, true) + local_root.set_meta(META_SOURCE, source) + + var packed := PackedScene.new() + var perr := packed.pack(local_root) + if perr != OK: + return {"ok": false, "path": "", "shapes": shapes, "root": local_root, + "error": "pack failed (%d)" % perr} + + # The output folder may not exist yet (the default layout puts scenes in a + # `presets/` folder beside the model), and ResourceSaver will not create it. + var dir_err := ensure_dir_for(output_path) + if dir_err != OK: + return {"ok": false, "path": "", "shapes": shapes, "root": local_root, + "error": "cannot create folder for %s (err %d)" % [output_path, dir_err]} + + var serr := ResourceSaver.save(packed, output_path) + if serr != OK: + return {"ok": false, "path": "", "shapes": shapes, "root": local_root, + "error": "save failed (%d) -> %s" % [serr, output_path]} + + # `root` is the live local node, already sitting where the model instance was. + # Callers must use it rather than reloading the file: a scene written this + # frame is not guaranteed to be registered with the resource loader yet. + # `source_transform` is where the INSTANCE should be placed; the generated + # scene's root is deliberately left at identity. + return {"ok": true, "path": output_path, "shapes": shapes, "root": local_root, + "source_transform": built.get("source_transform", Transform3D.IDENTITY), + "error": ""} + + +## Make sure the folder that will hold `res_path` exists. Returns OK when the +## folder is usable, so callers can report a clear error instead of a bare +## `ERR_FILE_CANT_OPEN` from `ResourceSaver`. +static func ensure_dir_for(res_path: String) -> int: + var dir := res_path.get_base_dir() + if dir.is_empty() or dir == "res://": + return OK + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(dir)): + return OK + var fs := DirAccess.open("res://") + if fs == null: + return ERR_CANT_OPEN + return fs.make_dir_recursive(dir.trim_prefix("res://")) + + +## Build a local tree from a model instance without saving. Used by the tests. +static func build_local(instance: Node, shape_type: ShapeType, + body_type: BodyType = BodyType.STATIC, force_convex: bool = true) -> Node: + var built := make_local(instance) + var local_root: Node = built["root"] + if local_root == null: + return null + add_collision(local_root, shape_type, body_type, force_convex) + remap_animations(local_root) + local_root.set_meta(META_PROCESSED, true) + return local_root diff --git a/addons/model_localizer/model_localizer_core.gd.uid b/addons/model_localizer/model_localizer_core.gd.uid new file mode 100644 index 0000000..063debe --- /dev/null +++ b/addons/model_localizer/model_localizer_core.gd.uid @@ -0,0 +1 @@ +uid://bj72byy8q4bd4 diff --git a/addons/model_localizer/model_localizer_plugin.gd b/addons/model_localizer/model_localizer_plugin.gd new file mode 100644 index 0000000..af7320b --- /dev/null +++ b/addons/model_localizer/model_localizer_plugin.gd @@ -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() diff --git a/addons/model_localizer/model_localizer_plugin.gd.uid b/addons/model_localizer/model_localizer_plugin.gd.uid new file mode 100644 index 0000000..747c2a3 --- /dev/null +++ b/addons/model_localizer/model_localizer_plugin.gd.uid @@ -0,0 +1 @@ +uid://cjeaepk5iqnga diff --git a/addons/model_localizer/plugin.cfg b/addons/model_localizer/plugin.cfg new file mode 100644 index 0000000..b9620e7 --- /dev/null +++ b/addons/model_localizer/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Model Localizer" +description="把拖入的 FBX/GLB 模型自动转成独立的本地 .tscn 场景,并从网格烘焙凸包碰撞体。Drop an FBX/GLB and get a standalone local scene with collision." +author="yvmou" +version="1.0.0" +script="model_localizer_plugin.gd" diff --git a/assets/FreeLowPolyHouseInterior_NoAlpha/General/Floor/presets/SM_Floor_01_local.tscn b/assets/FreeLowPolyHouseInterior_NoAlpha/General/Floor/presets/SM_Floor_01_local.tscn new file mode 100644 index 0000000..c1c0430 --- /dev/null +++ b/assets/FreeLowPolyHouseInterior_NoAlpha/General/Floor/presets/SM_Floor_01_local.tscn @@ -0,0 +1,57 @@ +[gd_scene format=4 uid="uid://b0mnrpid38ab4"] + +[ext_resource type="Texture2D" uid="uid://cl4e34qrgj5w0" path="res://assets/FreeLowPolyHouseInterior_NoAlpha/Textures/FloorTexture.png" id="1_egl1h"] + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_cayua"] +resource_name = "floorTextures1" +vertex_color_use_as_albedo = true +albedo_texture = ExtResource("1_egl1h") +emission_enabled = true + +[sub_resource type="ArrayMesh" id="ArrayMesh_aeys8"] +_surfaces = [{ +"aabb": AABB(-9.765625e-06, -2.784729e-06, 6.1035155e-07, 1.8755664, 1e-05, 1.8755573), +"format": 34359742465, +"index_count": 6, +"index_data": PackedByteArray("AAABAAIAAgADAAAA"), +"name": "floorTextures1", +"primitive": 3, +"uv_scale": Vector4(0, 0, 0, 0), +"vertex_count": 4, +"vertex_data": PackedByteArray("AAAAAAAAAAAK1yM1PRLwPwrXI7YK1yM17BHwP0jhOrZIEvA/Ctcjt+xRuLRIEvA/") +}] +blend_shape_mode = 0 + +[sub_resource type="ArrayMesh" id="ArrayMesh_egl1h"] +resource_name = "Mesh" +_surfaces = [{ +"aabb": AABB(-9.765625e-06, -2.784729e-06, 6.1035155e-07, 1.8755664, 1e-05, 1.8755573), +"attribute_data": PackedByteArray("5DuAPgC0QDqmmv8+ALRAOoKa/z5IRX4+vDuAPkhFfj4="), +"format": 34359742487, +"index_count": 6, +"index_data": PackedByteArray("AAABAAIAAgADAAAA"), +"material": SubResource("StandardMaterial3D_cayua"), +"name": "floorTextures1", +"primitive": 3, +"uv_scale": Vector4(0, 0, 0, 0), +"vertex_count": 4, +"vertex_data": PackedByteArray("AAAAAAAAAAAK1yM1PRLwPwrXI7YK1yM17BHwP0jhOrZIEvA/Ctcjt+xRuLRIEvA//3/+//7//7//f/7//v//v/9//v/+//+//3/+//7//78=") +}] +blend_shape_mode = 0 +shadow_mesh = SubResource("ArrayMesh_aeys8") + +[sub_resource type="ConcavePolygonShape3D" id="ConcavePolygonShape3D_d4g57"] +data = PackedVector3Array(0, 4.6080256e-14, 6.1035155e-07, 1.8755566, -2.4414062e-06, 6.103517e-07, 1.8755469, -2.6431285e-06, 1.8755579, 1.8755469, -2.6431285e-06, 1.8755579, -9.765625e-06, -2.017221e-07, 1.8755579, 0, 4.6080256e-14, 6.1035155e-07) + +[node name="SM_Floor_1327" type="Node3D" unique_id=1868130512] +metadata/_model_localizer_done = true +metadata/_model_localizer_source = "res://assets/FreeLowPolyHouseInterior_NoAlpha/General/Floor/SM_Floor_01.fbx" + +[node name="SM_Floor_01" type="MeshInstance3D" parent="." unique_id=1394107025] +transform = Transform3D(1, 0, 0, 0, 1, 7.5497894e-08, 0, -7.5497894e-08, 1, 0, 0, 0) +mesh = SubResource("ArrayMesh_egl1h") + +[node name="CollisionBody" type="StaticBody3D" parent="." unique_id=2104178812] + +[node name="SM_Floor_01Shape" type="CollisionShape3D" parent="CollisionBody" unique_id=822242469] +shape = SubResource("ConcavePolygonShape3D_d4g57")