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

22 KiB
Raw Blame History

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|quadquad 用 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 bpyunittest、现有 headless Blender runnermt_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.pyQR 桥纯逻辑,不 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

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):

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

"""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

python -m unittest tests.test_qr_bridge -v
python -m unittest discover -s tests -v

Expected: 新测试全过;现有 ModelTranslator 测试全过、零失败。

  • Step 5: 提交
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] reducerMT_SUMMARY 新增 reducer 字段。

说明:_remesh_quad 依赖 bpy,无法纯单测,由 Task 4 真机 smoke 覆盖;本任务以 py_compile 把关语法。

  • Step 1: 顶部导入 qr_bridge 与轮询常量

bl_decimate.py 顶部 sys.path.insert(...) 之后加:

import qr_bridge

QR_POLL_INTERVAL = 0.3   # progress.txt 轮询间隔(秒)
  • Step 2: 加 _remesh_quad(放在 _decimate 之后、_clean_mesh 之前的 Blender-only 区)

bl_decimate.py_decimate 函数之后插入:

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() 中,把:

    overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL

改为(其后新增一行):

    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() 中现有的这段:

    _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))

整体替换为:

    _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

        {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
         "tris_before": orig, "tris_after": cur, "target": target,
         "uv": uv_info,

改为(加一行 reducer):

        {"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

python -m py_compile qr_bridge.py bl_decimate.py
python -m unittest discover -s tests -v

Expected: 编译 exit 0;全部测试通过(本任务未改纯函数,回归应全绿)。

  • Step 7: 提交
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.pymain() 中,--unwrap 参数定义之后加:

    ap.add_argument("--reducer", choices=("collapse", "quad"), default="collapse",
                    help="减面后端:collapse=Decimate 塌陷(默认),quad=Quad Remesher 四边重拓扑(失败自动回退 collapse")
  • Step 2: 透传 reducer 到 Blender argv

把现有:

    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)])

改为:

    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: 打印实际后端

把现有的这行:

    print("== %s: %d -> %d 面(目标 %d-> %s" %
          (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx))

其后新增一行:

    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

python -m py_compile model_decimate.py
python model_decimate.py --help

Expected: 编译 exit 0--help 输出含 --reducer {collapse,quad}

  • Step 5: 提交
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 用本机实际路径):

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

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: 打印 后端: collapseout_collapse/well1500_low.fbx 存在;面数落在 ±3% 容差内(与本功能前一致)。对比两次输出的 UV 岛数,quad 应更少/更规整(记录数值即可,非硬性门槛)。

  • Step 3: 强制回退验证

Run(把 QR_ENGINE 指向不存在的路径,逼出回退):

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 说明处加一段:

- **`--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

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

git status --short
git diff --check

Expected: 无空白错误;只有本计划涉及的源码/README/文档被改或暂存;用户既有的 Unity/src/产物脏文件保持未暂存未改。

  • Step 7: 提交文档
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 消费一致。