Files
AIC-Project/docs/superpowers/plans/2026-07-21-uv-unwrap-optimization.md
ud18010andClaude Opus 4.8 f0046ea64d ModelTranslator: 按 spike 结论改 UV 观察图为 PIL 两端架构
headless Blender export_layout(PNG) 走 GPUOffScreen 不可用;改为
Blender 抽 UV 几何→sidecar JSON→系统 Python(Pillow) 绘 PNG。
拆 Task 5(绘图 TDD)/6(抽几何)/7(渲染清理),README 加 Pillow 依赖。

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

688 lines
28 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 展开质量优化 + 线框观察图 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: PASS5 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 观察图相关常量不放这里——headless 无法用 Blender 出 PNG,绘图移到系统 Python 侧的 `uv_preview.py`,见 Task 5。)
- [ ] **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_fallback324 岛,翻转 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_preview.py` — PIL 绘 UV 线框 PNGTDD
新模块,纯函数 `render_uv_png`:拿 UV 多边形(归一化坐标)用 Pillow 画白底半透明填充+线框 PNG。在系统 Python(有 PIL 12.2.0)单测。
**Files:**
- Create: `Tools/ModelTranslator/uv_preview.py`
- Test: `Tools/ModelTranslator/tests/test_uv_preview.py`
- [ ] **Step 1: 写失败测试**
Create `Tools/ModelTranslator/tests/test_uv_preview.py`:
```python
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import uv_preview as up
from PIL import Image
class TestToPx(unittest.TestCase):
def test_origin_bottom_left_maps_to_bottom_left_pixel(self):
# UV(0,0) 原点在左下 -> 图像左下角(x=pad, y=size-pad
x, y = up._to_px(0.0, 0.0, size=100, pad=10)
self.assertAlmostEqual(x, 10.0)
self.assertAlmostEqual(y, 90.0)
def test_top_right(self):
x, y = up._to_px(1.0, 1.0, size=100, pad=10)
self.assertAlmostEqual(x, 90.0)
self.assertAlmostEqual(y, 10.0)
class TestRenderUvPng(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def _out(self, name="uv.png"):
return os.path.join(self.tmp, name)
def test_writes_png_of_requested_size(self):
polys = [[[0.1, 0.1], [0.4, 0.1], [0.25, 0.4]],
[[0.6, 0.6], [0.9, 0.6], [0.9, 0.9], [0.6, 0.9]]]
out = up.render_uv_png(polys, self._out(), size=128)
self.assertTrue(os.path.isfile(out))
with Image.open(out) as im:
self.assertEqual(im.size, (128, 128))
self.assertEqual(im.format, "PNG")
def test_empty_polygons_still_writes_blank(self):
out = up.render_uv_png([], self._out("blank.png"), size=64)
self.assertTrue(os.path.isfile(out))
with Image.open(out) as im:
self.assertEqual(im.size, (64, 64))
def test_degenerate_polygon_skipped_no_crash(self):
out = up.render_uv_png([[[0.5, 0.5]]], self._out("deg.png"), size=64)
self.assertTrue(os.path.isfile(out))
```
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_uv_preview -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'uv_preview'`
- [ ] **Step 3: 写实现**
Create `Tools/ModelTranslator/uv_preview.py`:
```python
"""UV 线框观察图渲染:把 UV 多边形(归一化 0-1 坐标)画成 PNG 供人工查阅。
在系统 Python(有 Pillow)中运行——Blender 自带 Python 无 PIL,故 UV 几何由
bl_decimate 导出为 JSON,本模块负责绘制。"""
from PIL import Image, ImageDraw
def _to_px(u, v, size, pad):
"""UV(0-1, 原点左下) -> 像素(原点左上),含边距。"""
span = size - 2 * pad
x = pad + u * span
y = pad + (1.0 - v) * span # V 轴翻转:图像 y 向下
return x, y
def render_uv_png(polygons, out_png, size=1024, pad=8,
line=(30, 30, 30, 255), fill=(80, 140, 220, 64)):
"""polygons: [[[u,v], ...], ...] 每个多边形一组 UV 顶点。
白底 + 半透明填充 + 深色线框;写 PNG 到 out_png,返回 out_png。"""
base = Image.new("RGBA", (size, size), (255, 255, 255, 255))
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay, "RGBA")
for poly in polygons:
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
if len(pts) >= 3:
draw.polygon(pts, fill=fill)
for poly in polygons:
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
if len(pts) >= 2:
draw.line(pts + pts[:1], fill=line, width=1)
Image.alpha_composite(base, overlay).convert("RGB").save(out_png, "PNG")
return out_png
```
- [ ] **Step 4: 运行测试确认通过**
Run:
```bash
cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_uv_preview -v
```
Expected: PASS5 tests OK
- [ ] **Step 5: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/uv_preview.py Tools/ModelTranslator/tests/test_uv_preview.py && git commit -m "ModelTranslator: 新增 uv_preview.render_uv_png——PIL 绘 UV 线框 PNG
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: `bl_decimate.py` 抽取 UV 几何到 sidecar JSON
Blender 端提取每面 UV 多边形写入 `<名>_low_uv.polys.json`summary 报告 `uv_preview`(目标 PNG 路径)与 `uv_polys`(sidecar 路径)。绘图由系统 Python 侧(Task 7)完成。
**Files:**
- Modify: `Tools/ModelTranslator/bl_decimate.py`(新增 `_collect_uv_polygons``main()` 接线 + summary 字段)
- [ ] **Step 1: 新增几何抽取函数**
`bl_decimate.py``_do_unwrap` 之后、`main` 之前新增:
```python
def _collect_uv_polygons(obj):
"""提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。
无 UV 层返回空列表。"""
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
uv = bm.loops.layers.uv.active
polys = []
if uv is not None:
for f in bm.faces:
polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops])
bm.free()
return polys
```
- [ ] **Step 2: `main()` 接线(抽取 + 写 sidecar**
`main()``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_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
uv_preview = None
try:
polys = _collect_uv_polygons(obj)
os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True)
with open(uv_polys_json, "w", encoding="utf-8") as fp:
json.dump(polys, fp)
uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成
except Exception as e:
warnings.append("UV 几何导出失败(%s),跳过观察图" % e)
```
- [ ] **Step 3: summary 增加字段**
`main()` 末尾 `print("MT_SUMMARY " + json.dumps({...}))` 的 dict 改为包含 `uv_preview``uv_polys`
```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,
"uv_polys": uv_polys_json if uv_preview else None,
"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: 端到端确认 sidecar 生成**
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.polys.json && head -c 120 src/gargoyle_low_uv.polys.json
```
Expected: 命令成功;`src/gargoyle_low_uv.polys.json` 存在且是形如 `[[[...]]]` 的 JSON(此时 Task 7 尚未接线,sidecar 不会被删除,属正常)。
- [ ] **Step 6: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: bl_decimate 抽取 UV 几何到 sidecar JSON
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: `model_decimate.py` 渲染 PNG + 清理 + 打印
系统 Python 侧读 sidecar JSON、用 `uv_preview.render_uv_png` 出 PNG、删 sidecar、打印路径。
**Files:**
- Modify: `Tools/ModelTranslator/model_decimate.py`UV 指标打印后、warnings 循环前)
- [ ] **Step 1: 接线渲染**
`model_decimate.py` 的 UV 指标打印(现有 `print(" UV: ...")` 两行)之后、`for w in s["warnings"]:` 之前插入:
```python
if s.get("uv_polys"):
try:
import json as _json
from uv_preview import render_uv_png
with open(s["uv_polys"], encoding="utf-8") as fp:
polys = _json.load(fp)
render_uv_png(polys, s["uv_preview"])
os.remove(s["uv_polys"])
print(" UV 观察图: %s" % s["uv_preview"])
except Exception as e:
print(" [警告] UV 观察图渲染失败:%s" % e)
```
- [ ] **Step 2: 端到端确认 PNG 生成、sidecar 清理、日志打印**
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 && ( test -f src/gargoyle_low_uv.polys.json && echo "SIDECAR REMAINS (BAD)" || echo "sidecar cleaned OK" )
```
Expected: 日志含 `UV 观察图: ...gargoyle_low_uv.png``gargoyle_low_uv.png` 存在且非空;打印 `sidecar cleaned OK`。用 Read 工具打开 PNG 肉眼确认是 UV 线框(岛分布可读)。
- [ ] **Step 3: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: model_decimate 渲染 UV 观察图 PNG 并清理 sidecar
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: README 更新
补充展开增强与观察图说明,保持文档与行为一致。
**Files:**
- Modify: `Tools/ModelTranslator/README.md`(依赖段 + model_decimate.py 说明段)
- [ ] **Step 1: 更新 model_decimate.py 说明**
`README.md` 那条 `**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 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
```
- [ ] **Step 2: 依赖段补 Pillow**
`README.md` 的「## 依赖」列表中追加一行(放在「系统 Python 3.x(仅标准库)」之后或替换其括注):
```markdown
- 系统 Python 3.x(标准库 + Pillow——仅 `model_decimate.py` 绘 UV 观察图用;`pip install Pillow`
```
- [ ] **Step 3: 提交**
```bash
cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补充展开增强、UV 观察图与 Pillow 依赖说明
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: 最终验证
- [ ] **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 记录(2026-07-21 spike 后修订)
- **Spec 覆盖**:①松弛+纹素均衡→Task 3;②角度扫描分级抢救→Task 2(纯函数)+Task 4;③UV 线框观察图→Task 5(PIL 绘图 TDD)+Task 6(Blender 抽几何)+Task 7(渲染/清理/打印);错误处理→各任务 try/except;实现首步验证→Task 1 spike(已完成,结论见下);README+依赖→Task 8;测试/验收→Task 2/4/5/7/9。全部有对应任务。
- **Task 1 spike 结论**`minimize_stretch` / `average_islands_scale` headless 可用;`export_layout(mode='PNG')` headless **不可用**GPUOffScreen 后台无 GPU);`io_mesh_uv_layout` 是正确 add-on 名但仍受 GPU 限制。故观察图改为 Blender 抽 UV 几何→系统 Python(PIL) 绘 PNG 的两端架构(Task 5–7),不再用 `export_layout`
- **占位符**:无 TBD/TODO;每个代码步给出完整代码与确切命令、预期输出。
- **类型/命名一致**`pick_best_candidate`Task 2 定义,Task 4 调用一致);`_unwrap_seam(obj, warnings, seam_angle_deg=...)`Task 3 定义,Task 4 调用一致);`render_uv_png`/`_to_px`Task 5 定义,Task 7 调用一致);`_collect_uv_polygons`Task 6 定义);summary 字段 `uv_preview`PNG 路径)与 `uv_polys`sidecar 路径)(Task 6 产出,Task 7 消费,键名一致);常量仅 `SEAM_ANGLE_SWEEP`/`STRETCH_ITERS`Task 3 定义,Task 4 使用)——UV 预览尺寸/透明度作为 `render_uv_png` 默认参数,不再是 bl_decimate 常量。
- **已知风险**Blender 抽几何在无 UV 层时返回空列表(Task 6 已处理);渲染在系统 Python 侧,PIL 已确认可用(12.2.0);sidecar 清理失败仅影响残留文件,不影响 FBX/PNG 产出。