ModelTranslator: QR watertightness gate + quad default
根因(真机排查):QuadRemesher 引擎在激进减面下非确定性地从封闭流形 输入打出孔洞/非流形边(实测同一输入约 85% 次数破碎),即"破碎面"。 修复:QR 导回后比对输入拓扑,若比输入新增开边(孔洞)/非流形边即判破损、 弃用 QR 回退 collapse(topology_acceptable 纯策略+单测;_edge_defects 计数)。 顺带修复水密门在导入后拒绝时未恢复 obj 选中态,导致 collapse 回退时 export use_selection 选不到对象、只导出 4KB 空 FBX 的 bug。 含用户改动:--reducer 默认改为 quad(配合水密门,破碎自动回退,安全), 及其 CLI 默认值测试。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
44060ac3cb
commit
0481f10b8c
@@ -46,8 +46,8 @@ python model_bake.py src/well1500.fbx src/well1500_low.fbx # -> out_bake/well1
|
|||||||
python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源)
|
python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **model_decimate.py**:多 mesh 自动 join;**减面前清理网格**(按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形、减少被迫加的 seam);三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;**`--max-overlap 0.15` 放宽重叠门限**,让更高 seam 角度(岛更少)能过门被选中——岛数优先、可接受略高重叠时用(翻转门限始终严格);排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次,旋转步进调细到 15° 以利薄斜条对齐提升利用率;UVPM 启发式搜索在 headless 下会随机崩溃引擎故未启用),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
- **model_decimate.py**:多 mesh 自动 join;**减面前清理网格**(按局部包围盒对角线相对焊接重合点 + 去退化面,给后端更干净的流形、减少被迫加的 seam);按 `--reducer` 后端减到 `--tris`(默认 Quad Remesher,失败自动回退 Decimate collapse;collapse 后端为 ±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;**`--max-overlap 0.15` 放宽重叠门限**,让更高 seam 角度(岛更少)能过门被选中——岛数优先、可接受略高重叠时用(翻转门限始终严格);排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次,旋转步进调细到 15° 以利薄斜条对齐提升利用率;UVPM 启发式搜索在 headless 下会随机崩溃引擎故未启用),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
||||||
- **`--reducer collapse|quad`**(默认 `collapse`,不改变既有行为):减面后端。`quad` 用 **Quad Remesher** 出规整四边拓扑(针对 collapse 的碎三角/UV 展开难痛点),headless 下**直接调 `Engine/xremesh.exe` 引擎子进程**(导出选中 mesh → 写 RetopoSettings.txt → 阻塞轮询 progress.txt → 导回 retopo.fbx),**绕开插件的 modal 操作符**(modal 在 `blender -b` 后台不执行,直接调操作符必然失败)。面数按 `--tris ÷ 2` 换算为目标四边形数,用 `ExactQuadCount=1` 尊重目标数(自适应模式在高细节硬表面模型上会把面数炸开数倍并连带 UV 重叠爆炸,故禁用);输出为近似面数(约 ±20%)。**任何失败——引擎缺失/超时/引擎报错/无有效输出/导回失败——都记警告并自动回退 collapse**,摘要 `reducer` 字段标 `quad->collapse`,绝不中断。`QR_ENGINE` 环境变量可覆盖引擎路径(**权威**:显式设了就以它为准,无效即视同缺失走回退)。实测 well1500:quad 得 5940 面/591 UV 岛(优于 collapse 的 745 岛、重叠 0.4% vs 2.4%);QR 网格 seam 展开在部分资产上可能退回 smart_project,但结果仍具竞争力
|
- **`--reducer collapse|quad`**(默认 `quad`):减面后端。`quad` 用 **Quad Remesher** 出规整四边拓扑(针对 collapse 的碎三角/UV 展开难痛点),headless 下**直接调 `Engine/xremesh.exe` 引擎子进程**(导出选中 mesh → 写 RetopoSettings.txt → 阻塞轮询 progress.txt → 导回 retopo.fbx),**绕开插件的 modal 操作符**(modal 在 `blender -b` 后台不执行,直接调操作符必然失败)。面数按 `--tris ÷ 2` 换算为目标四边形数,用 `ExactQuadCount=1` 尊重目标数(自适应模式在高细节硬表面模型上会把面数炸开数倍并连带 UV 重叠爆炸,故禁用);输出为近似面数(约 ±20%)。**任何失败——引擎缺失/超时/引擎报错/无有效输出/导回失败——都记警告并自动回退 collapse**,摘要 `reducer` 字段标 `quad->collapse`,绝不中断。**水密门**:QR 引擎在激进减面下会*非确定性*地打出孔洞/非流形边(同一封闭输入实测约 85% 次数破碎),故导回后比对输入拓扑——QR 若比输入新增开边(孔洞)/非流形边即判破损、弃用 QR 回退 collapse(保证减面输出的拓扑完整性不劣于源模,杜绝"破碎面")。`QR_ENGINE` 环境变量可覆盖引擎路径(**权威**:显式设了就以它为准,无效即视同缺失走回退)。因此 quad 只在 QR 确实产出干净水密网格时才真正生效,否则安全回退 collapse;QR 更适合有机/密网资产而非激进减面
|
||||||
- **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃
|
- **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃
|
||||||
- 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定
|
- 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,22 @@ def _decimate(obj, ratio):
|
|||||||
_apply_modifier(obj, mod)
|
_apply_modifier(obj, mod)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_defects(obj):
|
||||||
|
"""(开边数, 非流形边数):开边=只挂 1 面(孔洞边界),非流形=挂 >2 面。
|
||||||
|
用于 QR 输出水密门——QR 引擎在激进减面下会非确定性地打出孔洞/非流形。"""
|
||||||
|
import bmesh
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_mesh(obj.data)
|
||||||
|
boundary = sum(1 for e in bm.edges if len(e.link_faces) == 1)
|
||||||
|
nonman = sum(1 for e in bm.edges if len(e.link_faces) > 2)
|
||||||
|
bm.free()
|
||||||
|
return boundary, nonman
|
||||||
|
|
||||||
|
|
||||||
def _remesh_quad(obj, target_tris, warnings):
|
def _remesh_quad(obj, target_tris, warnings):
|
||||||
"""Quad Remesher 重拓扑 obj(headless 直调 xremesh.exe,绕开 modal 操作符)。
|
"""Quad Remesher 重拓扑 obj(headless 直调 xremesh.exe,绕开 modal 操作符)。
|
||||||
成功返回 True,并把导回的 quad 网格设为新 active object(原 obj 已删);
|
成功返回 True,并把导回的 quad 网格设为新 active object(原 obj 已删);
|
||||||
任何失败记 warning 返回 False,交上层回退 collapse。"""
|
任何失败——含 QR 输出破损未过水密门——记 warning 返回 False,交上层回退 collapse。"""
|
||||||
import bpy
|
import bpy
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -142,6 +154,7 @@ def _remesh_quad(obj, target_tris, warnings):
|
|||||||
bpy.ops.object.select_all(action='DESELECT')
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
obj.select_set(True)
|
obj.select_set(True)
|
||||||
bpy.context.view_layer.objects.active = obj
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
src_boundary, src_nonman = _edge_defects(obj) # QR 输入拓扑基线
|
||||||
bpy.ops.export_scene.fbx(filepath=in_fbx, use_selection=True)
|
bpy.ops.export_scene.fbx(filepath=in_fbx, use_selection=True)
|
||||||
target_quads = qr_bridge.tris_to_target_quads(target_tris)
|
target_quads = qr_bridge.tris_to_target_quads(target_tris)
|
||||||
with open(settings, "w") as fp:
|
with open(settings, "w") as fp:
|
||||||
@@ -197,6 +210,18 @@ def _remesh_quad(obj, target_tris, warnings):
|
|||||||
warnings.append("QR 导回无 mesh,回退 collapse")
|
warnings.append("QR 导回无 mesh,回退 collapse")
|
||||||
return False
|
return False
|
||||||
retopo = new[0]
|
retopo = new[0]
|
||||||
|
# 水密门:QR 引擎非确定性地会打出孔洞/非流形,破损即弃用 QR 回退 collapse
|
||||||
|
qr_boundary, qr_nonman = _edge_defects(retopo)
|
||||||
|
if not qr_bridge.topology_acceptable(src_boundary, src_nonman,
|
||||||
|
qr_boundary, qr_nonman):
|
||||||
|
warnings.append("QR 输出拓扑破损(开边 %d→%d,非流形 %d→%d),回退 collapse"
|
||||||
|
% (src_boundary, qr_boundary, src_nonman, qr_nonman))
|
||||||
|
bpy.data.objects.remove(retopo, do_unlink=True) # 弃坏网格,原 obj 完好
|
||||||
|
# FBX 导入把选中挪给了 retopo;恢复 obj 选中+active,供 collapse 与最终导出
|
||||||
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
return False
|
||||||
bpy.ops.object.select_all(action='DESELECT')
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
retopo.select_set(True)
|
retopo.select_set(True)
|
||||||
bpy.context.view_layer.objects.active = retopo
|
bpy.context.view_layer.objects.active = retopo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
|
"""FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
|
||||||
用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir]
|
用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir]
|
||||||
[--unwrap seam|smart] [--max-overlap 0.15] [--blender exe]"""
|
[--unwrap seam|smart] [--reducer collapse|quad] [--max-overlap 0.15] [--blender exe]"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ def main():
|
|||||||
ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)")
|
ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)")
|
||||||
ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
|
ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
|
||||||
help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")
|
help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")
|
||||||
ap.add_argument("--reducer", choices=("collapse", "quad"), default="collapse",
|
ap.add_argument("--reducer", choices=("collapse", "quad"), default="quad",
|
||||||
help="减面后端:collapse=Decimate 塌陷(默认),quad=Quad Remesher 四边重拓扑(失败自动回退 collapse)")
|
help="减面后端:quad=Quad Remesher 四边重拓扑(默认,失败自动回退 collapse),collapse=Decimate 塌陷")
|
||||||
ap.add_argument("--max-overlap", type=float, default=None,
|
ap.add_argument("--max-overlap", type=float, default=None,
|
||||||
help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛")
|
help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛")
|
||||||
ap.add_argument("--blender", default=None)
|
ap.add_argument("--blender", default=None)
|
||||||
|
|||||||
@@ -62,3 +62,11 @@ def parse_progress(text):
|
|||||||
if v < 0:
|
if v < 0:
|
||||||
return ("error", lines[1] if len(lines) > 1 else "")
|
return ("error", lines[1] if len(lines) > 1 else "")
|
||||||
return ("running", "")
|
return ("running", "")
|
||||||
|
|
||||||
|
|
||||||
|
def topology_acceptable(src_boundary, src_nonmanifold,
|
||||||
|
out_boundary, out_nonmanifold):
|
||||||
|
"""QR 输出拓扑是否可接受:不比输入引入更多开边(孔洞)/非流形边。
|
||||||
|
QR 引擎在激进减面下会非确定性地打出孔洞——封闭输入(0/0)时要求输出也 0/0,
|
||||||
|
输入本身开放(壳)时按其基线放行;否则判破损,交上层回退 collapse。"""
|
||||||
|
return out_boundary <= src_boundary and out_nonmanifold <= src_nonmanifold
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import model_decimate
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelDecimateCli(unittest.TestCase):
|
||||||
|
def test_default_reducer_is_quad(self):
|
||||||
|
summary = {
|
||||||
|
"src": "store.fbx",
|
||||||
|
"tris_before": 49966,
|
||||||
|
"tris_after": 3000,
|
||||||
|
"target": 3000,
|
||||||
|
"reducer": "quad",
|
||||||
|
"uv": {
|
||||||
|
"mode": "seam",
|
||||||
|
"islands": 1,
|
||||||
|
"fill": 1.0,
|
||||||
|
"flipped": 0.0,
|
||||||
|
"overlap": 0.0,
|
||||||
|
},
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
argv = ["model_decimate.py", "store.fbx", "--tris", "3000"]
|
||||||
|
|
||||||
|
with mock.patch.object(sys, "argv", argv), \
|
||||||
|
mock.patch.object(model_decimate, "find_blender", return_value="blender"), \
|
||||||
|
mock.patch.object(model_decimate, "run_blender_script", return_value=summary) as run:
|
||||||
|
model_decimate.main()
|
||||||
|
|
||||||
|
args = run.call_args.args[2]
|
||||||
|
self.assertEqual(args[-1], "quad")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -116,5 +116,30 @@ class TestFindQrEngine(unittest.TestCase):
|
|||||||
self.assertIn(os.path.join("5.0", "scripts"), chosen)
|
self.assertIn(os.path.join("5.0", "scripts"), chosen)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTopologyAcceptable(unittest.TestCase):
|
||||||
|
def test_closed_source_clean_qr_ok(self):
|
||||||
|
# 封闭输入(0/0) + 水密 QR(0/0) -> 接受
|
||||||
|
self.assertTrue(qb.topology_acceptable(0, 0, 0, 0))
|
||||||
|
|
||||||
|
def test_closed_source_holey_qr_rejected(self):
|
||||||
|
# 封闭输入被 QR 打出开边 -> 拒绝
|
||||||
|
self.assertFalse(qb.topology_acceptable(0, 0, 8, 0))
|
||||||
|
|
||||||
|
def test_closed_source_nonmanifold_qr_rejected(self):
|
||||||
|
self.assertFalse(qb.topology_acceptable(0, 0, 0, 1))
|
||||||
|
|
||||||
|
def test_open_shell_source_same_ok(self):
|
||||||
|
# 输入本身是开放壳(10 开边),QR 未新增 -> 接受
|
||||||
|
self.assertTrue(qb.topology_acceptable(10, 0, 10, 0))
|
||||||
|
|
||||||
|
def test_open_shell_source_worse_rejected(self):
|
||||||
|
# QR 比输入引入更多开边 -> 拒绝
|
||||||
|
self.assertFalse(qb.topology_acceptable(10, 0, 12, 0))
|
||||||
|
|
||||||
|
def test_qr_fewer_defects_ok(self):
|
||||||
|
# QR 输出反而更少缺陷(不劣于输入)-> 接受
|
||||||
|
self.assertTrue(qb.topology_acceptable(10, 2, 8, 1))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user