Files
AIC-Project/docs/superpowers/plans/2026-07-21-uv-island-reduction.md
ud18010andClaude Opus 4.8 2673cda83a ModelTranslator: UV 减岛优化实现计划
6 任务:overlap_tol 纯函数(TDD)→_do_unwrap/main 透传→_clean_mesh
网格清理→model_decimate --max-overlap→README→端到端验证对比。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:26:26 +08:00

383 lines
18 KiB
Markdown
Raw Permalink 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.
# UV 减岛优化实现计划(--max-overlap + 减面前网格清理)
> **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:** 让减面工具能通过 `--max-overlap` 放宽重叠门限选中更高 seam 角度(更少碎岛),并在减面前清理网格,从而降低 UV 岛数、回升利用率。
**Architecture:** 全部改动内联进 `bl_decimate.py`Blender 脚本)与 `model_decimate.py`(CLI)。重叠容忍由模块常量升级为 keyword-default 运行时参数,经 `uv_gate_ok`/`pick_best_candidate`/`_do_unwrap` 透传;翻转门限保持严格。纯函数(门限判定)走 TDD 单测;Blender 算子集成(`_clean_mesh``_do_unwrap`、argv、CLI)由端到端真实 FBX 验证。所有增强单点失败降级、不中断,FBX 必产出。
**Tech Stack:** Python 3(标准库 + unittest)、Blender 5.0 headless`bpy`/`bmesh`/`mathutils`)。
---
## File Structure
- `Tools/ModelTranslator/bl_decimate.py`Modify):`uv_gate_ok`/`pick_best_candidate`/`_do_unwrap``overlap_tol` 参数;新增常量 `REL_WELD` 与函数 `_clean_mesh``main()` 接线(argv 第 5 位 + join 后清理)。
- `Tools/ModelTranslator/model_decimate.py`Modify):新增 `--max-overlap` 参数、校验、透传。
- `Tools/ModelTranslator/tests/test_bl_decimate.py`Modify):`uv_gate_ok`/`pick_best_candidate``overlap_tol` 单测。
- `Tools/ModelTranslator/README.md`Modify):`--max-overlap` 与网格清理说明。
---
## Task 1: `overlap_tol` 参数穿透纯函数(TDD
`uv_gate_ok``pick_best_candidate` 增加 keyword-default `overlap_tol`;翻转门限不受影响。
**Files:**
- Modify: `Tools/ModelTranslator/bl_decimate.py``uv_gate_ok` 约 :31-33、`pick_best_candidate` 约 :41-48
- Test: `Tools/ModelTranslator/tests/test_bl_decimate.py`
- [ ] **Step 1: 写失败测试**
`tests/test_bl_decimate.py``TestUvGateOk` 类内追加以下方法:
```python
def test_custom_overlap_tol_allows_higher_overlap(self):
self.assertTrue(bd.uv_gate_ok(0.0, 0.12, overlap_tol=0.15))
self.assertFalse(bd.uv_gate_ok(0.0, 0.16, overlap_tol=0.15))
def test_custom_overlap_tol_does_not_relax_flip(self):
# 放宽重叠不影响翻转判定(翻转仍按 UV_FLIP_TOL
self.assertFalse(bd.uv_gate_ok(0.03, 0.0, overlap_tol=0.5))
def test_default_overlap_tol_matches_constant(self):
self.assertTrue(bd.uv_gate_ok(0.0, bd.UV_OVERLAP_TOL))
self.assertFalse(bd.uv_gate_ok(0.0, bd.UV_OVERLAP_TOL + 0.01))
```
`TestPickBestCandidate` 类内追加:
```python
def test_overlap_tol_lets_more_candidates_pass(self):
# overlap=0.12 在默认 8% 门限下不过;放宽到 0.15 后过门并被选中
cands = [self._c(0.0, 0.12, 50, 55)]
self.assertIsNone(bd.pick_best_candidate(cands))
best = bd.pick_best_candidate(cands, overlap_tol=0.15)
self.assertEqual(best["angle"], 55)
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate.TestUvGateOk tests.test_bl_decimate.TestPickBestCandidate -v
```
Expected: FAIL — `uv_gate_ok() got an unexpected keyword argument 'overlap_tol'`(及 pick_best_candidate 同理)
- [ ] **Step 3: 改实现**
`bl_decimate.py``uv_gate_ok` 替换为:
```python
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL):
"""UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。
overlap 为 Noneop 不可用)按 0 处理。"""
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol
```
`pick_best_candidate` 替换为:
```python
def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL):
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。"""
passing = [c for c in candidates
if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)]
if not passing:
return None
return min(passing, key=lambda c: c["islands"])
```
- [ ] **Step 4: 运行测试确认通过**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v
```
Expected: 全部 PASS(含新增用例)
- [ ] **Step 5: 提交**
```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_gate_ok/pick_best_candidate 加 overlap_tol 参数
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: `_do_unwrap` 透传 overlap_tol + main() argv 解析
`_do_unwrap` 接收 `overlap_tol` 并用于扫描门检与择优;`main()` 解析 argv 第 5 位。
**Files:**
- Modify: `Tools/ModelTranslator/bl_decimate.py``_do_unwrap``main()`
- [ ] **Step 1: 改 `_do_unwrap`**
`bl_decimate.py` 的整个 `_do_unwrap` 替换为(仅签名、docstring、两处 gate/择优调用带上 overlap_tol,其余逻辑不变):
```python
def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
"""按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫
——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在
候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发);
全失败退 smart。overlap_tol 覆盖重叠门限(翻转仍严格)。排布 UVPM4 增强,只在最终 UV
上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。"""
if mode == "seam":
angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP)
candidates = []
for ang in angles:
_unwrap_seam(obj, warnings, seam_angle_deg=ang)
m = _collect_uv_metrics(obj)
m["angle"] = ang
candidates.append(m)
if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol):
break # 该档已过门;更低角度只会更碎,无需再试
best = pick_best_candidate(candidates, overlap_tol)
if best is not None:
if best["angle"] != candidates[-1]["angle"]:
# 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖)
_unwrap_seam(obj, warnings, seam_angle_deg=best["angle"])
uvpm = _uvpm_repack(obj, warnings)
m = _collect_uv_metrics(obj)
m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm)
return m
detail = "、".join(
"%d°(翻转%.1f%% 重叠%s)" % (
int(c["angle"]), c["flipped"] * 100,
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知")
for c in candidates)
warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail)
mode = "smart_fallback"
_unwrap_smart(obj)
uvpm = _uvpm_repack(obj, warnings)
m = _collect_uv_metrics(obj)
m["mode"] = uvpm_mode_label(mode if mode == "smart_fallback" else "smart", uvpm)
return m
```
- [ ] **Step 2: 改 `main()` argv 解析与调用**
`main()` 中,`unwrap_mode = argv[3] if len(argv) > 3 else "seam"` 之后新增一行解析 overlap_tol
```python
unwrap_mode = argv[3] if len(argv) > 3 else "seam"
overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL
```
并把 `main()` 里对 `_do_unwrap` 的调用(现为 `uv_info = _do_unwrap(obj, unwrap_mode, warnings)`)改为:
```python
uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol)
```
- [ ] **Step 3: 语法自检 + 单测不回归**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('bl_decimate.py', encoding='utf-8').read()); print('OK')" && python -m unittest tests.test_bl_decimate 2>&1 | tail -3
```
Expected: `OK`,随后单测全 PASS。(`_do_unwrap` 行为在 Task 6 端到端验证。)
- [ ] **Step 4: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: _do_unwrap 透传 overlap_tol,main 解析 argv 第5位
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: 减面前网格清理 `_clean_mesh`
新增常量 `REL_WELD``_clean_mesh``main()` 在 join 后、三角化前调用。
**Files:**
- Modify: `Tools/ModelTranslator/bl_decimate.py`(常量区 + `_decimate` 后新增函数 + `main()`
- [ ] **Step 1: 新增常量**
`bl_decimate.py` 常量区 `STRETCH_ITERS = 30` 那一行之后追加:
```python
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
```
- [ ] **Step 2: 新增 `_clean_mesh` 函数**
`bl_decimate.py``_decimate` 函数之后新增:
```python
def _clean_mesh(obj, warnings):
"""减面前清理:按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形,
减少被迫加的 seam。各步失败降级不中断。"""
import bpy
import mathutils
bb = [mathutils.Vector(c) for c in obj.bound_box]
diag = (bb[0] - bb[6]).length
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
if diag > 0.0:
try:
bpy.ops.mesh.remove_doubles(threshold=diag * REL_WELD)
except RuntimeError as e:
warnings.append("remove_doubles 失败(%s),跳过焊接" % e)
else:
warnings.append("包围盒对角线为 0,跳过焊接")
try:
bpy.ops.mesh.dissolve_degenerate()
except RuntimeError as e:
warnings.append("dissolve_degenerate 失败(%s),跳过去退化" % e)
bpy.ops.object.mode_set(mode='OBJECT')
```
- [ ] **Step 3: `main()` 接线**
`main()``obj = join_meshes(meshes)` 之后、`_triangulate(obj)` 之前插入:
```python
_clean_mesh(obj, warnings)
```
- [ ] **Step 4: 语法自检 + 单测不回归**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('bl_decimate.py', encoding='utf-8').read()); print('OK')" && python -m unittest tests.test_bl_decimate 2>&1 | tail -3
```
Expected: `OK`,随后单测全 PASS。(清理行为在 Task 6 端到端验证。)
- [ ] **Step 5: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: 减面前 _clean_mesh 焊接重合点+去退化面
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: `model_decimate.py` 新增 `--max-overlap`
CLI 参数 + 校验 + 透传给 bl_decimateargv 第 5 位)。
**Files:**
- Modify: `Tools/ModelTranslator/model_decimate.py`
- [ ] **Step 1: 加参数**
`model_decimate.py``ap.add_argument("--unwrap", ...)` 之后、`ap.add_argument("--blender", ...)` 之前新增:
```python
ap.add_argument("--max-overlap", type=float, default=None,
help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛")
```
- [ ] **Step 2: 加校验**
`if args.tris <= 0: ap.error(...)` 之后新增:
```python
if args.max_overlap is not None and not (0.0 < args.max_overlap <= 1.0):
ap.error("--max-overlap 必须在 (0, 1] 内")
```
- [ ] **Step 3: 透传**
`run_blender_script(blender, "bl_decimate.py", [args.input, out_fbx, str(args.tris), args.unwrap])` 改为:
```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)])
```
- [ ] **Step 4: 语法自检 + 参数校验(无需 Blender)**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -c "import ast; ast.parse(open('model_decimate.py', encoding='utf-8').read()); print('OK')" && python model_decimate.py dummy.fbx --tris 100 --max-overlap 2 2>&1 | tail -2
```
Expected: `OK`;随后 argparse 报错退出,信息含 `--max-overlap 必须在 (0, 1] 内`(在启动 Blender 前就拒绝,无需真实 FBX)。
- [ ] **Step 5: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: model_decimate 新增 --max-overlap 参数(校验+透传)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: README 更新
**Files:**
- Modify: `Tools/ModelTranslator/README.md`
- [ ] **Step 1: 更新 model_decimate.py 说明段**
将 README.md 的 `- **model_decimate.py**:…` 那条整体替换为(在现有基础上补入网格清理与 `--max-overlap`):
```markdown
- **model_decimate.py**:多 mesh 自动 join;**减面前清理网格**(按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形、减少被迫加的 seam);三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project`--unwrap smart` 可直接选投影式展开;**`--max-overlap 0.15` 放宽重叠门限**,让更高 seam 角度(岛更少)能过门被选中——岛数优先、可接受略高重叠时用(翻转门限始终严格);排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx``-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
```
- [ ] **Step 2: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补充 --max-overlap 与减面前网格清理
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: 端到端验证 + 前后对比
**Files:** 无(仅运行验证)
- [ ] **Step 1: 全量单测**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_uv_preview tests.test_unity_assets 2>&1 | tail -3
```
Expected: 全部 PASS。
- [ ] **Step 2: 端到端 —— 放宽重叠(减岛目标)**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && rm -f src/gargoyle_low_uv.png && PYTHONIOENCODING=utf-8 python model_decimate.py src/gargoyle.fbx --tris 3000 --max-overlap 0.15 2>&1 | tail -6 && ls -la src/gargoyle_low_uv.png
```
Expected: 成功;`模式` 为更高角度(如 `seam@55+uvpm`/`seam@45+uvpm`);**岛数明显低于基线 686、利用率高于 38.2%**`gargoyle_low_uv.png` 生成。记录实际 岛数/利用率/翻转/重叠/角度。用 Read 打开 PNG 确认碎岛减少、留白减少。
- [ ] **Step 3: 端到端 —— 默认路径不回归**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && PYTHONIOENCODING=utf-8 python model_decimate.py src/gargoyle.fbx --tris 3000 2>&1 | tail -6
```
Expected: 成功;默认仍 8% 门限(翻转严格);流程不崩,FBX + 观察图产出。因网格清理现默认开启,指标可能与历史基线(686/38.2%/seam@35)略有不同——记录并说明是清理带来的差异,非回归。
- [ ] **Step 4: 记录前后对比表**
在最终汇报里给出三列对比:
- 基线(本优化前):seam@35 / 686 岛 / 38.2% / 翻转 0% / 重叠 6.3%
- 默认路径(清理开启,无 --max-overlap):实测
- --max-overlap 0.15(清理开启):实测
列出 模式/岛数/利用率/翻转/重叠。
---
## Self-Review 记录
- **Spec 覆盖**:①`--max-overlap` 穿透→Task 1(纯函数)+Task 2(_do_unwrap/main)+Task 4(CLI);②减面前网格清理→Task 3;③测试→Task 1(TDD)+Task 6(端到端);④README→Task 5;错误处理表(remove_doubles/dissolve_degenerate/对角线为0/越界/空串回落)→Task 2 argv 回落、Task 3 各 try/except、Task 4 校验。全部有对应任务。
- **占位符**:无 TBD/TODO;每个代码步给出完整代码与确切命令、预期输出。
- **类型/命名一致**`overlap_tol`Task 1 定义于 uv_gate_ok/pick_best_candidateTask 2 在 _do_unwrap 使用并透传,键名一致);`REL_WELD`/`_clean_mesh`Task 3 定义与 main 调用一致);`--max-overlap`→argv 第 5 位空串回落(Task 4 产生,Task 2 `main` 解析 `float(argv[4]) if ... and argv[4] else UV_OVERLAP_TOL`,契约一致);翻转门限 `UV_FLIP_TOL` 全程不动。
- **已知风险/取舍**:网格清理默认开启会改变默认路径指标(Task 6 Step 3 明确记录说明);`remove_doubles` 相对阈值依赖 `obj.bound_box` 局部坐标(对角线为 0 时跳过,已处理);`_do_unwrap` 早停使 pick_best_candidate 择优在单调假设下与早停一致(沿用既有设计,overlap_tol 只是放宽门限、不改这一性质)。