Files
AIC-Project/docs/superpowers/plans/2026-07-21-uv-unwrap-optimization.md
T
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

28 KiB
Raw Blame History

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.pyBlender 内脚本)与 model_decimate.pyCLI)。纯函数(候选择优)用 unittest 做 TDD;Blender 算子集成沿用本仓库既有约定——由端到端跑真实 FBX 验证(无法在无 Blender 环境单测)。所有新增算子/导出均为增强项,任一失败降级记警告、不中断,FBX 必产出。

Tech Stack: Python 3(标准库 + unittest)、Blender 5.0 headlessbpy)、UVPackmaster 4(既有,可选)。


File Structure

  • Tools/ModelTranslator/bl_decimate.pyModify):新增常量、pick_best_candidate 纯函数、_unwrap_seam 增强并参数化角度、_do_unwrap 改分级抢救、新增 _export_uv_layout/_ensure_uv_layout_addonmain() 接线。
  • Tools/ModelTranslator/model_decimate.pyModify):CLI 打印 UV 观察图路径。
  • Tools/ModelTranslator/tests/test_bl_decimate.pyModify):新增 pick_best_candidate 单测。
  • Tools/ModelTranslator/README.md(Modify):补充展开增强与观察图说明。

参照文件(无需改):mt_run.pyBlender 运行器 + 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:

"""探针:验证 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:

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 {...},理想全为 okuv_layout_addon 为某个非 None 名字。

  • Step 3: 记录结果,按需调整后续任务

  • 若某算子 FAIL:保留后续任务里对应的 try/except 降级(本就有),并在 commit message 注明该算子不可用。

  • uv_layout_addonNone(两个名字都不行):把探针输出里 addon_errors 贴到 Task 5 的 commit note,并把 UV_LAYOUT_ADDONS 换成探针发现的可用名(若有);仍为 None 则观察图功能整体降级为「记警告跳过」,需告知用户。

  • export_layoutsize/opacity 参数签名报错:记录真实签名,Task 5 相应调整。

  • Step 4: 删除探针脚本并提交记录(无代码改动则跳过提交)

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.pyTestUvpmModeLabel 类之后新增:

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:

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.pyuvpm_mode_label 函数之后新增:

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:

cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate.TestPickBestCandidate -v

Expected: PASS5 tests OK

  • Step 5: 全量单测不回归

Run:

cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate tests.test_unity_assets -v

Expected: 全部 PASS

  • Step 6: 提交
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)之后追加:

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 替换为:

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:

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:

cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v

Expected: 全部 PASS(纯函数区未受影响)

  • Step 5: 提交
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 替换为:

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:

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:

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:

cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000

Expected: 成功;模式 合理(seam@66 或某档 / smart_fallback),无异常退出。

  • Step 5: 提交
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:

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:

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:

"""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:

cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_uv_preview -v

Expected: PASS5 tests OK

  • Step 5: 提交
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.jsonsummary 报告 uv_preview(目标 PNG 路径)与 uv_polys(sidecar 路径)。绘图由系统 Python 侧(Task 7)完成。

Files:

  • Modify: Tools/ModelTranslator/bl_decimate.py(新增 _collect_uv_polygonsmain() 接线 + summary 字段)

  • Step 1: 新增几何抽取函数

bl_decimate.py_do_unwrap 之后、main 之前新增:

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 = ... 之前插入:

    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_previewuv_polys

    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:

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:

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: 提交
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.pyUV 指标打印后、warnings 循环前)

  • Step 1: 接线渲染

model_decimate.py 的 UV 指标打印(现有 print(" UV: ...") 两行)之后、for w in s["warnings"]: 之前插入:

    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:

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.pnggargoyle_low_uv.png 存在且非空;打印 sidecar cleaned OK。用 Read 工具打开 PNG 肉眼确认是 UV 线框(岛分布可读)。

  • Step 3: 提交
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**:… 说明整体替换为反映新行为:

- **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(仅标准库)」之后或替换其括注):

- 系统 Python 3.x(标准库 + Pillow——仅 `model_decimate.py` 绘 UV 观察图用;`pip install Pillow`
  • Step 3: 提交
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:

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:

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_candidateTask 2 定义,Task 4 调用一致);_unwrap_seam(obj, warnings, seam_angle_deg=...)Task 3 定义,Task 4 调用一致);render_uv_png/_to_pxTask 5 定义,Task 7 调用一致);_collect_uv_polygonsTask 6 定义);summary 字段 uv_previewPNG 路径)与 uv_polyssidecar 路径)(Task 6 产出,Task 7 消费,键名一致);常量仅 SEAM_ANGLE_SWEEP/STRETCH_ITERSTask 3 定义,Task 4 使用)——UV 预览尺寸/透明度作为 render_uv_png 默认参数,不再是 bl_decimate 常量。
  • 已知风险Blender 抽几何在无 UV 层时返回空列表(Task 6 已处理);渲染在系统 Python 侧,PIL 已确认可用(12.2.0);sidecar 清理失败仅影响残留文件,不影响 FBX/PNG 产出。