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>
383 lines
18 KiB
Markdown
383 lines
18 KiB
Markdown
# 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 为 None(op 不可用)按 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_decimate(argv 第 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_candidate,Task 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 只是放宽门限、不改这一性质)。
|