Files
AIC-Project/docs/superpowers/plans/2026-07-22-quadremesher-decimate-backend.md
T
2026-07-22 17:29:36 +08:00

631 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 重拓扑 objheadless 直调 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 消费一致。