From ac99d104c04669f00e6137386eae5f3bf1ee47fc Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 22 Jul 2026 17:29:36 +0800 Subject: [PATCH] ModelTranslator: document QR quad remesh backend Co-Authored-By: Claude Opus 4.8 --- Tools/ModelTranslator/README.md | 1 + ...026-07-22-quadremesher-decimate-backend.md | 630 ++++++++++++++++++ ...22-quadremesher-decimate-backend-design.md | 137 ++++ 3 files changed, 768 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md create mode 100644 docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md diff --git a/Tools/ModelTranslator/README.md b/Tools/ModelTranslator/README.md index 4b3d5c31..8eb04faf 100644 --- a/Tools/ModelTranslator/README.md +++ b/Tools/ModelTranslator/README.md @@ -47,6 +47,7 @@ python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/ ``` - **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 角度 +- **`--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,但结果仍具竞争力 - **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`,正好命中转换器的纯网格贴图命名约定 diff --git a/docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md b/docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md new file mode 100644 index 00000000..198df558 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md @@ -0,0 +1,630 @@ +# Quad Remesher 减面后端 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 给 `model_decimate.py` 加可选减面后端 `--reducer collapse|quad`,`quad` 用 Quad Remesher 出规整四边拓扑,失败自动回退 collapse,默认行为零变化。 + +**Architecture:** 新增纯模块 `qr_bridge.py`(引擎发现/面数换算/settings 生成/progress 解析,可 TDD);`bl_decimate.py` 加 `_remesh_quad()` 在 headless 直接跑 `xremesh.exe` 子进程(绕开后台不可用的 modal 操作符),`main()` 按 `reducer` 分流;`model_decimate.py` 加 CLI 开关透传。 + +**Tech Stack:** Python 3 标准库、Blender 5.0 `bpy`、`unittest`、现有 headless Blender runner(`mt_run.py`)。 + +设计依据:`docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md` + +## Global Constraints + +- 不加外部依赖。 +- 不覆盖输入 FBX;重拓扑数据只进生成的 `_low.fbx`。 +- `--reducer` 默认 `collapse`,现有 CLI/输出/collapse 路径行为零变化。 +- QR 任何失败(引擎缺失/超时/负值/空输出/导回无 mesh)记 warning 并回退 collapse,绝不中断。 +- 面数:`target_quads = tris // 2`,接受近似,不做二次 collapse。 +- 不 stage `Tools/ModelTranslator/src`、bake 产物、Unity 资源等脏工作区文件;只提交本计划涉及的源码/文档。 +- Blender 路径:本机在 `D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`,真机验证用 `--blender` 显式传或设 `BLENDER_EXE`。 + +## File Map + +- Create `Tools/ModelTranslator/qr_bridge.py`:QR 桥纯逻辑,不 import bpy。 +- Create `Tools/ModelTranslator/tests/test_qr_bridge.py`:纯函数单测。 +- Modify `Tools/ModelTranslator/bl_decimate.py`:加 `_remesh_quad()` 与 `main()` 分流、`MT_SUMMARY.reducer`。 +- Modify `Tools/ModelTranslator/model_decimate.py`:加 `--reducer`,透传并打印后端。 +- Modify `Tools/ModelTranslator/README.md`:记录 `--reducer` 与回退语义。 + +--- + +### Task 1: QR 桥纯逻辑模块 + +**Files:** +- Create: `Tools/ModelTranslator/qr_bridge.py` +- Create: `Tools/ModelTranslator/tests/test_qr_bridge.py` + +**Interfaces:** +- `find_qr_engine() -> str | None` +- `tris_to_target_quads(tris) -> int` +- `build_settings(in_fbx, out_fbx, prog, target_quads, *, adaptive=50, exact=False, hard_edges=True) -> str` +- `parse_progress(text) -> (state, msg)`,`state ∈ {"running","success","error"}` +- 常量 `QR_TIMEOUT` + +- [ ] **Step 1: 写失败单测** + +Create `Tools/ModelTranslator/tests/test_qr_bridge.py`: + +```python +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import qr_bridge as qb + + +class TestTrisToTargetQuads(unittest.TestCase): + def test_half(self): + self.assertEqual(qb.tris_to_target_quads(10000), 5000) + + def test_odd_floors(self): + self.assertEqual(qb.tris_to_target_quads(3), 1) + + def test_at_least_one(self): + self.assertEqual(qb.tris_to_target_quads(1), 1) + self.assertEqual(qb.tris_to_target_quads(0), 1) + + +class TestBuildSettings(unittest.TestCase): + def _s(self, **kw): + return qb.build_settings("/i.fbx", "/o.fbx", "/p.txt", 500, **kw) + + def test_core_lines_present(self): + s = self._s() + self.assertIn('FileIn="/i.fbx"', s) + self.assertIn('FileOut="/o.fbx"', s) + self.assertIn('ProgressFile="/p.txt"', s) + self.assertIn("TargetQuadCount=500", s) + self.assertIn("HostApp=Blender", s) + self.assertTrue(s.endswith("\n")) + + def test_hard_edges_toggle(self): + self.assertIn("AutoDetectHardEdges=1", self._s(hard_edges=True)) + self.assertIn("AutoDetectHardEdges=0", self._s(hard_edges=False)) + + def test_exact_toggle(self): + self.assertIn("ExactQuadCount=0", self._s(exact=False)) + self.assertIn("ExactQuadCount=1", self._s(exact=True)) + + +class TestParseProgress(unittest.TestCase): + def test_success(self): + self.assertEqual(qb.parse_progress("2\n"), ("success", "")) + + def test_error_with_text(self): + self.assertEqual(qb.parse_progress("-3\n重拓扑失败"), ("error", "重拓扑失败")) + + def test_error_no_text(self): + self.assertEqual(qb.parse_progress("-3"), ("error", "")) + + def test_running_fraction(self): + self.assertEqual(qb.parse_progress("0.5"), ("running", "")) + + def test_running_empty(self): + self.assertEqual(qb.parse_progress(""), ("running", "")) + + def test_running_garbage(self): + self.assertEqual(qb.parse_progress("abc"), ("running", "")) + + +class TestFindQrEngine(unittest.TestCase): + def setUp(self): + self._env = dict(os.environ) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._env) + + def test_env_var_priority(self): + with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f: + path = f.name + try: + os.environ["QR_ENGINE"] = path + self.assertEqual(qb.find_qr_engine(), path) + finally: + os.remove(path) + + def test_missing_returns_none(self): + os.environ["QR_ENGINE"] = os.path.join(tempfile.gettempdir(), "nope_xremesh.exe") + os.environ["APPDATA"] = tempfile.mkdtemp() + self.assertIsNone(qb.find_qr_engine()) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: 跑测试确认 RED** + +Run(在 `Tools/ModelTranslator`): + +```bash +python -m unittest tests.test_qr_bridge -v +``` + +Expected: `ModuleNotFoundError: No module named 'qr_bridge'`。 + +- [ ] **Step 3: 实现 `qr_bridge.py`** + +Create `Tools/ModelTranslator/qr_bridge.py`: + +```python +"""QR 桥纯逻辑:引擎发现、面数换算、settings 生成、progress 解析。不 import bpy。 +减面可选后端 Quad Remesher 用——headless 直接调 Engine/xremesh.exe,绕开 modal 操作符。""" +import glob +import os + +QR_TIMEOUT = 120.0 # 引擎轮询超时(秒) + + +def find_qr_engine(): + """定位 QuadRemesher 的 xremesh.exe;找不到返回 None。 + 顺序:QR_ENGINE 环境变量 -> 标准 addon 路径(取最高 Blender 版本目录)。""" + env = os.environ.get("QR_ENGINE") + if env and os.path.isfile(env): + return env + appdata = os.environ.get("APPDATA") + if not appdata: + return None + pattern = os.path.join( + appdata, "Blender Foundation", "Blender", "*", + "scripts", "addons", "QuadRemesher", "Engine", "xremesh.exe") + hits = sorted(glob.glob(pattern)) + return hits[-1] if hits else None + + +def tris_to_target_quads(tris): + """三角面数换算 QR 目标四边形数:1 quad ≈ 2 tris,至少 1。""" + return max(1, int(tris) // 2) + + +def build_settings(in_fbx, out_fbx, prog, target_quads, + adaptive=50, exact=False, hard_edges=True): + """生成 RetopoSettings.txt 文本(xremesh.exe -s 读取)。""" + lines = [ + "HostApp=Blender", + 'FileIn="%s"' % in_fbx, + 'FileOut="%s"' % out_fbx, + 'ProgressFile="%s"' % prog, + "TargetQuadCount=%d" % target_quads, + "CurvatureAdaptivness=%d" % adaptive, + "ExactQuadCount=%d" % (1 if exact else 0), + "UseVertexColorMap=False", + "UseMaterialIds=0", + "UseIndexedNormals=0", + "AutoDetectHardEdges=%d" % (1 if hard_edges else 0), + ] + return "\n".join(lines) + "\n" + + +def parse_progress(text): + """解析 progress.txt -> (state, msg)。 + 首行 '2'=success;<0=error(次行为文本);空/分数/非法=running。""" + lines = text.splitlines() + if not lines: + return ("running", "") + try: + v = float(lines[0]) + except ValueError: + return ("running", "") + if v == 2: + return ("success", "") + if v < 0: + return ("error", lines[1] if len(lines) > 1 else "") + return ("running", "") +``` + +- [ ] **Step 4: 跑测试确认 GREEN** + +Run: + +```bash +python -m unittest tests.test_qr_bridge -v +python -m unittest discover -s tests -v +``` + +Expected: 新测试全过;现有 ModelTranslator 测试全过、零失败。 + +- [ ] **Step 5: 提交** + +```bash +git add -- Tools/ModelTranslator/qr_bridge.py Tools/ModelTranslator/tests/test_qr_bridge.py +git commit -m "ModelTranslator: add QR bridge pure logic" +``` + +--- + +### Task 2: `bl_decimate.py` 加 QR 重拓扑与分流 + +**Files:** +- Modify: `Tools/ModelTranslator/bl_decimate.py` + +**Interfaces:** +- Consumes: `qr_bridge.find_qr_engine/tris_to_target_quads/build_settings/parse_progress/QR_TIMEOUT`。 +- Produces: `_remesh_quad(obj, target_tris, warnings) -> bool`。 +- `main()` 读 argv[5] `reducer`,`MT_SUMMARY` 新增 `reducer` 字段。 + +> 说明:`_remesh_quad` 依赖 bpy,无法纯单测,由 Task 4 真机 smoke 覆盖;本任务以 `py_compile` 把关语法。 + +- [ ] **Step 1: 顶部导入 qr_bridge 与轮询常量** + +在 `bl_decimate.py` 顶部 `sys.path.insert(...)` 之后加: + +```python +import qr_bridge + +QR_POLL_INTERVAL = 0.3 # progress.txt 轮询间隔(秒) +``` + +- [ ] **Step 2: 加 `_remesh_quad`(放在 `_decimate` 之后、`_clean_mesh` 之前的 Blender-only 区)** + +在 `bl_decimate.py` 的 `_decimate` 函数之后插入: + +```python +def _remesh_quad(obj, target_tris, warnings): + """Quad Remesher 重拓扑 obj(headless 直调 xremesh.exe,绕开 modal 操作符)。 + 成功返回 True,并把导回的 quad 网格设为新 active object(原 obj 已删); + 任何失败记 warning 返回 False,交上层回退 collapse。""" + import bpy + import subprocess + import tempfile + import time + engine = qr_bridge.find_qr_engine() + if not engine: + warnings.append("未找到 QuadRemesher 引擎(xremesh.exe),回退 collapse") + return False + work = os.path.join(tempfile.gettempdir(), "mt_qr") + os.makedirs(work, exist_ok=True) + in_fbx = os.path.join(work, "inputMesh.fbx").replace("\\", "/") + out_fbx = os.path.join(work, "retopo.fbx").replace("\\", "/") + prog = os.path.join(work, "progress.txt").replace("\\", "/") + settings = os.path.join(work, "RetopoSettings.txt") + for f in (out_fbx, prog): + if os.path.isfile(f): + os.remove(f) + try: + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.export_scene.fbx(filepath=in_fbx, use_selection=True) + target_quads = qr_bridge.tris_to_target_quads(target_tris) + with open(settings, "w") as fp: + fp.write(qr_bridge.build_settings(in_fbx, out_fbx, prog, target_quads)) + proc = subprocess.Popen([engine, "-s", settings]) + except Exception as e: + warnings.append("QR 启动失败(%s),回退 collapse" % e) + return False + + t0 = time.time() + state = "running" + while True: + try: + with open(prog) as fp: + state, msg = qr_bridge.parse_progress(fp.read()) + except IOError: + state, msg = "running", "" + if state == "success": + break + if state == "error": + warnings.append("QR 重拓扑失败(%s),回退 collapse" % msg) + return False + if proc.poll() is not None: + # 进程已退出:给 progress/输出一点落地时间后再判一次 + time.sleep(QR_POLL_INTERVAL) + try: + with open(prog) as fp: + state, _ = qr_bridge.parse_progress(fp.read()) + except IOError: + state = "running" + break + if time.time() - t0 > qr_bridge.QR_TIMEOUT: + proc.kill() + warnings.append("QR 超时(%ds),回退 collapse" % int(qr_bridge.QR_TIMEOUT)) + return False + time.sleep(QR_POLL_INTERVAL) + + if state != "success" or not ( + os.path.isfile(out_fbx) and os.path.getsize(out_fbx) > 0): + warnings.append("QR 无有效输出,回退 collapse") + return False + + try: + before = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=out_fbx) + new = [o for o in bpy.data.objects + if o not in before and o.type == 'MESH'] + if not new: + warnings.append("QR 导回无 mesh,回退 collapse") + return False + retopo = new[0] + bpy.data.objects.remove(obj, do_unlink=True) + bpy.ops.object.select_all(action='DESELECT') + retopo.select_set(True) + bpy.context.view_layer.objects.active = retopo + return True + except Exception as e: + warnings.append("QR 导回失败(%s),回退 collapse" % e) + return False +``` + +- [ ] **Step 3: `main()` 解析 reducer 参数** + +在 `main()` 中,把: + +```python + overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL +``` + +改为(其后新增一行): + +```python + overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL + reducer_arg = argv[5] if len(argv) > 5 else "collapse" +``` + +- [ ] **Step 4: `main()` 按 reducer 分流减面** + +把 `main()` 中现有的这段: + +```python + _clean_mesh(obj, warnings) + _triangulate(obj) + orig = len(obj.data.polygons) + cur = orig + if cur <= target: + warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target)) + else: + # collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮 + for _ in range(1 + MAX_RETRY): + _decimate(obj, decimate_ratio(target, cur)) + _triangulate(obj) # collapse triangulate 后仍可能残留非三角面 + cur = len(obj.data.polygons) + if within_tolerance(target, cur) or cur <= target: + break + # 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受 + if not within_tolerance(target, cur) and cur > target: + warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) +``` + +整体替换为: + +```python + _clean_mesh(obj, warnings) + _triangulate(obj) + orig = len(obj.data.polygons) # 高模三角面数(tris_before) + cur = orig + used_reducer = "collapse" + did_quad = False + if reducer_arg == "quad": + if _remesh_quad(obj, target, warnings): + obj = bpy.context.view_layer.objects.active # QR 导回的 quad 对象 + _triangulate(obj) # 三角化供导出/计数 + cur = len(obj.data.polygons) + used_reducer = "quad" + did_quad = True + else: + used_reducer = "quad->collapse" + if not did_quad: + if cur <= target: + warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target)) + else: + # collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮 + for _ in range(1 + MAX_RETRY): + _decimate(obj, decimate_ratio(target, cur)) + _triangulate(obj) # collapse triangulate 后仍可能残留非三角面 + cur = len(obj.data.polygons) + if within_tolerance(target, cur) or cur <= target: + break + # 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受 + if not within_tolerance(target, cur) and cur > target: + warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) +``` + +- [ ] **Step 5: `MT_SUMMARY` 加 reducer 字段** + +把 `main()` 末尾 `print("MT_SUMMARY " + json.dumps(` 里的 dict: + +```python + {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), + "tris_before": orig, "tris_after": cur, "target": target, + "uv": uv_info, +``` + +改为(加一行 `reducer`): + +```python + {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), + "tris_before": orig, "tris_after": cur, "target": target, + "reducer": used_reducer, + "uv": uv_info, +``` + +- [ ] **Step 6: 编译并跑现有测试** + +Run: + +```bash +python -m py_compile qr_bridge.py bl_decimate.py +python -m unittest discover -s tests -v +``` + +Expected: 编译 exit 0;全部测试通过(本任务未改纯函数,回归应全绿)。 + +- [ ] **Step 7: 提交** + +```bash +git add -- Tools/ModelTranslator/bl_decimate.py +git commit -m "ModelTranslator: add QR quad remesh decimate backend with collapse fallback" +``` + +--- + +### Task 3: `model_decimate.py` CLI 加 `--reducer` + +**Files:** +- Modify: `Tools/ModelTranslator/model_decimate.py` + +**Interfaces:** +- 加 `--reducer {collapse,quad}`(默认 `collapse`),透传为 Blender argv 第 6 位(index 5)。 +- 打印实际后端。 + +- [ ] **Step 1: 加 argparse 选项** + +在 `model_decimate.py` 的 `main()` 中,`--unwrap` 参数定义之后加: + +```python + ap.add_argument("--reducer", choices=("collapse", "quad"), default="collapse", + help="减面后端:collapse=Decimate 塌陷(默认),quad=Quad Remesher 四边重拓扑(失败自动回退 collapse)") +``` + +- [ ] **Step 2: 透传 reducer 到 Blender argv** + +把现有: + +```python + s = run_blender_script(blender, "bl_decimate.py", + [args.input, out_fbx, str(args.tris), args.unwrap, + "" if args.max_overlap is None else str(args.max_overlap)]) +``` + +改为: + +```python + s = run_blender_script(blender, "bl_decimate.py", + [args.input, out_fbx, str(args.tris), args.unwrap, + "" if args.max_overlap is None else str(args.max_overlap), + args.reducer]) +``` + +- [ ] **Step 3: 打印实际后端** + +把现有的这行: + +```python + print("== %s: %d -> %d 面(目标 %d)-> %s" % + (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) +``` + +其后新增一行: + +```python + print("== %s: %d -> %d 面(目标 %d)-> %s" % + (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) + print(" 后端: %s" % s.get("reducer", "collapse")) +``` + +- [ ] **Step 4: 编译与 CLI 冒烟(不进 Blender 也能验证 argparse)** + +Run: + +```bash +python -m py_compile model_decimate.py +python model_decimate.py --help +``` + +Expected: 编译 exit 0;`--help` 输出含 `--reducer {collapse,quad}`。 + +- [ ] **Step 5: 提交** + +```bash +git add -- Tools/ModelTranslator/model_decimate.py +git commit -m "ModelTranslator: expose --reducer CLI option" +``` + +--- + +### Task 4: 真机验证与文档 + +**Files:** +- Modify: `Tools/ModelTranslator/README.md` +- Verify: `Tools/ModelTranslator/out_qr/*`(未跟踪产物) + +- [ ] **Step 1: quad 后端真机 smoke** + +Run(`--blender` 用本机实际路径): + +```bash +python model_decimate.py src/well1500.fbx --tris 5000 --reducer quad -o out_qr \ + --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe" +``` + +Expected: +- `out_qr/well1500_low.fbx` 存在; +- 打印 `后端: quad`; +- 面数近似 5000(±20%,QR 近似输出); +- 打印 UV 岛数/翻转/重叠。 + +- [ ] **Step 2: collapse 默认路径回归(确认零变化)** + +Run: + +```bash +python model_decimate.py src/well1500.fbx --tris 5000 -o out_collapse \ + --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe" +``` + +Expected: 打印 `后端: collapse`;`out_collapse/well1500_low.fbx` 存在;面数落在 ±3% 容差内(与本功能前一致)。对比两次输出的 UV 岛数,quad 应更少/更规整(记录数值即可,非硬性门槛)。 + +- [ ] **Step 3: 强制回退验证** + +Run(把 `QR_ENGINE` 指向不存在的路径,逼出回退): + +```bash +QR_ENGINE="/nonexistent/xremesh.exe" python model_decimate.py src/well1500.fbx --tris 5000 --reducer quad -o out_qr_fallback \ + --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe" +``` + +Expected: 打印 `后端: quad->collapse`;输出仍正常生成;warnings 含"未找到 QuadRemesher 引擎"。 + +- [ ] **Step 4: 更新 README** + +在 `Tools/ModelTranslator/README.md` 的减面章节,`model_decimate.py` 说明处加一段: + +```markdown +- **`--reducer collapse|quad`**(默认 `collapse`):减面后端。`quad` 用 Quad Remesher + 出规整四边拓扑(改善碎三角与 UV 展开),headless 下直接调 `Engine/xremesh.exe` + 引擎子进程(不走插件 modal 操作符)。面数按 `tris/2` 换算为目标四边形数,输出为 + 近似面数(±10~20%)。引擎缺失/超时/失败/无有效输出时**自动回退 collapse**,摘要 + `reducer` 字段标 `quad->collapse`。可用 `QR_ENGINE` 环境变量覆盖引擎路径。 +``` + +- [ ] **Step 5: 最终自动化验证** + +Run: + +```bash +python -m py_compile qr_bridge.py bl_decimate.py model_decimate.py +python -m unittest discover -s tests -v +``` + +Expected: 编译 exit 0;全部测试通过、零失败。 + +- [ ] **Step 6: 检查 diff 与暂存范围** + +Run: + +```bash +git status --short +git diff --check +``` + +Expected: 无空白错误;只有本计划涉及的源码/README/文档被改或暂存;用户既有的 Unity/src/产物脏文件保持未暂存未改。 + +- [ ] **Step 7: 提交文档** + +```bash +git add -- Tools/ModelTranslator/README.md docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md +git commit -m "ModelTranslator: document QR quad remesh backend" +``` + +--- + +## Self-Review 记录 + +- **Spec 覆盖**:可选后端(Task 2/3)、纯桥模块(Task 1)、headless 直调引擎(Task 2 `_remesh_quad`)、回退 collapse(Task 2 分流 + Task 4 Step 3)、面数 tris/2(Task 1 `tris_to_target_quads`)、硬边默认(Task 1 `build_settings` hard_edges=True)、`reducer` 摘要字段(Task 2 Step 5)、README(Task 4)、测试(Task 1 单测 + Task 4 真机)——全覆盖。 +- **占位符**:无 TBD/TODO,所有代码步骤含完整代码。 +- **类型一致**:`reducer` 取值 `collapse`/`quad`/`quad->collapse` 全程一致;`parse_progress` 返回 `(state, msg)` 与 `_remesh_quad` 消费一致;argv[5] 索引与 `model_decimate.py` 透传第 6 位一致;`find_qr_engine`/`tris_to_target_quads`/`build_settings` 签名在 Task 1 定义、Task 2 消费一致。 diff --git a/docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md b/docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md new file mode 100644 index 00000000..7a9b59a1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-quadremesher-decimate-backend-design.md @@ -0,0 +1,137 @@ +# ModelTranslator 减面可选后端 Quad Remesher 设计 + +日期:2026-07-22 + +## 背景与动机 + +现有减面管线 `model_decimate.py` → `bl_decimate.py` 用 Blender 原生 +**DECIMATE(COLLAPSE)** modifier 按 `--tris` 目标三角面数减面。用户不满的核心痛点: + +1. **拓扑乱 / 碎三角**:COLLAPSE 产出狭长不规则三角,影响着色、变形、法线。 +2. **UV 展开难**:碎三角导致 seam/UV 岛碎、利用率低。 + +用户已安装 **Quad Remesher(Exoside,重打包版)** Blender 插件,可产出规整四边拓扑, +从根子上改善以上两点。本设计将其作为**可选减面后端**接入,默认不改变现有行为。 + +## 关键技术结论(已 spike 验证) + +`bpy.ops.qremesher.remesh()` 是 **modal 操作符**(`modal_handler_add` + timer,返回 +`RUNNING_MODAL`)。`blender -b` 后台无事件循环,modal 永不触发,结果 FBX 永不导回—— +**直接调操作符在 headless 必然失败**(与 UVPM headless 崩的教训同类)。 + +真正的重活是**解耦的外部引擎进程**:插件只是"桥",做导出 FBX → 写 settings → +跑 `Engine/xremesh.exe -s settings.txt` 子进程 → 轮询 `progress.txt` → 导回 retopo。 +本设计**自己重实现这 ~40 行桥胶水**,完全确定、后台安全、不依赖 modal、不受 +`--factory-startup` 影响、不需 enable 插件。 + +**Spike 实测**:icosphere 5120 tris → `TargetQuadCount=500` → 0.7s 出 441 面全 quad, +exit 0、progress=2;重打包版无许可问题能直接跑。 + +## 约束 + +- 不加外部依赖(纯标准库 + bpy)。 +- 不覆盖输入 FBX;只在生成的 `_low.fbx` 输出重拓扑数据。 +- 现有 CLI/输出目录/命名/`collapse` 路径行为**零变化**(`--reducer` 默认 `collapse`)。 +- QR 失败(引擎缺失/超时/负值/导回空)一律记 warning 并**回退 collapse,绝不中断**—— + 与现有 UVPM"失败即回退"同哲学。 +- 面数口径:`target_quads = tris // 2`,接受 QR 近似输出(约 ±20%),不做二次 collapse + (二次 collapse 会重新引入碎三角,抵消 QR 好处)。**用 `ExactQuadCount=1` 尊重目标数** + ——真机验证发现自适应模式(`ExactQuadCount=0`)在高细节硬表面模型上面数暴涨数倍 + (well1500 目标 2500→11500 quads)并连带 seam 展开重叠爆炸,故禁用自适应。 +- `QR_ENGINE` 覆盖为**权威**语义:显式设了就以它为准,无效路径即视同缺失走 collapse 回退 + (不再静默自动发现,保证回退契约可测)。 + +## 架构 + +分三层,纯逻辑与 Blender 操作分离,便于 TDD: + +### 1. 新增纯模块 `qr_bridge.py`(不 import bpy) + +| 函数 | 职责 | +|---|---| +| `find_qr_engine() -> str \| None` | 按标准 addon 路径查 `xremesh.exe`,找不到返回 None | +| `tris_to_target_quads(tris) -> int` | `max(1, tris // 2)`;1 quad ≈ 2 tris | +| `build_settings(in_fbx, out_fbx, prog, target_quads, *, adaptive=50, exact=False, hard_edges=True) -> str` | 生成 `RetopoSettings.txt` 文本(HostApp/FileIn/FileOut/ProgressFile/TargetQuadCount/CurvatureAdaptivness/ExactQuadCount/AutoDetectHardEdges 等) | +| `parse_progress(text) -> (state, msg)` | `state ∈ {"running","success","error"}`;首行 `2`=success,`<0`=error(次行为文本),空/其它=running | + +`find_qr_engine` 搜索顺序:`QR_ENGINE` 环境变量 → `%APPDATA%/Blender Foundation/Blender/*/scripts/addons/QuadRemesher/Engine/xremesh.exe`。 + +### 2. `bl_decimate.py` 新增 `_remesh_quad(obj, target_tris, warnings) -> bool` + +Blender 内执行,步骤: + +1. `find_qr_engine()`;None → warning,return False。 +2. 建工作目录(`tempfile.gettempdir()/mt_qr/`),清旧 `retopo.fbx`/`progress.txt`。 +3. 选中 `obj`,`bpy.ops.export_scene.fbx(use_selection=True)` 导出 `inputMesh.fbx`。 +4. `build_settings(...)` 写 `RetopoSettings.txt`(`hard_edges=True`,`exact=False`)。 +5. `subprocess.Popen([engine, "-s", settings])`,阻塞轮询 `progress.txt`(间隔 0.3s, + 超时默认 **120s**): + - `parse_progress` → `success` 且 `retopo.fbx` 非空 → 进入 6; + - `error` / 超时 / 进程退出但无输出 → warning,return False。 +6. 记录旧对象集合 → `bpy.ops.import_scene.fbx(retopo.fbx)` → 删除原 `obj`,把导回的 + mesh 设为新 active object(供后续展 UV/导出复用)。return True。 + +**任何异常都被捕获 → warning → return False**,不抛出。 + +### 3. `main()` 分流 + +- 解析第 5 个 argv `reducer`(`"collapse"`(默认)/`"quad"`)。 +- 流程: + - `_clean_mesh(obj)`(现有) + - `reducer == "quad"` 且 `_remesh_quad()` 成功 → **跳过 DECIMATE**,记 `reducer="quad"`; + 否则(或 QR 失败回退)走现有 `_triangulate` + collapse 循环,记 `reducer="collapse"` + 或 `"quad->collapse"`。 + - 之后照旧:`_do_unwrap`(在更干净的拓扑上展 UV)→ 三角化保证导出为三角 → 导出。 +- `MT_SUMMARY` 加 `reducer` 字段。 + +> 注:QR 输出为 quad,展 UV 在 quad 上进行(seam 更干净);导出前需确保三角化 +> (复用现有 `_triangulate`),使 `_low.fbx` 仍为三角网格,兼容下游烘焙。 + +### 4. `model_decimate.py` CLI + +- 加 `--reducer {collapse,quad}`,`default="collapse"`,透传给 Blender argv 第 5 位。 +- 打印实际后端与面数:`后端=quad 面数 20000 -> 882`(或 `quad->collapse` 回退提示)。 + +## 数据流 + +``` +FBX --import--> join --clean--> [reducer 分流] + quad: export inputMesh.fbx --xremesh.exe--> retopo.fbx --import--> 全 quad obj + collapse: DECIMATE(ratio) 循环 +--> _do_unwrap(seam+UVPM) --triangulate--> export _low.fbx + MT_SUMMARY{reducer,...} +``` + +## 错误处理 + +| 情况 | 处理 | +|---|---| +| 找不到 xremesh.exe | warning,回退 collapse | +| 引擎超时(>120s) | kill 进程,warning,回退 collapse | +| progress 负值(含无许可/EULA 的 -2) | warning(带引擎文本),回退 collapse | +| 进程退出但 retopo.fbx 空/缺 | warning,回退 collapse | +| 导回 FBX 无 mesh | warning,回退 collapse | + +回退后 `reducer` 字段标 `quad->collapse`,用户可从摘要看出实际走了哪条路。 + +## 测试 + +- **`tests/test_qr_bridge.py`**(纯函数,TDD): + - `tris_to_target_quads`:偶/奇/极小值(`1→1`, `10000→5000`, `1→1`)。 + - `build_settings`:含关键行、路径转义、`exact`/`hard_edges` 开关映射正确。 + - `parse_progress`:`"2"`→success、`"-3\n失败"`→error 带文本、`""`/`"0.5"`→running。 + - `find_qr_engine`:环境变量优先、缺失返回 None(用临时目录构造)。 +- **回归**:`python -m unittest discover -s tests` 全绿(现有测试不受影响)。 +- **真机 smoke**: + - `python model_decimate.py src/well1500.fbx --tris 5000 --reducer quad -o out_qr` + → 输出存在、`reducer=quad`、面数近似 5000(±20%)、UV 岛数/翻转/重叠 打印。 + - 与 `--reducer collapse`(默认)对比 UV 指标,确认岛数下降/更规整。 + - 强制回退验证:临时改 `QR_ENGINE` 指向不存在路径 → `reducer=quad->collapse`、 + 仍正常出 `_low.fbx`。 + +## 明确不做(YAGNI) + +- 不改烘焙 `bl_bake.py`(QR 只作用于减面产出的低模)。 +- 不做二次 collapse 卡精确面数。 +- 不暴露 QR 全部参数(对称/顶点色/材质引导);只用 target/adaptive/hard_edges 合理默认。 +- 不动进行中的 `2026-07-16-model-bake-quality-optimization` 计划;本功能与其正交, + 可后续叠加(quad 拓扑 + 法线修复不冲突)。