插件
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
# Model Localizer
|
||||
|
||||
把 FBX / GLB 拖进 Godot 4.7 编辑器后,自动生成**独立的本地 `.tscn` 场景**并从网格烘焙**凸包碰撞体**。
|
||||
|
||||
针对 Godot **4.7.2** 编写并实测。
|
||||
|
||||

|
||||
|
||||
*由 `Chair_PC_04.glb` 生成的 `Chair_PC_04_local.tscn`,几何、材质、贴图完整。*
|
||||
|
||||

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