diff --git a/addons/model_localizer/README.md b/addons/model_localizer/README.md index e3838d9..bb65b82 100644 --- a/addons/model_localizer/README.md +++ b/addons/model_localizer/README.md @@ -1,39 +1,73 @@ -# Model Localizer +# Mesh Collider -把 FBX / GLB 拖进 Godot 4.7 编辑器后,自动生成**独立的本地 `.tscn` 场景**并从网格烘焙**凸包碰撞体**。 +把 FBX / GLB 拖进 Godot 场景,**自动给这个实例加上碰撞体**。 -针对 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` 渲染。* +面向 Godot **4.7.2**。 --- -## 它做什么 +## 2.0 改了什么 -往 3D 视口里拖入一个模型,插件会: +1.0 的版本会把模型"本地化"——把整棵节点树压平、把网格数据内嵌进一个新的 `_local.tscn`。实测代价是 **1.0–3.1× 的额外存储**,以及"模型重导出后场景里是旧几何而且不报错"的风险。 -1. **感知拖入** —— 外部文件先被移动到 `res://models/`(可配置),不再散落在 `res://` 根目录; -2. **生成本地场景** —— 在模型旁边写出 `模型名_local.tscn`,节点树完全独立,不再引用模型的导入结果; -3. **烘焙碰撞** —— 每个网格生成一个 `ConvexPolygonShape3D`,挂到 `StaticBody3D` 下; -4. **替换实例** —— 把场景里那个模型实例换成生成好的本地场景(**可 Ctrl+Z 撤销**)。 +2.0 **删掉了全部本地化逻辑**。现在只做一件事:往实例上挂碰撞。 -已经自带碰撞的模型(`-col` / `-colonly` 后缀,或导入设置里生成过碰撞)会被**原样跳过**,绝不重复添加。 +之所以这样可行,是因为一个容易被忽略的事实:**实例化的场景节点可以添加子节点**。你只是不能改它内部已有的节点,而注入的碰撞体是**新子节点** —— 它作为父场景自己的内容被序列化,不引用模型文件,也就没有膨胀。 --- ## 安装 -1. 把整个 `addons/model_localizer/` 目录复制到你项目的 `addons/` 下; -2. 打开 **项目 → 项目设置 → 插件**,启用 **Model Localizer**; -3. 插件菜单出现在 **项目 → 工具** 下。 +1. 把 `addons/mesh_collider/` 复制到你项目的 `addons/` 下; +2. **项目 → 项目设置 → 插件**,启用 **Mesh Collider**; +3. 菜单出现在 **项目 → 工具** 下。 -设置项在 **项目设置 → model_localizer**。 +设置项在 **项目设置 → mesh_collider**。 + +--- + +## 用法 + +**拖进去就行。** 插件会轮询当前编辑的场景,发现新拖入的模型实例就自动加碰撞,并且整个操作走编辑器的撤销系统 —— **Ctrl+Z 可以撤销,Ctrl+Shift+Z 可以重做**。 + +菜单里还有三个手动操作: + +| 菜单项 | 作用 | +| --- | --- | +| 给选中模型加碰撞 | 对当前选中的模型实例执行一次 | +| 移除选中模型的碰撞 | 移除本插件加的那个碰撞体(同样可撤销) | +| 设置 | 跳到项目设置页 | + +--- + +## 生成的节点结构 + +``` +Chair_PC_04 ← 模型实例,仍然引用 res://.../chair.glb +└─ CollisionBody (StaticBody3D) ← 本插件加的,属于你的场景 + └─ Chair_PC_04Shape (CollisionShape3D) +``` + +这个布局与 **Godot 自己**用 `-col` / `-convcol` 名字后缀导入时生成的完全一致。我实测过每种后缀的行为: + +| 后缀 | Godot 生成的形状 | +| --- | --- | +| `-col` / `_col` | 网格下挂 `StaticBody3D` → `ConcavePolygonShape3D`(三角网格) | +| `-convcol` / `_convcol` | 同上,形状换成 `ConvexPolygonShape3D`(凸包) | +| `-colonly` / `-convcolonly` | 网格同级生成 `@StaticBody3D@`,网格隐藏 | + +保持一致是有意的:两种机制产出的东西对你后续编辑而言是同一种东西,可以混用、可以互相替换。 + +### 那为什么不直接用 `-col`? + +`-col` **更省事,建议先用它** —— 能用就别引插件。插件的价值在这几种情况: + +- 要**按实例**调整碰撞(不同实例用不同形状/层); +- 模型是买来的成品,不想改它的名字或导入设置; +- 只想给**场景里某一个**实例加碰撞,而不是所有用这个模型的地方; +- 想在编辑器里直接看到、选中、手动微调这些碰撞节点。 --- @@ -41,149 +75,69 @@ | 设置 | 默认 | 说明 | | --- | --- | --- | -| `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` 有效**:用真实三角面构建凸包 | +| `auto_add_collision` | `true` | 自动处理拖进场景的模型 | +| `collision_shape` | `Convex Hull` | `Convex Hull` / `Trimesh` / `Box` / `Sphere` / `Capsule` / `Cylinder` | +| `collision_body` | `StaticBody3D` | `StaticBody3D` / `RigidBody3D` / `Area3D` / `CharacterBody3D` | +| `skip_meshes_with_collision` | `false` | 跳过已经位于物理体下的网格(模型自带碰撞时打开) | | `collision_layer` / `collision_mask` | `1` | 碰撞层 / 掩码 | +| `model_extensions` | `fbx glb gltf dae obj blend …` | 识别为模型的后缀 | | `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** —— 最省性能。这几种形状会按网格的实际位置自动偏移居中。 +- **Convex Hull**(默认)—— 每个网格一个凸包。性能好,可用于会移动的物体;凹形会被"填平"。 +- **Trimesh** —— 逐面精确,不做凸包简化。**只能用在静态物体上**,顶点多时开销较大。 +- **Box / Sphere / Capsule / Cylinder** —— 最省性能。这几种形状会**按每个网格的实际位置自动偏移居中**。 -### 生成的场景根节点保持单位变换 +### 模型自带碰撞时 -生成的 `.tscn` **根节点变换固定为单位阵**,拖放位置记录在**场景实例**上。这样做有两个原因: - -1. Godot 会对"根节点被变换"的场景弹出警告(`节点配置警告!建议不要变换场景的根节点`); -2. 即使你不理这个警告,Godot 里**实例自身的变换优先级高于场景内根节点的变换** —— 烘焙进根节点的位置会被静默丢弃。 - -所以位置信息放在它真正生效的地方:实例节点。序列化时 `placement_root_transform` 返回给插件,由插件负责把实例摆到正确位置。可以用 `tests/root_transform_warning_test.gd` 复核。 +如果模型是用 `-col` 后缀导入的,它内部已经有碰撞体了。这时把 `skip_meshes_with_collision` 打开,插件会跳过那些已经有碰撞的子树,避免重复。 --- -## 手动操作 +## 脚本里直接用 -**项目 → 工具** 菜单: +核心库不依赖编辑器,运行时也能用: -- **处理选中模型** —— 对场景里选中的模型实例执行一次转换; -- **重置记录** —— 清空本次会话的处理记录(想重新处理同一个模型时用); -- **设置** —— 跳到项目设置页。 +```gdscript +const MeshColliderLib := preload("res://addons/mesh_collider/mesh_collider.gd") ---- +# 运行时:直接挂上去 +var body := MeshColliderLib.add_collision( + model_instance, + MeshColliderLib.ShapeType.CONVEX_HULL, + MeshColliderLib.BodyType.STATIC) +if body != null: + print("shapes: ", MeshColliderLib.count_shapes(body)) -## 行为细节 - -- **幂等**:同一个模型在同一场景里只处理一次,不会反复生成。 -- **模型重导出自动同步**:模型的 `.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 +# 检查 / 移除 +MeshColliderLib.has_injected_collision(model_instance) # bool +MeshColliderLib.has_any_collision(model_instance) # 子树里任何碰撞 +MeshColliderLib.remove_collision(model_instance) # bool ``` -> **注意**:步骤 5/6/7 依赖真实的场景树。headless `--script` 模式下 `SceneTree.root` -> **本身不在树里**,`global_transform` 不会传播 —— 任何涉及变换传播的检查都会读到 -> 单位阵而给出**假结果**(包括假通过)。所以这类验证必须走编辑器 `--editor`, -> 或带 `--rendering-driver opengl3` 以窗口模式运行。 +编辑器里请用插件(走撤销系统)。`add_collision` 是给运行时和工具脚本用的便捷包装;**放进场景保存时**记得节点要有 `owner`,否则不会被序列化。 -### 实测结果 +--- -在 `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 | +**1. 碰撞体不会跟着动画变形。** +碰撞是按模型**静止姿态的几何**烘焙的,不是按骨骼运行时变形后的形状。这是静态烘焙的固有性质,游戏里通常也是用胶囊/凸包代替。 + +需要碰撞跟随骨骼时,把 `collision_body` 改成 `RigidBody3D` / `CharacterBody3D`,并自己用骨骼驱动它。 + +**2. 模型重新导出后,碰撞体不会自动更新。** +碰撞体是你场景里的普通节点,Godot 不会因为模型变了就重建它。**换了模型要重新生成**:先「移除选中模型的碰撞」,再「给选中模型加碰撞」即可。 + +> 这一条正是 1.0 本地化方案想解决的问题,但它的代价(1–3× 存储 + 静默过期)不值得。原生 `-col` 方案没有这个问题,因为它把碰撞生成放在导入流程里 —— 这也是我建议优先考虑它的原因。 + +--- + +## 已知取舍 + +- **检测靠轮询**(0.4 秒一次)。Godot 没有暴露"模型被拖进来"的信号,这是唯一可靠的做法。 +- **已验证的 API 兼容性**:本插件只使用在 Godot 4.7.2 上实测过的 API。踩过的坑包括 `MeshInstance3D.skeleton` 是 `NodePath` 而不是 `Skeleton3D`、`ClassDB.can_instantiate()` 才是判断节点能否容纳子节点的正确方法、以及 GDScript 不允许 `const` 字典的值引用其他常量。 --- @@ -191,15 +145,14 @@ godot --rendering-driver opengl3 --path . res://tests/physics_test.tscn -- \ ``` demo_project/ -├─ addons/model_localizer/ ← 插件本体(复制这个目录即可) +├─ addons/mesh_collider/ ← 插件本体(复制这个目录即可) │ ├─ plugin.cfg -│ ├─ model_localizer_plugin.gd 编辑器集成(探测 / 生成 / 替换) -│ ├─ model_localizer_core.gd 核心逻辑(纯逻辑,可 headless 自测) -│ ├─ localizer_settings.gd 项目设置 +│ ├─ mesh_collider_plugin.gd 编辑器集成(检测 / 注入 / 撤销) +│ ├─ mesh_collider.gd 核心逻辑(纯逻辑,不依赖编辑器) +│ ├─ mesh_collider_settings.gd 项目设置 │ └─ README.md -├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX) -├─ docs/ 截图 -└─ tests/ 验证脚本 +├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX) +└─ tests/ 辅助脚本 ``` -*Model Localizer · 面向 Godot 4.7.2* +*Mesh Collider 2.0 · 面向 Godot 4.7.2* diff --git a/addons/model_localizer/localizer_settings.gd b/addons/model_localizer/localizer_settings.gd deleted file mode 100644 index 73eb27e..0000000 --- a/addons/model_localizer/localizer_settings.gd +++ /dev/null @@ -1,228 +0,0 @@ -@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 deleted file mode 100644 index 5574bef..0000000 --- a/addons/model_localizer/localizer_settings.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://die44am1mwqfv diff --git a/addons/model_localizer/mesh_collider.gd b/addons/model_localizer/mesh_collider.gd new file mode 100644 index 0000000..106cdbc --- /dev/null +++ b/addons/model_localizer/mesh_collider.gd @@ -0,0 +1,556 @@ +@tool +class_name MeshCollider +extends RefCounted + +## Collision baking for imported 3D models. +## +## This library adds a physics body as a CHILD of a model instance that lives in +## your own scene. Because the added node is new, it belongs to the scene that +## contains the instance rather than to the imported asset - so the model file +## stays referenced once and nothing is copied or duplicated on disk. +## +## The resulting layout is deliberately identical to the one Godot's own +## `-col` / `-convcol` name-suffix import produces: +## +## ModelInstance (instance of res://.../model.glb) +## └── CollisionBody (StaticBody3D) +## ├── MeshShape (CollisionShape3D) transform = identity +## └── … +## +## Every collision shape is baked in the coordinate space of the body, so a +## CollisionShape3D never inherits a non-uniformly scaled transform from the +## model's own node hierarchy. + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Extensions treated as "an imported model". +const MODEL_EXTENSIONS: PackedStringArray = [ + "fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm", +] + +## Name of the physics body the library adds. +const BODY_NAME := "CollisionBody" + +## Metadata stamped on an added body, so it can always be identified as ours - +## by the plugin, by undo, or by a later removal pass - without guessing from +## the node name alone. +const META_OWNED := &"_mesh_collider_body" + +## Metadata on a generated shape recording how far its centre sits from the +## body's origin. Trimesh and convex shapes carry that offset inside their own +## points, but the primitive shapes are always centred on their own origin and +## need this to be placed correctly. +const META_SHAPE_OFFSET := &"_mesh_collider_shape_offset" + +enum ShapeType { + CONVEX_HULL, ## Mesh.create_convex_shape() - good for props and characters. + TRIMESH, ## Mesh.create_trimesh_shape() - exact, static bodies only. + BOX, ## Per-mesh 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 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` 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 that has actually finished importing. +static func is_imported_model(path: String) -> bool: + if not is_model_file(path): + return false + if not ResourceLoader.exists(path): + return false + # The `.import` sidecar is written once the asset is usable. + return FileAccess.file_exists(path + ".import") + + +## True when `node` is a root node instantiated directly from a model file. +static func is_model_instance(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) + + +# --------------------------------------------------------------------------- +# Collision generation +# --------------------------------------------------------------------------- + +## Build a physics body with baked collision shapes for `model`, and return it +## DETACHED, or `null` if there was nothing to add. +## +## The caller owns the returned node and is responsible for putting it in the +## tree (the editor plugin does so through the undo system, so an injection can +## be reverted with Ctrl+Z). Intended to be added as a child of `model`. +## +## `model` must be inside the scene tree: global transforms do not resolve on a +## detached node, which would silently bake every shape at the origin. The body +## itself is deliberately never parented here, because its own transform stays +## at identity and the shapes are already baked into that space. +## +## `exclude_existing_bodies` skips meshes that already sit under a physics body. +## Turn it on for models that ship their own collision (Godot's `-col` suffixes). +## +## NOTE: the shape/body defaults are written as literal enum indices rather than +## `ShapeType.CONVEX_HULL`. GDScript requires default parameter values to be +## constant expressions, and a qualified enum member is not reliably accepted - +## the same restriction that rules out `const` dictionaries referencing other +## constants. Keep these literals in step with the enum order above. +static func build_collision_body( + model: Node, + shape_type: ShapeType = 0, # ShapeType.CONVEX_HULL + body_type: BodyType = 0, # BodyType.STATIC + exclude_existing_bodies: bool = false, + collision_layer: int = 1, + collision_mask: int = 1) -> Node: + if model == null or not is_instance_valid(model): + return null + if not (model is Node3D): + return null + if _find_added_body(model) != null: + return null # already has collision from this library + if not model.is_inside_tree(): + push_warning("[MeshCollider] %s is not inside the scene tree; cannot bake " + % model.name + "collision without resolved global transforms.") + return null + + var meshes: Array[MeshInstance3D] = [] + _collect_meshes(model, meshes, exclude_existing_bodies) + if meshes.is_empty(): + return null + + var body := _make_body(body_type) + if body == null: + return null + body.name = BODY_NAME + if body is Node3D: + (body as Node3D).transform = Transform3D.IDENTITY + if body is CollisionObject3D: + (body as CollisionObject3D).collision_layer = collision_layer + (body as CollisionObject3D).collision_mask = collision_mask + body.set_meta(META_OWNED, true) + + var added := 0 + for mi in meshes: + var shape := build_shape_for_mesh(mi, body as Node3D, shape_type) + if shape == null: + continue + var cs := CollisionShape3D.new() + cs.name = _unique_child_name(body, _shape_name(mi)) + cs.shape = shape + # Primitive shapes report where their centre belongs; trimesh and convex + # hulls already carry that offset inside their own points. + cs.transform = Transform3D.IDENTITY + if shape.has_meta(META_SHAPE_OFFSET): + var offset = shape.get_meta(META_SHAPE_OFFSET) + if offset is Vector3: + cs.transform = Transform3D(Basis(), offset) + body.add_child(cs, true) + added += 1 + + if added == 0: + # Nothing usable - hand back nothing rather than an empty body. + body.free() + return null + return body + + +## Give `body` and everything under it the ownership that makes it serialise +## into the scene. Assigning `owner` only takes effect once the node is inside +## the tree, which is why this cannot happen at build time. +static func prepare_for_scene(body: Node, scene_root: Node) -> void: + if body == null or not is_instance_valid(body): + return + _stamp_owner(body, scene_root) + + +static func _stamp_owner(node: Node, scene_root: Node) -> void: + node.owner = scene_root + for child in node.get_children(): + _stamp_owner(child, scene_root) + + +## Add collision to `model` directly, without the undo system. +## +## Convenient at runtime or in a tool script. In the editor prefer the plugin, +## which routes through undo so the change can be reverted. +static func add_collision( + model: Node, + shape_type: ShapeType = 0, # ShapeType.CONVEX_HULL + body_type: BodyType = 0, # BodyType.STATIC + exclude_existing_bodies: bool = false, + collision_layer: int = 1, + collision_mask: int = 1) -> Node: + var body := build_collision_body(model, shape_type, body_type, + exclude_existing_bodies, collision_layer, collision_mask) + if body == null: + return null + model.add_child(body, true) + body.owner = model.owner + return body + + +## Remove a body previously added by `add_collision`. Returns true if a body was +## removed. +static func remove_collision(model: Node) -> bool: + var body := _find_added_body(model) + if body == null: + return false + var parent := body.get_parent() + if parent != null: + parent.remove_child(body) + body.free() + return true + + +## The body this library added to `model`, or null if there is none. +static func _find_added_body(model: Node) -> Node: + if model == null or not is_instance_valid(model): + return null + for child in model.get_children(): + if child.has_meta(META_OWNED): + return child + return null + + +## True when a body added by this library is present under `model`. +static func has_injected_collision(model: Node) -> bool: + return _find_added_body(model) != null + + +## True when anything in the subtree already provides collision. +static func has_any_collision(root: Node) -> bool: + if root == null or not is_instance_valid(root): + return false + 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 + + +## Count the collision shapes under `root`. +static func count_shapes(root: Node) -> int: + var total := 0 + var stack: Array[Node] = [root] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is CollisionShape3D and (n as CollisionShape3D).shape != null: + total += 1 + for c in n.get_children(): + stack.append(c) + return total + + +static func _collect_meshes(node: Node, out: Array[MeshInstance3D], + skip_physics_subtrees: bool) -> 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(): + # A subtree that is already a physics body brings its own collision. + if skip_physics_subtrees and (child is PhysicsBody3D or child is Area3D): + continue + if child.has_meta(META_OWNED): + continue + _collect_meshes(child, out, skip_physics_subtrees) + + +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_name(mi: MeshInstance3D) -> String: + var n := String(mi.name) + if n.is_empty(): + n = "Mesh" + return n + "Shape" + + +## A name that is not already taken by a child of `parent`. Shapes are added to +## `body` as they are named, so `has_node` alone is enough to keep them unique - +## no separate bookkeeping needed. +static func _unique_child_name(parent: Node, wanted: String) -> String: + var name := wanted + var i := 1 + while parent.has_node(NodePath(name)): + name = "%s%d" % [wanted, i] + i += 1 + return name + + +# --------------------------------------------------------------------------- +# Shape construction +# --------------------------------------------------------------------------- + +## Build one shape resource for `mi`, baked into the coordinate space of +## `body`. +## +## Returns null when no shape can be produced. The `shape_type` default is a +## literal enum index for the reason given on `build_collision_body`. +static func build_shape_for_mesh(mi: MeshInstance3D, body: Node3D, + shape_type: ShapeType = 0) -> Shape3D: # ShapeType.CONVEX_HULL + if mi == null or body == null: + return null + var mesh := mi.mesh + if mesh == null or mesh.get_surface_count() == 0: + return null + + # Bake into the body's space. `global_transform` is the anchor even for a + # skinned mesh: the mesh's own node transform carries the armature scale and + # offset, which is where the geometry is actually drawn. + var to_local := body.global_transform.affine_inverse() * mi.global_transform + + match shape_type: + ShapeType.TRIMESH: + return _trimesh_shape(mi, to_local) + ShapeType.BOX: + return _box_shape(mi, to_local) + ShapeType.SPHERE: + return _sphere_shape(mi, to_local) + ShapeType.CAPSULE: + return _capsule_shape(mi, to_local) + ShapeType.CYLINDER: + return _cylinder_shape(mi, to_local) + _: + # ConvexPolygonShape3D builds the hull from whatever points it is + # given, so the mesh's vertices are enough - and unlike + # `Mesh.get_faces()` they are also available for skinned meshes. + return _convex_shape(mi, to_local) + + +## One surface's vertices, transformed into the body's space. +static func _surface_points(mi: MeshInstance3D, surface: int, + to_local: Transform3D) -> PackedVector3Array: + var out := PackedVector3Array() + var mesh := mi.mesh + if mesh == null: + return out + var arrays := mesh.surface_get_arrays(surface) + if arrays.is_empty(): + return out + var verts = arrays[Mesh.ARRAY_VERTEX] + if not (verts is PackedVector3Array): + return out + var v: PackedVector3Array = verts + for p in v: + out.append(to_local * p) + return out + + +static func _mesh_points(mi: MeshInstance3D, to_local: Transform3D) -> PackedVector3Array: + var out := PackedVector3Array() + var mesh := mi.mesh + if mesh == null: + return out + for s in mesh.get_surface_count(): + out.append_array(_surface_points(mi, s, to_local)) + return out + + +## The mesh's AABB in the body's space. Uses the mesh's own stored AABB when it +## is trustworthy, which avoids walking every vertex for the primitive types. +static func _aabb_in_space(mi: MeshInstance3D, to_local: Transform3D) -> AABB: + var mesh := mi.mesh + if mesh == null: + return AABB() + # A skinned mesh's stored AABB is in bind space, so it does not describe the + # posed geometry; fall back to the real vertices for those. + if mi.skin != null and not mi.skeleton.is_empty(): + return _aabb_of(_mesh_points(mi, to_local)) + return _aabb_of_transformed(mesh.get_aabb(), to_local) + + +static func _aabb_of_transformed(box: AABB, xform: Transform3D) -> AABB: + var pts := PackedVector3Array() + for corner in _aabb_corners(box): + pts.append(xform * corner) + return _aabb_of(pts) + + +static func _aabb_of(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, + ] + + +static func _convex_shape(mi: MeshInstance3D, to_local: Transform3D) -> ConvexPolygonShape3D: + var points := _mesh_points(mi, to_local) + if points.size() < 4: + # A hull needs at least a tetrahedron's worth of points. + return null + var shape := ConvexPolygonShape3D.new() + shape.points = points + return shape + + +static func _trimesh_shape(mi: MeshInstance3D, to_local: Transform3D) -> 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 + # Gather this surface's points once, then index into them so triangle + # order and count are preserved exactly. + var points := _surface_points(mi, s, to_local) + if points.is_empty(): + continue + var idx = arrays[Mesh.ARRAY_INDEX] + if idx is PackedInt32Array and (idx as PackedInt32Array).size() >= 3: + var indices: PackedInt32Array = idx + 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) -> BoxShape3D: + var box := _aabb_in_space(mi, to_local) + if box.size.length_squared() <= 0.0: + return null + 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) -> SphereShape3D: + var box := _aabb_in_space(mi, to_local) + 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) -> CapsuleShape3D: + var box := _aabb_in_space(mi, to_local) + var s := box.size + # Godot's capsule is Y-up: the two largest horizontal axes give the radius. + 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) -> CylinderShape3D: + var box := _aabb_in_space(mi, to_local) + 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 + + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- + +## A one-line description of the collision under `root`, for log output. +static func describe_collision(root: Node) -> String: + var shapes := count_shapes(root) + if shapes == 0: + return "no collision" + var kinds: Dictionary = {} + var stack: Array[Node] = [root] + while not stack.is_empty(): + var n: Node = stack.pop_back() + if n is CollisionShape3D and (n as CollisionShape3D).shape != null: + var k := (n as CollisionShape3D).shape.get_class() + kinds[k] = int(kinds.get(k, 0)) + 1 + for c in n.get_children(): + stack.append(c) + var parts: Array[String] = [] + for k in kinds: + parts.append("%s x%d" % [k, kinds[k]]) + return "%d shape(s): %s" % [shapes, ", ".join(parts)] diff --git a/addons/model_localizer/mesh_collider.gd.uid b/addons/model_localizer/mesh_collider.gd.uid new file mode 100644 index 0000000..87d1ac9 --- /dev/null +++ b/addons/model_localizer/mesh_collider.gd.uid @@ -0,0 +1 @@ +uid://c8dekkagt8ty7 diff --git a/addons/model_localizer/mesh_collider_plugin.gd b/addons/model_localizer/mesh_collider_plugin.gd new file mode 100644 index 0000000..64257d9 --- /dev/null +++ b/addons/model_localizer/mesh_collider_plugin.gd @@ -0,0 +1,225 @@ +@tool +extends EditorPlugin + +## Mesh Collider - editor integration. +## +## Drag an FBX/GLB into a scene and the plugin adds a physics body with baked +## collision shapes to that instance. Nothing is written to disk and no copy of +## the model is made: the added nodes belong to the scene you are editing, so the +## model file stays referenced exactly once. +## +## Everything the plugin does goes through the editor's undo system, so Ctrl+Z +## reverts an injection and Ctrl+Shift+Z re-applies it. + +const Collider := preload("mesh_collider.gd") +const Settings := preload("mesh_collider_settings.gd") + +const MENU_ADD := "Mesh Collider: 给选中模型加碰撞 / Add collision to selected" +const MENU_REMOVE := "Mesh Collider: 移除选中模型的碰撞 / Remove collision" +const MENU_SETTINGS := "Mesh Collider: 设置 / Settings" + +## How often the edited scene is scanned for freshly dropped models. Detection +## needs polling because Godot exposes no "a model was dragged in" signal. +const SCAN_INTERVAL_SEC := 0.4 + +var _scan_accum := 0.0 +var _needs_scan := false +## Instances already handled this session (instance id -> true), so a model is +## processed once and never re-processed while the editor stays open. +var _handled: Dictionary = {} + + +func _enter_tree() -> void: + Settings.install() + add_tool_menu_item(MENU_ADD, _add_pressed) + add_tool_menu_item(MENU_REMOVE, _remove_pressed) + add_tool_menu_item(MENU_SETTINGS, _open_settings) + get_tree().node_added.connect(_on_node_added) + _needs_scan = true + print("[MeshCollider] ready. Settings live in Project Settings > mesh_collider.") + + +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_ADD) + remove_tool_menu_item(MENU_REMOVE) + remove_tool_menu_item(MENU_SETTINGS) + + +func _on_node_added(node: Node) -> void: + # Cheap pre-filter: only a 3D node can be a model root worth scanning for. + 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 + if Settings.get_bool(Settings.KEY_AUTO): + _scan_scene() + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +## Walk the edited scene for model instances that have not been handled yet. +func _scan_scene() -> void: + var scene_root := get_editor_interface().get_edited_scene_root() + if scene_root == null: + return + var found: Array[Node] = [] + _collect_model_instances(scene_root, found) + for node in found: + if _handled.has(node.get_instance_id()): + continue + _handle(node) + + +func _collect_model_instances(node: Node, out: Array[Node]) -> void: + for child in node.get_children(): + if child is Node3D and _is_configured_model_instance(child): + out.append(child) + _collect_model_instances(child, out) + + +## A model instance we should act on: instantiated straight from a configured +## model file, and not already carrying one of our bodies. +func _is_configured_model_instance(node: Node) -> bool: + if node.scene_file_path.is_empty(): + return false + if not Settings.is_configured_model(node.scene_file_path): + return false + if not ResourceLoader.exists(node.scene_file_path): + return false # not imported yet + return not Collider.has_injected_collision(node) + + +func _handle(node: Node) -> void: + if not is_instance_valid(node): + return + _handled[node.get_instance_id()] = true + _inject(node) + + +# --------------------------------------------------------------------------- +# Injection +# --------------------------------------------------------------------------- + +## Add collision to `model` through the undo system. +func _inject(model: Node, source: String = "") -> void: + if not (model is Node3D): + return + if Collider.has_injected_collision(model): + return + if source.is_empty(): + source = String(model.scene_file_path) + + var skip_existing := Settings.get_bool(Settings.KEY_SKIP_EXISTING) + if skip_existing and Collider.has_any_collision(model): + print("[MeshCollider] %s already has collision; skipped." % model.name) + return + + # Built detached on purpose: the editor's undo system owns every tree change + # in this plugin, so inserting the body here would mean undoing a change we + # had already applied behind undo's back. + var body := Collider.build_collision_body( + model, + Settings.shape_type(), + Settings.body_type(), + skip_existing, + Settings.get_int(Settings.KEY_LAYER), + Settings.get_int(Settings.KEY_MASK)) + if body == null: + print("[MeshCollider] %s: no usable mesh geometry found." % model.name) + return + + var scene_root := get_editor_interface().get_edited_scene_root() + var undo := get_undo_redo() + undo.create_action("Mesh Collider: add collision to %s" % model.name) + undo.add_do_method(model, "add_child", body, true) + # `owner` can only be assigned once the body is inside the tree - without it + # Godot would not serialise the body or its shapes into the saved scene. + undo.add_do_method(Collider, "prepare_for_scene", body, scene_root) + undo.add_do_reference(body) + undo.add_undo_method(model, "remove_child", body) + undo.commit_action() + + get_editor_interface().mark_scene_as_unsaved() + print("[MeshCollider] %s (%s): added %s with %d shape(s)" + % [model.name, source.get_file(), body.get_class(), Collider.count_shapes(body)]) + + +## Remove the collision this plugin added to `model`, undoably. +func _remove(model: Node) -> void: + if not is_instance_valid(model): + return + var body := _find_added_body(model) + if body == null: + print("[MeshCollider] %s has no collision added by this plugin." % model.name) + return + var scene_root := get_editor_interface().get_edited_scene_root() + var undo := get_undo_redo() + undo.create_action("Mesh Collider: remove collision from %s" % model.name) + undo.add_do_method(model, "remove_child", body) + undo.add_undo_method(model, "add_child", body, true) + undo.add_undo_method(body, "set_owner", scene_root) + # Keep the object alive so redo can put the same body back. + undo.add_undo_reference(body) + undo.commit_action() + get_editor_interface().mark_scene_as_unsaved() + print("[MeshCollider] %s: collision removed." % model.name) + + +func _find_added_body(model: Node) -> Node: + if not is_instance_valid(model): + return null + for child in model.get_children(): + if child.has_meta(Collider.META_OWNED): + return child + return null + + +# --------------------------------------------------------------------------- +# Menu actions +# --------------------------------------------------------------------------- + +func _add_pressed() -> void: + var targets := _selected_models() + for model in targets: + _handled[model.get_instance_id()] = true + _inject(model) + if targets.is_empty(): + print("[MeshCollider] 先选中一个拖入的 FBX/GLB 实例 / " + + "select a model instance first") + + +func _remove_pressed() -> void: + var targets := _selected_models() + for model in targets: + _remove(model) + if targets.is_empty(): + print("[MeshCollider] 先选中一个模型实例 / select a model instance first") + + +## Selected nodes that are model instances. Copied into a fresh array, because +## injection modifies the tree while the selection array is still live. +func _selected_models() -> Array[Node]: + var out: Array[Node] = [] + var selection := get_editor_interface().get_selection().get_selected_nodes() + for node in selection: + if node is Node3D and not String(node.scene_file_path).is_empty() \ + and Settings.is_configured_model(node.scene_file_path): + out.append(node) + return out + + +func _open_settings() -> void: + # The settings are ordinary project settings; point the user at them rather + # than inventing a second place to configure the same values. + get_editor_interface().call_deferred("set_main_screen_editor", "Project") + print("[MeshCollider] settings are under Project Settings > mesh_collider") diff --git a/addons/model_localizer/mesh_collider_plugin.gd.uid b/addons/model_localizer/mesh_collider_plugin.gd.uid new file mode 100644 index 0000000..b7951cd --- /dev/null +++ b/addons/model_localizer/mesh_collider_plugin.gd.uid @@ -0,0 +1 @@ +uid://c7bgv57rbnt8f diff --git a/addons/model_localizer/mesh_collider_settings.gd b/addons/model_localizer/mesh_collider_settings.gd new file mode 100644 index 0000000..7ff7a9e --- /dev/null +++ b/addons/model_localizer/mesh_collider_settings.gd @@ -0,0 +1,183 @@ +@tool +class_name MeshColliderSettings +extends RefCounted + +## Configuration for Mesh Collider. +## +## Everything lives in `project.godot` under `mesh_collider/*`, so it is +## per-project and shows up in **Project Settings → Mesh Collider**. + +const ROOT := "mesh_collider/" + +const KEY_AUTO := ROOT + "auto_add_collision" +const KEY_SHAPE := ROOT + "collision_shape" +const KEY_BODY := ROOT + "collision_body" +const KEY_SKIP_EXISTING := ROOT + "skip_meshes_with_collision" +const KEY_LAYER := ROOT + "collision_layer" +const KEY_MASK := ROOT + "collision_mask" +const KEY_EXTENSIONS := ROOT + "model_extensions" +const KEY_SKIP_DIRS := ROOT + "skip_dirs" + +## NOTE: a function rather than a `const` dictionary on purpose. GDScript +## requires a const dictionary's *values* to be constant expressions, so a +## PackedStringArray cannot be built inside one ("isn't a constant expression"). +## +## The enum-backed defaults below are written as literal indices for the same +## reason applied one level out: they must not depend on another script's enum +## resolving. Keep them in step with MeshCollider's enum order. +static func project_info() -> Dictionary: + return { + KEY_AUTO: { + "name": KEY_AUTO, + "type": TYPE_BOOL, + "default": true, + "hint_string": "Add collision automatically to models dragged into a scene", + }, + KEY_SHAPE: { + "name": KEY_SHAPE, + "type": TYPE_INT, + "default": 0, # MeshCollider.ShapeType.CONVEX_HULL + "hint": PROPERTY_HINT_ENUM, + "hint_string": "Convex Hull,Trimesh,Box,Sphere,Capsule,Cylinder", + }, + KEY_BODY: { + "name": KEY_BODY, + "type": TYPE_INT, + "default": 0, # MeshCollider.BodyType.STATIC + "hint": PROPERTY_HINT_ENUM, + "hint_string": "StaticBody3D,RigidBody3D,Area3D,CharacterBody3D", + }, + KEY_SKIP_EXISTING: { + "name": KEY_SKIP_EXISTING, + "type": TYPE_BOOL, + "default": false, + "hint_string": "Skip meshes that already sit under a physics body " + + "(turn on for models that ship their own collision)", + }, + 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_EXTENSIONS: { + "name": KEY_EXTENSIONS, + "type": TYPE_PACKED_STRING_ARRAY, + "default": PackedStringArray([ + "fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm", + ]), + "hint": PROPERTY_HINT_ARRAY_TYPE, + "hint_string": "String", + }, + 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", + }, + } + + +## Cached copy of `project_info()`. The dictionary is rebuilt from scratch on +## every call, and the getters below are called on every scan, so keeping one +## instance avoids pointless allocation. +static var _cached_info: Dictionary = {} + + +static func _all_info() -> Dictionary: + if _cached_info.is_empty(): + _cached_info = project_info() + return _cached_info + + +static func _info(key: String) -> Dictionary: + var all := _all_info() + if all.has(key): + return all[key] + return {"name": key, "type": TYPE_NIL, "default": null} + + +## Register every setting with ProjectSettings, so the values appear in the UI +## and carry a usable default even before the user touches anything. +static func install() -> void: + var all := _all_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) + # Record 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_strings(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) + + +## The configured shape type, clamped to the valid enum range. +static func shape_type() -> MeshCollider.ShapeType: + return clampi(get_int(KEY_SHAPE), 0, + MeshCollider.SHAPE_TYPE_NAMES.size() - 1) as MeshCollider.ShapeType + + +## The configured body type, clamped to the valid enum range. +static func body_type() -> MeshCollider.BodyType: + return clampi(get_int(KEY_BODY), 0, + MeshCollider.BODY_TYPE_NAMES.size() - 1) as MeshCollider.BodyType + + +## True when `path` looks like a model, according to the configured extensions. +static func is_configured_model(path: String) -> bool: + if path.is_empty(): + return false + var ext := path.get_extension().to_lower() + if ext.is_empty(): + return false + var configured := get_strings(KEY_EXTENSIONS) + if configured.is_empty(): + return MeshCollider.is_model_file(path) + for e in configured: + if String(e).to_lower().trim_prefix(".") == ext: + return true + return false + + +## True when `path` is inside a directory the plugin must never scan. +static func should_skip(path: String) -> bool: + var p := path.replace("\\", "/") + for dir in get_strings(KEY_SKIP_DIRS): + var d := String(dir).replace("\\", "/") + if d.is_empty(): + continue + if not d.ends_with("/"): + d += "/" + if p.begins_with(d): + return true + return false diff --git a/addons/model_localizer/mesh_collider_settings.gd.uid b/addons/model_localizer/mesh_collider_settings.gd.uid new file mode 100644 index 0000000..1661651 --- /dev/null +++ b/addons/model_localizer/mesh_collider_settings.gd.uid @@ -0,0 +1 @@ +uid://cd0a87lji34em diff --git a/addons/model_localizer/model_localizer_core.gd b/addons/model_localizer/model_localizer_core.gd deleted file mode 100644 index 27782a7..0000000 --- a/addons/model_localizer/model_localizer_core.gd +++ /dev/null @@ -1,907 +0,0 @@ -@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 deleted file mode 100644 index 063debe..0000000 --- a/addons/model_localizer/model_localizer_core.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bj72byy8q4bd4 diff --git a/addons/model_localizer/model_localizer_plugin.gd b/addons/model_localizer/model_localizer_plugin.gd deleted file mode 100644 index af7320b..0000000 --- a/addons/model_localizer/model_localizer_plugin.gd +++ /dev/null @@ -1,424 +0,0 @@ -@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 deleted file mode 100644 index 747c2a3..0000000 --- a/addons/model_localizer/model_localizer_plugin.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cjeaepk5iqnga diff --git a/addons/model_localizer/plugin.cfg b/addons/model_localizer/plugin.cfg index b9620e7..2efdd53 100644 --- a/addons/model_localizer/plugin.cfg +++ b/addons/model_localizer/plugin.cfg @@ -1,7 +1,7 @@ [plugin] -name="Model Localizer" -description="把拖入的 FBX/GLB 模型自动转成独立的本地 .tscn 场景,并从网格烘焙凸包碰撞体。Drop an FBX/GLB and get a standalone local scene with collision." +name="Mesh Collider" +description="给拖入场景的 FBX/GLB 模型自动添加碰撞体,不复制模型、不生成新场景。Adds collision to dragged-in models without duplicating the model or creating a new scene." author="yvmou" -version="1.0.0" -script="model_localizer_plugin.gd" +version="2.0.0" +script="mesh_collider_plugin.gd"