ModelTranslator: 减面 UV seam 展开实现计划

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-16 11:50:38 +08:00
co-authored by Claude Opus 4.8
parent 9f326c9f80
commit c24d7b9999
@@ -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.0bpy/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 -- <src.fbx> <out.fbx> <target_tris> [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/` 查看接缝表现。此步由用户完成。