diff --git a/docs/superpowers/plans/2026-07-16-decimate-uv-seam-unwrap.md b/docs/superpowers/plans/2026-07-16-decimate-uv-seam-unwrap.md new file mode 100644 index 00000000..af760aa5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-decimate-uv-seam-unwrap.md @@ -0,0 +1,403 @@ +# 减面工具 UV 展开优化实现计划 + +> **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` 的 UV 展开从 Smart UV Project 换成"锐边 seam + MINIMUM_STRETCH 展开 + 质量门自动回退",大幅减少 UV 碎岛。 + +**Architecture:** 纯逻辑(union-find 数岛、翻转/利用率计算)放 `bl_decimate.py` 顶部可单测区;Blender 侧新增 seam 展开路径与 bmesh 指标提取,质量门(翻转/重叠占比 >2%)触发时回退原 smart_project 路径。`MT_SUMMARY` 增加 `uv` 字段,CLI 增加 `--unwrap {seam,smart}`(默认 seam)。 + +**Tech Stack:** Python 3 标准库、Blender 5.0(bpy/bmesh)、unittest。 + +**设计文档:** `docs/superpowers/specs/2026-07-16-decimate-uv-seam-unwrap-design.md` + +**重要提醒:** +- 仓库路径含 `#`(`D:\UD\AI\AIC#Project`),**bash 里所有路径必须加引号** +- Blender:`D:\tools\blender-5.0.0-windows-x64\blender.exe` +- 冒烟素材:`Tools/ModelTranslator/src/well1500.fbx`(149.9 万面)→ 5000 面 +- 产物(`_low.fbx`)不入 git,只提交代码/文档 + +## 文件结构 + +``` +Tools/ModelTranslator/ + bl_decimate.py # 修改:纯逻辑区加 3 函数+1 常量;Blender 区重构展开路径 + model_decimate.py # 修改:--unwrap 参数 + UV 指标打印 + tests/test_bl_decimate.py # 修改:追加纯逻辑测试 + README.md # 修改:--unwrap 说明(Task 3) +``` + +--- + +### Task 1: UV 指标纯逻辑(TDD) + +**Files:** +- Modify: `Tools/ModelTranslator/tests/test_bl_decimate.py`(文件末尾 `if __name__` 之前追加) +- Modify: `Tools/ModelTranslator/bl_decimate.py`(纯逻辑区,`within_tolerance` 之后、`# ---------------- 以下仅在 Blender 内执行` 之前) + +- [ ] **Step 1: 追加失败测试** + +在 `tests/test_bl_decimate.py` 的 `if __name__ == "__main__":` 之前插入: + +```python +class TestCountIslands(unittest.TestCase): + def test_no_faces(self): + self.assertEqual(bd.count_islands(0, []), 0) + + def test_no_pairs_each_face_is_island(self): + self.assertEqual(bd.count_islands(3, []), 3) + + def test_chain_merges_to_one(self): + self.assertEqual(bd.count_islands(4, [(0, 1), (1, 2), (2, 3)]), 1) + + def test_two_groups(self): + self.assertEqual(bd.count_islands(5, [(0, 1), (3, 4)]), 3) # {0,1} {2} {3,4} + + def test_duplicate_pairs_ok(self): + self.assertEqual(bd.count_islands(2, [(0, 1), (1, 0), (0, 1)]), 1) + + +class TestFlippedFraction(unittest.TestCase): + def test_empty_is_zero(self): + self.assertEqual(bd.flipped_fraction([]), 0.0) + + def test_mixed(self): + self.assertAlmostEqual(bd.flipped_fraction([0.1, -0.2, 0.3, 0.4]), 0.25) + + def test_all_positive(self): + self.assertEqual(bd.flipped_fraction([0.1, 0.2]), 0.0) + + +class TestFillRatio(unittest.TestCase): + def test_sum(self): + self.assertAlmostEqual(bd.fill_ratio([0.2, 0.3]), 0.5) + + def test_clamped_to_one(self): + self.assertEqual(bd.fill_ratio([0.8, 0.9]), 1.0) + + def test_empty_is_zero(self): + self.assertEqual(bd.fill_ratio([]), 0.0) +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` +Expected: ERROR(`module 'bl_decimate' has no attribute 'count_islands'` 等) + +- [ ] **Step 3: 写实现** + +在 `bl_decimate.py` 的 `within_tolerance` 函数之后、`# ---------------- 以下仅在 Blender 内执行` 注释之前插入: + +```python +UV_BAD_TOL = 0.02 # 质量门:UV 翻转/重叠面占比超此值则回退 smart_project + + +def count_islands(n_faces, adjacent_pairs): + """union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。""" + parent = list(range(n_faces)) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for a, b in adjacent_pairs: + ra, rb = find(a), find(b) + if ra != rb: + parent[ra] = rb + return len({find(i) for i in range(n_faces)}) + + +def flipped_fraction(signed_areas): + """UV 面签名面积为负(翻转)的占比。""" + if not signed_areas: + return 0.0 + return sum(1 for a in signed_areas if a < 0) / float(len(signed_areas)) + + +def fill_ratio(abs_areas): + """UV 空间利用率:面积和(重叠会虚高,截断到 1.0)。""" + return min(1.0, sum(abs_areas)) +``` + +- [ ] **Step 4: 运行确认通过** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` +Expected: 19 个测试全部 PASS(原 8 + 新 11) + +- [ ] **Step 5: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/tests/test_bl_decimate.py && git commit -m "ModelTranslator: UV 岛数/翻转/利用率纯逻辑(TDD)" +``` + +--- + +### Task 2: seam 展开路径 + 质量门 + CLI 参数 + +**Files:** +- Modify: `Tools/ModelTranslator/bl_decimate.py`(Blender 区:替换 `_smart_unwrap`,`main()` 接线) +- Modify: `Tools/ModelTranslator/model_decimate.py`(`--unwrap` + UV 指标打印) + +- [ ] **Step 1: bl_decimate.py 替换 `_smart_unwrap` 为新展开体系** + +把现有 `_smart_unwrap` 函数(`def _smart_unwrap(obj):` 到其 `bpy.ops.object.mode_set(mode='OBJECT')` 整个函数)整体替换为: + +```python +SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam +PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px + + +def _clear_uv_layers(mesh): + while mesh.uv_layers: + mesh.uv_layers.remove(mesh.uv_layers[0]) + + +def _unwrap_smart(obj): + """Smart UV Project(原路径,也是质量门的回退路径)。""" + import bpy + import math + _clear_uv_layers(obj.data) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=PACK_MARGIN) + bpy.ops.object.mode_set(mode='OBJECT') + + +def _unwrap_seam(obj, warnings): + """锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> pack。碎岛远少于投影式。""" + import bpy + import math + _clear_uv_layers(obj.data) + obj.data.uv_layers.new() + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + # 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛 + bpy.ops.mesh.select_mode(type='EDGE') + bpy.ops.mesh.select_all(action='DESELECT') + bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(SEAM_ANGLE_DEG)) + bpy.ops.mesh.mark_seam(clear=False) + bpy.ops.mesh.select_all(action='SELECT') + try: + bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN) + except TypeError: # 老版本无 SLIM 枚举 + warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED") + bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN) + try: + bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN) + except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包 + warnings.append("pack_islands 不可用,沿用 unwrap 自带布局") + bpy.ops.object.mode_set(mode='OBJECT') + + +def _collect_uv_metrics(obj): + """bmesh 提取 UV 指标:岛数(UV 连续边并查集)、利用率、翻转占比、重叠占比。""" + import bmesh + bm = bmesh.new() + bm.from_mesh(obj.data) + uv = bm.loops.layers.uv.active + pairs = [] + for e in bm.edges: + if len(e.link_faces) != 2: + continue + f1, f2 = e.link_faces + cont = True + for v in e.verts: # 两面在该边两端 UV 一致 => UV 连续(同岛) + l1 = next(l for l in f1.loops if l.vert == v) + l2 = next(l for l in f2.loops if l.vert == v) + if (l1[uv].uv - l2[uv].uv).length > 1e-6: + cont = False + break + if cont: + pairs.append((f1.index, f2.index)) + signed = [] + for f in bm.faces: + area = 0.0 + loops = f.loops + for i in range(len(loops)): + a = loops[i][uv].uv + b = loops[(i + 1) % len(loops)][uv].uv + area += a.x * b.y - b.x * a.y + signed.append(area * 0.5) + n = len(bm.faces) + bm.free() + return {"islands": count_islands(n, pairs), + "fill": round(fill_ratio([abs(a) for a in signed]), 4), + "flipped": round(flipped_fraction(signed), 4), + "overlap": _overlap_fraction(obj)} + + +def _overlap_fraction(obj): + """uv.select_overlap 统计重叠 UV 面占比;op 上下文不可用返回 None。""" + import bpy + bpy.context.scene.tool_settings.use_uv_select_sync = True + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='DESELECT') + try: + bpy.ops.uv.select_overlap() + except RuntimeError: + return None + finally: + bpy.ops.object.mode_set(mode='OBJECT') + polys = obj.data.polygons + if not len(polys): + return 0.0 + return round(sum(1 for p in polys if p.select) / float(len(polys)), 4) + + +def _do_unwrap(obj, mode, warnings): + """按模式展开;seam 质量不达标自动回退 smart。返回 uv 指标 dict(含 mode)。""" + if mode == "seam": + _unwrap_seam(obj, warnings) + m = _collect_uv_metrics(obj) + overlap = m["overlap"] or 0.0 + if m["flipped"] <= UV_BAD_TOL and overlap <= UV_BAD_TOL: + m["mode"] = "seam" + return m + warnings.append("seam 展开质量不达标(翻转 %.1f%% 重叠 %s),回退 smart_project" + % (m["flipped"] * 100, + "%.1f%%" % (m["overlap"] * 100) if m["overlap"] is not None else "未知")) + mode = "smart_fallback" + _unwrap_smart(obj) + m = _collect_uv_metrics(obj) + m["mode"] = mode if mode == "smart_fallback" else "smart" + return m +``` + +- [ ] **Step 2: main() 接线(argv 第 4 参 + uv 字段)** + +`main()` 里两处修改。argv 解析行: + +```python + src, out_fbx, target = argv[0], argv[1], int(argv[2]) +``` + +改为: + +```python + src, out_fbx, target = argv[0], argv[1], int(argv[2]) + unwrap_mode = argv[3] if len(argv) > 3 else "seam" +``` + +`_smart_unwrap(obj)` 调用行改为: + +```python + uv_info = _do_unwrap(obj, unwrap_mode, warnings) +``` + +`MT_SUMMARY` 的 dict 加 `"uv": uv_info`: + +```python + print("MT_SUMMARY " + json.dumps( + {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), + "tris_before": orig, "tris_after": cur, "target": target, + "uv": uv_info, "warnings": warnings}, ensure_ascii=False)) +``` + +同时把文件顶部 docstring 第 1-2 行更新为: + +```python +"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。 +调用:blender -b --factory-startup --python bl_decimate.py -- [seam|smart] +""" +``` + +- [ ] **Step 3: model_decimate.py 加 --unwrap 与指标打印** + +argparse 增加(`--blender` 行之前): + +```python + ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam", + help="UV 展开方式:seam=锐边接缝整岛展开(默认),smart=Smart UV Project") +``` + +`run_blender_script` 调用改为: + +```python + s = run_blender_script(blender, "bl_decimate.py", + [args.input, out_fbx, str(args.tris), args.unwrap]) +``` + +结果打印行之后、警告循环之前加: + +```python + u = s["uv"] + overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a" + print(" UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" % + (u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap)) +``` + +文件 docstring 第 1 行的"并 Smart UV 重展"改为"并重展 UV(默认锐边 seam 整岛展开)"。 + +- [ ] **Step 4: 语法检查 + 全部单测** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_decimate.py model_decimate.py && python -m unittest discover -s tests -v` +Expected: 编译通过,40 个测试全部 PASS(29 原有 + 11 新增) + +- [ ] **Step 5: Commit** + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: 减面 UV 改锐边 seam 展开(质量门回退 smart,--unwrap 可选)" +``` + +--- + +### Task 3: 双模式冒烟对比 + README + +**Files:** +- Modify: `Tools/ModelTranslator/README.md` +- 产物 `src/well1500_low.fbx`、`/tmp` 对比数据不入 git + +- [ ] **Step 1: smart 模式跑基线**(timeout 300000) + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000 --unwrap smart` +Expected: 正常完成,记录 `UV: 模式=smart 岛数=N_smart 利用率=F_smart ...` + +- [ ] **Step 2: seam 模式(默认)跑对比**(timeout 300000) + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000` +Expected: `UV: 模式=seam ...`,且满足验收标准: +- `岛数` 显著低于 smart 基线(至少降一个数量级或 <1/5) +- `利用率` ≥ smart 基线的 90% +- `翻转` ≤ 2%、`重叠` ≤ 2%(未触发回退警告) + +不达标则按 systematic-debugging 排查(常见点:`edges_select_sharp` 的 sharpness 语义、`MINIMUM_STRETCH` 枚举名、`pack_islands`/`select_overlap` headless poll 失败——后两者已有降级路径,看警告判断),修复后重跑并把修复单独 commit。 + +- [ ] **Step 3: 用新低模快速复烘验证**(timeout 600000) + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_bake.py src/well1500.fbx src/well1500_low.fbx --size 1024 --samples 16` +Expected: 5 张 PNG 正常产出、无 error;用下面命令确认 normal/color 非空: + +```bash +cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python-expr " +import bpy +for n in ('color', 'normal'): + img = bpy.data.images.load(r'D:\UD\AI\AIC#Project\Tools\ModelTranslator\out_bake\well1500\well1500_%s.png' % n) + uniq = len(set(round(v, 2) for v in img.pixels[:4000])) + print('CHECK', n, 'unique=', uniq) +" +``` +Expected: 两图 unique 明显 >1 + +- [ ] **Step 4: README 更新** + +`README.md` 中 model_decimate.py 说明条目("旧 UV 全删,Smart UV Project 重展"那句)改为: + +``` +- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边标 seam + MINIMUM_STRETCH 整岛展开(碎岛少、接缝落在硬边),质量不达标(翻转/重叠 >2%)自动回退 Smart UV Project,`--unwrap smart` 可强制旧行为;输出 `<名>_low.fbx`(`-o` 改目录),日志报 UV 岛数/利用率 +``` + +- [ ] **Step 5: 全部单测最后过一遍 + Commit** + +Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest discover -s tests -v` +Expected: 40/40 PASS + +```bash +cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补 --unwrap 说明(well1500 双模式冒烟对比通过)" +``` + +- [ ] **Step 6: Unity 手动验证(用户操作)** + +如需上 Unity 验证:`python model_translator.py out_bake/well1500/well1500.fbx` 后拷 `out/well1500/` 进 `Client/Assets/` 查看接缝表现。此步由用户完成。