# 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 消费一致。