ModelTranslator: UV 展开质量优化实现计划
8 个任务:算子可行性 spike → pick_best_candidate(TDD) → _unwrap_seam 增强 → _do_unwrap 角度扫描 → UV 观察图导出 → CLI 打印 → README → 验证。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e8c0804582
commit
890d12fb09
@@ -0,0 +1,555 @@
|
|||||||
|
# UV 展开质量优化 + 线框观察图 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:** 增强 ModelTranslator 减面工具的 UV 展开质量(松弛 + 纹素均衡 + seam 角度扫描抢救),并每次导出一张 UV 线框观察图供人工查阅。
|
||||||
|
|
||||||
|
**Architecture:** 全部改动内联进 `Tools/ModelTranslator/bl_decimate.py`(Blender 内脚本)与 `model_decimate.py`(CLI)。纯函数(候选择优)用 `unittest` 做 TDD;Blender 算子集成沿用本仓库既有约定——由端到端跑真实 FBX 验证(无法在无 Blender 环境单测)。所有新增算子/导出均为增强项,任一失败降级记警告、不中断,FBX 必产出。
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3(标准库 + unittest)、Blender 5.0 headless(`bpy`)、UVPackmaster 4(既有,可选)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `Tools/ModelTranslator/bl_decimate.py`(Modify):新增常量、`pick_best_candidate` 纯函数、`_unwrap_seam` 增强并参数化角度、`_do_unwrap` 改分级抢救、新增 `_export_uv_layout`/`_ensure_uv_layout_addon`、`main()` 接线。
|
||||||
|
- `Tools/ModelTranslator/model_decimate.py`(Modify):CLI 打印 UV 观察图路径。
|
||||||
|
- `Tools/ModelTranslator/tests/test_bl_decimate.py`(Modify):新增 `pick_best_candidate` 单测。
|
||||||
|
- `Tools/ModelTranslator/README.md`(Modify):补充展开增强与观察图说明。
|
||||||
|
|
||||||
|
参照文件(无需改):`mt_run.py`(Blender 运行器 + MT_SUMMARY 协议)、`tests/test_bl_decimate.py`(现有纯函数测试模式)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Headless 算子可行性验证(spike)
|
||||||
|
|
||||||
|
先确认三个新算子在 Blender 5.0 `--factory-startup` 下能跑通,锁定 `export_layout` 的 add-on 模块名。结果决定后续任务是否需调整降级路径。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Tools/ModelTranslator/tests/bl_probe_ops.py`(临时探针脚本,验证后删除)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写探针脚本**
|
||||||
|
|
||||||
|
Create `Tools/ModelTranslator/tests/bl_probe_ops.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""探针:验证 minimize_stretch / average_islands_scale / export_layout 在
|
||||||
|
headless factory-startup 下可用性,并找出 UV Layout add-on 模块名。
|
||||||
|
运行:blender -b --factory-startup --python tests/bl_probe_ops.py -- <out.png>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import bpy
|
||||||
|
import addon_utils
|
||||||
|
|
||||||
|
out_png = sys.argv[sys.argv.index("--") + 1:][0]
|
||||||
|
r = {}
|
||||||
|
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
bpy.ops.mesh.primitive_uv_sphere_add()
|
||||||
|
obj = bpy.context.view_layer.objects.active
|
||||||
|
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
bpy.ops.mesh.select_all(action='SELECT')
|
||||||
|
bpy.ops.uv.smart_project()
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.uv.minimize_stretch(iterations=5)
|
||||||
|
r["minimize_stretch"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
r["minimize_stretch"] = "FAIL: %r" % e
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.uv.average_islands_scale()
|
||||||
|
r["average_islands_scale"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
r["average_islands_scale"] = "FAIL: %r" % e
|
||||||
|
|
||||||
|
enabled = None
|
||||||
|
for name in ("io_mesh_uv_layout", "bl_ext.blender_org.io_mesh_uv_layout"):
|
||||||
|
try:
|
||||||
|
if addon_utils.enable(name, default_set=True) is not None:
|
||||||
|
enabled = name
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
r.setdefault("addon_errors", []).append("%s: %r" % (name, e))
|
||||||
|
r["uv_layout_addon"] = enabled
|
||||||
|
|
||||||
|
try:
|
||||||
|
bpy.ops.uv.select_all(action='SELECT')
|
||||||
|
bpy.ops.uv.export_layout(filepath=out_png, mode='PNG', size=(256, 256), opacity=0.25)
|
||||||
|
r["export_layout"] = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
r["export_layout"] = "FAIL: %r" % e
|
||||||
|
|
||||||
|
print("PROBE " + repr(r))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行探针**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:\tools\blender-5.0.0-windows-x64\blender.exe" -b --factory-startup --python tests/bl_probe_ops.py -- /tmp/probe_uv.png
|
||||||
|
```
|
||||||
|
Expected: 输出一行 `PROBE {...}`,理想全为 `ok` 且 `uv_layout_addon` 为某个非 None 名字。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 记录结果,按需调整后续任务**
|
||||||
|
|
||||||
|
- 若某算子 `FAIL`:保留后续任务里对应的 try/except 降级(本就有),并在 commit message 注明该算子不可用。
|
||||||
|
- 若 `uv_layout_addon` 为 `None`(两个名字都不行):把探针输出里 `addon_errors` 贴到 Task 5 的 commit note,并把 `UV_LAYOUT_ADDONS` 换成探针发现的可用名(若有);仍为 None 则观察图功能整体降级为「记警告跳过」,需告知用户。
|
||||||
|
- 若 `export_layout` 的 `size`/`opacity` 参数签名报错:记录真实签名,Task 5 相应调整。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 删除探针脚本并提交记录(无代码改动则跳过提交)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && rm tests/bl_probe_ops.py
|
||||||
|
```
|
||||||
|
|
||||||
|
(探针为一次性,不入库。将结论写入下一个 commit 的正文即可。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `pick_best_candidate` 纯函数(TDD)
|
||||||
|
|
||||||
|
从候选 UV 指标中选「过质量门且 UV 岛数最少」者;无过门候选返回 None。这是分级抢救的选择规则,纯函数、可单测。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/bl_decimate.py`(在 `uvpm_mode_label` 之后、`count_islands` 之前的纯函数区新增)
|
||||||
|
- Test: `Tools/ModelTranslator/tests/test_bl_decimate.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
在 `tests/test_bl_decimate.py` 的 `TestUvpmModeLabel` 类之后新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestPickBestCandidate(unittest.TestCase):
|
||||||
|
def _c(self, flipped, overlap, islands, angle):
|
||||||
|
return {"flipped": flipped, "overlap": overlap,
|
||||||
|
"islands": islands, "angle": angle}
|
||||||
|
|
||||||
|
def test_none_when_empty(self):
|
||||||
|
self.assertIsNone(bd.pick_best_candidate([]))
|
||||||
|
|
||||||
|
def test_none_when_no_candidate_passes_gate(self):
|
||||||
|
cands = [self._c(0.30, 0.50, 100, 66), self._c(0.20, 0.40, 200, 45)]
|
||||||
|
self.assertIsNone(bd.pick_best_candidate(cands))
|
||||||
|
|
||||||
|
def test_picks_only_passing(self):
|
||||||
|
cands = [self._c(0.30, 0.50, 50, 66), self._c(0.00, 0.00, 300, 45)]
|
||||||
|
best = bd.pick_best_candidate(cands)
|
||||||
|
self.assertEqual(best["angle"], 45)
|
||||||
|
|
||||||
|
def test_picks_fewest_islands_among_passing(self):
|
||||||
|
cands = [self._c(0.00, 0.00, 120, 55), self._c(0.01, 0.02, 90, 45),
|
||||||
|
self._c(0.00, 0.00, 300, 35)]
|
||||||
|
best = bd.pick_best_candidate(cands)
|
||||||
|
self.assertEqual(best["islands"], 90)
|
||||||
|
|
||||||
|
def test_overlap_none_treated_as_pass(self):
|
||||||
|
cands = [self._c(0.01, None, 42, 66)]
|
||||||
|
best = bd.pick_best_candidate(cands)
|
||||||
|
self.assertEqual(best["islands"], 42)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate.TestPickBestCandidate -v
|
||||||
|
```
|
||||||
|
Expected: FAIL — `AttributeError: module 'bl_decimate' has no attribute 'pick_best_candidate'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
在 `bl_decimate.py` 的 `uvpm_mode_label` 函数之后新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_best_candidate(candidates):
|
||||||
|
"""从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
|
||||||
|
每个 candidate 至少含 flipped/overlap/islands。"""
|
||||||
|
passing = [c for c in candidates
|
||||||
|
if uv_gate_ok(c["flipped"], c["overlap"])]
|
||||||
|
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.TestPickBestCandidate -v
|
||||||
|
```
|
||||||
|
Expected: PASS(5 tests OK)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 全量单测不回归**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_unity_assets -v
|
||||||
|
```
|
||||||
|
Expected: 全部 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
|
||||||
|
```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: 新增 pick_best_candidate——过门且岛数最少者择优
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `_unwrap_seam` 增强(松弛 + 纹素均衡 + 角度参数)
|
||||||
|
|
||||||
|
unwrap 后加 `minimize_stretch` 松弛、pack 前加 `average_islands_scale`;seam 角度参数化以支持扫描;扫描重跑前清掉上一档残留 seam。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/bl_decimate.py:104-107`(常量区)、`:128-151`(`_unwrap_seam`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 新增常量**
|
||||||
|
|
||||||
|
在 `bl_decimate.py` 现有 `SEAM_ANGLE_DEG = 66.0` 一段(`:104-108`)之后追加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 66° 不过门时依次下探的 seam 角度
|
||||||
|
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
|
||||||
|
UV_PREVIEW_SIZE = 1024 # UV 观察图边长(px)
|
||||||
|
UV_PREVIEW_OPACITY = 0.25 # UV 观察图填充不透明度
|
||||||
|
UV_LAYOUT_ADDONS = ("io_mesh_uv_layout",
|
||||||
|
"bl_ext.blender_org.io_mesh_uv_layout") # export_layout 依赖的 add-on 候选名
|
||||||
|
```
|
||||||
|
|
||||||
|
(若 Task 1 探针确认了确切 add-on 名,把它放在元组首位。)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 替换 `_unwrap_seam`**
|
||||||
|
|
||||||
|
将 `bl_decimate.py:128-151` 的整个 `_unwrap_seam` 替换为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG):
|
||||||
|
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。
|
||||||
|
seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。"""
|
||||||
|
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')
|
||||||
|
bpy.ops.mesh.select_mode(type='EDGE')
|
||||||
|
# 清掉上一档角度残留 seam(同一对象多角度扫描时必须)
|
||||||
|
bpy.ops.mesh.select_all(action='SELECT')
|
||||||
|
bpy.ops.mesh.mark_seam(clear=True)
|
||||||
|
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||||||
|
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.minimize_stretch(iterations=STRETCH_ITERS)
|
||||||
|
except (RuntimeError, TypeError) as e:
|
||||||
|
warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e)
|
||||||
|
try: # 统一纹素密度,避免大岛霸占分辨率
|
||||||
|
bpy.ops.uv.average_islands_scale()
|
||||||
|
except RuntimeError as e:
|
||||||
|
warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e)
|
||||||
|
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')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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')"
|
||||||
|
```
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **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 && git commit -m "ModelTranslator: _unwrap_seam 加松弛+纹素均衡,seam 角度参数化
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `_do_unwrap` 分级抢救(角度扫描)
|
||||||
|
|
||||||
|
首选 seam@66 过门则直接用;不过则依次扫 55/45/35,收集候选,用 `pick_best_candidate` 取过门且岛最少者并重跑恢复其 UV;全失败退 smart。UVPM 只在最终选定 UV 上跑一次。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/bl_decimate.py:267-285`(`_do_unwrap`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 替换 `_do_unwrap`**
|
||||||
|
|
||||||
|
将 `bl_decimate.py:267-285` 的整个 `_do_unwrap` 替换为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _do_unwrap(obj, mode, warnings):
|
||||||
|
"""按模式展开:seam 首选 66° 过门即用;否则扫 SEAM_ANGLE_SWEEP,
|
||||||
|
取过门且岛数最少者(重跑恢复其 UV);全失败退 smart。排布 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"]):
|
||||||
|
break # 该档已过门;更低角度只会更碎,无需再试
|
||||||
|
best = pick_best_candidate(candidates)
|
||||||
|
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
|
||||||
|
warnings.append("所有 seam 角度(%s)均未过质量门,回退 smart_project"
|
||||||
|
% "、".join("%d°" % int(a) for a in angles))
|
||||||
|
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: 语法自检**
|
||||||
|
|
||||||
|
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')"
|
||||||
|
```
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 端到端跑 gargoyle(验证扫描逻辑)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000
|
||||||
|
```
|
||||||
|
Expected: 成功输出面数与 UV 行;`模式` 为 `seam@NN+uvpm`(某档走通)**或** `smart_fallback+uvpm`(全失败退回,且日志有「所有 seam 角度…均未过质量门」警告)。对比改动前基线(smart_fallback,324 岛,翻转 0/重叠 2.3%)——记录新岛数/翻转/重叠。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 回归第二个模型(确认首选路径未坏)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000
|
||||||
|
```
|
||||||
|
Expected: 成功;`模式` 合理(seam@66 或某档 / smart_fallback),无异常退出。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: _do_unwrap 改角度扫描分级抢救,取过门且岛最少者
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: UV 线框观察图导出
|
||||||
|
|
||||||
|
最终 UV 定下后用 `export_layout` 导出 `<名>_low_uv.png`;`main()` 接线并写入 MT_SUMMARY。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/bl_decimate.py`(新增 `_ensure_uv_layout_addon` / `_export_uv_layout`;`main()` 接线 + summary 字段)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 新增导出函数**
|
||||||
|
|
||||||
|
在 `bl_decimate.py` 的 `_do_unwrap` 之后、`main` 之前新增:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _ensure_uv_layout_addon():
|
||||||
|
"""启用 UV Layout 导出 add-on(export_layout 依赖,factory-startup 默认关闭)。
|
||||||
|
返回是否有一个候选启用成功。"""
|
||||||
|
import addon_utils
|
||||||
|
for name in UV_LAYOUT_ADDONS:
|
||||||
|
try:
|
||||||
|
if addon_utils.enable(name, default_set=True) is not None:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _export_uv_layout(obj, uv_png_path, warnings):
|
||||||
|
"""导出 UV 线框观察图 PNG;任何失败记警告返回 False,不中断主流程。"""
|
||||||
|
import bpy
|
||||||
|
if not _ensure_uv_layout_addon():
|
||||||
|
warnings.append("UV Layout add-on 不可用,跳过观察图导出")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
|
bpy.ops.mesh.select_all(action='SELECT')
|
||||||
|
bpy.ops.uv.select_all(action='SELECT')
|
||||||
|
bpy.ops.uv.export_layout(filepath=uv_png_path, mode='PNG',
|
||||||
|
size=(UV_PREVIEW_SIZE, UV_PREVIEW_SIZE),
|
||||||
|
opacity=UV_PREVIEW_OPACITY)
|
||||||
|
return True
|
||||||
|
except (RuntimeError, SystemError, TypeError) as e:
|
||||||
|
warnings.append("UV 观察图导出失败(%s)" % e)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if obj.mode != 'OBJECT':
|
||||||
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `main()` 接线**
|
||||||
|
|
||||||
|
在 `bl_decimate.py:325` 的 `uv_info = _do_unwrap(obj, unwrap_mode, warnings)` 之后、`out_dir = ...` 之前插入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png"
|
||||||
|
uv_preview = uv_png if _export_uv_layout(obj, uv_png, warnings) else None
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: summary 增加字段**
|
||||||
|
|
||||||
|
将 `main()` 末尾 `print("MT_SUMMARY " + json.dumps({...}))` 的 dict(`bl_decimate.py:332-335`)改为包含 `uv_preview`:
|
||||||
|
|
||||||
|
```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, "uv_preview": uv_preview, "warnings": warnings},
|
||||||
|
ensure_ascii=False))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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')"
|
||||||
|
```
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 5: 端到端确认观察图生成**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000 && ls -la src/gargoyle_low_uv.png
|
||||||
|
```
|
||||||
|
Expected: 命令成功;`src/gargoyle_low_uv.png` 存在且非空。用 Read 工具打开图片肉眼确认是 UV 线框(岛分布可读)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: 导出 <名>_low_uv.png UV 线框观察图
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: CLI 打印观察图路径
|
||||||
|
|
||||||
|
`model_decimate.py` 日志追加 UV 观察图路径行。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/model_decimate.py:36`(`for w in s["warnings"]` 循环之前)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 追加打印**
|
||||||
|
|
||||||
|
在 `model_decimate.py` 的 UV 指标打印(`:34-35`)之后、`for w in s["warnings"]:`(`:36`)之前插入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if s.get("uv_preview"):
|
||||||
|
print(" UV 观察图: %s" % s["uv_preview"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 端到端确认打印**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000
|
||||||
|
```
|
||||||
|
Expected: 日志含 `UV 观察图: ...gargoyle_low_uv.png` 一行。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: model_decimate CLI 打印 UV 观察图路径
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: README 更新
|
||||||
|
|
||||||
|
补充展开增强与观察图说明,保持文档与行为一致。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Tools/ModelTranslator/README.md:49`(model_decimate.py 说明段)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 更新 model_decimate.py 说明**
|
||||||
|
|
||||||
|
将 `README.md:49` 那条 `**model_decimate.py**:…` 说明中的展开描述更新为反映新行为,追加两点:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补充展开增强与 UV 观察图说明
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 最终验证
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量单测**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_unity_assets -v
|
||||||
|
```
|
||||||
|
Expected: 全部 PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: 端到端 + 观察图肉眼确认**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/gargoyle.fbx --tris 1000 && ls -la src/gargoyle_low_uv.png
|
||||||
|
```
|
||||||
|
Expected:命令成功;日志有 `模式`、UV 指标、`UV 观察图:` 行;`gargoyle_low_uv.png` 存在。用 Read 打开图片确认线框可读。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 记录前后对比**
|
||||||
|
|
||||||
|
在最终汇报里给出改动前(基线 smart_fallback / 324 岛 / 翻转 0% / 重叠 2.3% / 利用率 77.6%)vs 改动后(实际 mode / 岛数 / 翻转 / 重叠 / 利用率)的对比表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review 记录
|
||||||
|
|
||||||
|
- **Spec 覆盖**:①松弛+纹素均衡→Task 3;②角度扫描分级抢救→Task 2(纯函数)+Task 4;③UV 线框观察图→Task 5;CLI 打印→Task 6;错误处理表→各 Blender 任务的 try/except;实现首步验证项→Task 1 spike;README→Task 7;测试/验收→Task 2/4/5/8。全部有对应任务。
|
||||||
|
- **占位符**:无 TBD/TODO;每个代码步给出完整代码与确切命令、预期输出。
|
||||||
|
- **类型/命名一致**:`pick_best_candidate`(Task 2 定义,Task 4 调用一致);`_unwrap_seam(obj, warnings, seam_angle_deg=...)`(Task 3 定义,Task 4 调用一致);`_export_uv_layout`/`_ensure_uv_layout_addon`(Task 5 定义与调用一致);`uv_preview` 字段(Task 5 产出,Task 6 消费,键名一致);常量 `SEAM_ANGLE_SWEEP`/`STRETCH_ITERS`/`UV_PREVIEW_SIZE`/`UV_PREVIEW_OPACITY`/`UV_LAYOUT_ADDONS`(Task 3 定义,Task 4/5 使用一致)。
|
||||||
|
- **已知风险**:三个新算子/add-on 的 headless 可用性由 Task 1 先验;不可用则既有 try/except 降级,不中断。
|
||||||
Reference in New Issue
Block a user