Files
AIC-Project/docs/superpowers/plans/2026-07-16-decimate-uv-seam-unwrap.md
T
2026-07-16 11:50:38 +08:00

15 KiB
Raw Blame History

减面工具 UV 展开优化实现计划

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: model_decimate.py 的 UV 展开从 Smart UV Project 换成"锐边 seam + MINIMUM_STRETCH 展开 + 质量门自动回退",大幅减少 UV 碎岛。

Architecture: 纯逻辑(union-find 数岛、翻转/利用率计算)放 bl_decimate.py 顶部可单测区;Blender 侧新增 seam 展开路径与 bmesh 指标提取,质量门(翻转/重叠占比 >2%)触发时回退原 smart_project 路径。MT_SUMMARY 增加 uv 字段,CLI 增加 --unwrap {seam,smart}(默认 seam)。

Tech Stack: Python 3 标准库、Blender 5.0bpy/bmesh)、unittest。

设计文档: docs/superpowers/specs/2026-07-16-decimate-uv-seam-unwrap-design.md

重要提醒:

  • 仓库路径含 #D:\UD\AI\AIC#Project),bash 里所有路径必须加引号
  • BlenderD:\tools\blender-5.0.0-windows-x64\blender.exe
  • 冒烟素材:Tools/ModelTranslator/src/well1500.fbx149.9 万面)→ 5000 面
  • 产物(_low.fbx)不入 git,只提交代码/文档

文件结构

Tools/ModelTranslator/
  bl_decimate.py            # 修改:纯逻辑区加 3 函数+1 常量;Blender 区重构展开路径
  model_decimate.py         # 修改:--unwrap 参数 + UV 指标打印
  tests/test_bl_decimate.py # 修改:追加纯逻辑测试
  README.md                 # 修改:--unwrap 说明(Task 3

Task 1: UV 指标纯逻辑(TDD

Files:

  • Modify: Tools/ModelTranslator/tests/test_bl_decimate.py(文件末尾 if __name__ 之前追加)

  • Modify: Tools/ModelTranslator/bl_decimate.py(纯逻辑区,within_tolerance 之后、# ---------------- 以下仅在 Blender 内执行 之前)

  • Step 1: 追加失败测试

tests/test_bl_decimate.pyif __name__ == "__main__": 之前插入:

class TestCountIslands(unittest.TestCase):
    def test_no_faces(self):
        self.assertEqual(bd.count_islands(0, []), 0)

    def test_no_pairs_each_face_is_island(self):
        self.assertEqual(bd.count_islands(3, []), 3)

    def test_chain_merges_to_one(self):
        self.assertEqual(bd.count_islands(4, [(0, 1), (1, 2), (2, 3)]), 1)

    def test_two_groups(self):
        self.assertEqual(bd.count_islands(5, [(0, 1), (3, 4)]), 3)  # {0,1} {2} {3,4}

    def test_duplicate_pairs_ok(self):
        self.assertEqual(bd.count_islands(2, [(0, 1), (1, 0), (0, 1)]), 1)


class TestFlippedFraction(unittest.TestCase):
    def test_empty_is_zero(self):
        self.assertEqual(bd.flipped_fraction([]), 0.0)

    def test_mixed(self):
        self.assertAlmostEqual(bd.flipped_fraction([0.1, -0.2, 0.3, 0.4]), 0.25)

    def test_all_positive(self):
        self.assertEqual(bd.flipped_fraction([0.1, 0.2]), 0.0)


class TestFillRatio(unittest.TestCase):
    def test_sum(self):
        self.assertAlmostEqual(bd.fill_ratio([0.2, 0.3]), 0.5)

    def test_clamped_to_one(self):
        self.assertEqual(bd.fill_ratio([0.8, 0.9]), 1.0)

    def test_empty_is_zero(self):
        self.assertEqual(bd.fill_ratio([]), 0.0)
  • Step 2: 运行确认失败

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v Expected: ERRORmodule 'bl_decimate' has no attribute 'count_islands' 等)

  • Step 3: 写实现

bl_decimate.pywithin_tolerance 函数之后、# ---------------- 以下仅在 Blender 内执行 注释之前插入:

UV_BAD_TOL = 0.02   # 质量门:UV 翻转/重叠面占比超此值则回退 smart_project


def count_islands(n_faces, adjacent_pairs):
    """union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
    parent = list(range(n_faces))

    def find(i):
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    for a, b in adjacent_pairs:
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[ra] = rb
    return len({find(i) for i in range(n_faces)})


def flipped_fraction(signed_areas):
    """UV 面签名面积为负(翻转)的占比。"""
    if not signed_areas:
        return 0.0
    return sum(1 for a in signed_areas if a < 0) / float(len(signed_areas))


def fill_ratio(abs_areas):
    """UV 空间利用率:面积和(重叠会虚高,截断到 1.0)。"""
    return min(1.0, sum(abs_areas))
  • Step 4: 运行确认通过

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v Expected: 19 个测试全部 PASS(原 8 + 新 11)

  • Step 5: Commit
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 岛数/翻转/利用率纯逻辑(TDD)"

Task 2: seam 展开路径 + 质量门 + CLI 参数

Files:

  • Modify: Tools/ModelTranslator/bl_decimate.pyBlender 区:替换 _smart_unwrapmain() 接线)

  • Modify: Tools/ModelTranslator/model_decimate.py--unwrap + UV 指标打印)

  • Step 1: bl_decimate.py 替换 _smart_unwrap 为新展开体系

把现有 _smart_unwrap 函数(def _smart_unwrap(obj): 到其 bpy.ops.object.mode_set(mode='OBJECT') 整个函数)整体替换为:

SEAM_ANGLE_DEG = 66.0   # 锐边阈值:两面夹角超此值标 seam
PACK_MARGIN = 0.002     # 岛间距 ≈ 2048 图 4px


def _clear_uv_layers(mesh):
    while mesh.uv_layers:
        mesh.uv_layers.remove(mesh.uv_layers[0])


def _unwrap_smart(obj):
    """Smart UV Project(原路径,也是质量门的回退路径)。"""
    import bpy
    import math
    _clear_uv_layers(obj.data)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.select_all(action='SELECT')
    bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=PACK_MARGIN)
    bpy.ops.object.mode_set(mode='OBJECT')


def _unwrap_seam(obj, warnings):
    """锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> pack。碎岛远少于投影式。"""
    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')
    # 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
    bpy.ops.mesh.select_mode(type='EDGE')
    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.pack_islands(rotate=True, margin=PACK_MARGIN)
    except RuntimeError:  # headless 上下文不满足时靠 unwrap 自带打包
        warnings.append("pack_islands 不可用,沿用 unwrap 自带布局")
    bpy.ops.object.mode_set(mode='OBJECT')


def _collect_uv_metrics(obj):
    """bmesh 提取 UV 指标:岛数(UV 连续边并查集)、利用率、翻转占比、重叠占比。"""
    import bmesh
    bm = bmesh.new()
    bm.from_mesh(obj.data)
    uv = bm.loops.layers.uv.active
    pairs = []
    for e in bm.edges:
        if len(e.link_faces) != 2:
            continue
        f1, f2 = e.link_faces
        cont = True
        for v in e.verts:  # 两面在该边两端 UV 一致 => UV 连续(同岛)
            l1 = next(l for l in f1.loops if l.vert == v)
            l2 = next(l for l in f2.loops if l.vert == v)
            if (l1[uv].uv - l2[uv].uv).length > 1e-6:
                cont = False
                break
        if cont:
            pairs.append((f1.index, f2.index))
    signed = []
    for f in bm.faces:
        area = 0.0
        loops = f.loops
        for i in range(len(loops)):
            a = loops[i][uv].uv
            b = loops[(i + 1) % len(loops)][uv].uv
            area += a.x * b.y - b.x * a.y
        signed.append(area * 0.5)
    n = len(bm.faces)
    bm.free()
    return {"islands": count_islands(n, pairs),
            "fill": round(fill_ratio([abs(a) for a in signed]), 4),
            "flipped": round(flipped_fraction(signed), 4),
            "overlap": _overlap_fraction(obj)}


def _overlap_fraction(obj):
    """uv.select_overlap 统计重叠 UV 面占比;op 上下文不可用返回 None。"""
    import bpy
    bpy.context.scene.tool_settings.use_uv_select_sync = True
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.mode_set(mode='EDIT')
    bpy.ops.mesh.select_all(action='DESELECT')
    try:
        bpy.ops.uv.select_overlap()
    except RuntimeError:
        return None
    finally:
        bpy.ops.object.mode_set(mode='OBJECT')
    polys = obj.data.polygons
    if not len(polys):
        return 0.0
    return round(sum(1 for p in polys if p.select) / float(len(polys)), 4)


def _do_unwrap(obj, mode, warnings):
    """按模式展开;seam 质量不达标自动回退 smart。返回 uv 指标 dict(含 mode)。"""
    if mode == "seam":
        _unwrap_seam(obj, warnings)
        m = _collect_uv_metrics(obj)
        overlap = m["overlap"] or 0.0
        if m["flipped"] <= UV_BAD_TOL and overlap <= UV_BAD_TOL:
            m["mode"] = "seam"
            return m
        warnings.append("seam 展开质量不达标(翻转 %.1f%% 重叠 %s),回退 smart_project"
                        % (m["flipped"] * 100,
                           "%.1f%%" % (m["overlap"] * 100) if m["overlap"] is not None else "未知"))
        mode = "smart_fallback"
    _unwrap_smart(obj)
    m = _collect_uv_metrics(obj)
    m["mode"] = mode if mode == "smart_fallback" else "smart"
    return m
  • Step 2: main() 接线(argv 第 4 参 + uv 字段)

main() 里两处修改。argv 解析行:

    src, out_fbx, target = argv[0], argv[1], int(argv[2])

改为:

    src, out_fbx, target = argv[0], argv[1], int(argv[2])
    unwrap_mode = argv[3] if len(argv) > 3 else "seam"

_smart_unwrap(obj) 调用行改为:

    uv_info = _do_unwrap(obj, unwrap_mode, warnings)

MT_SUMMARY 的 dict 加 "uv": uv_info

    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, "warnings": warnings}, ensure_ascii=False))

同时把文件顶部 docstring 第 1-2 行更新为:

"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
调用:blender -b --factory-startup --python bl_decimate.py -- <src.fbx> <out.fbx> <target_tris> [seam|smart]
"""
  • Step 3: model_decimate.py 加 --unwrap 与指标打印

argparse 增加(--blender 行之前):

    ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
                    help="UV 展开方式:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")

run_blender_script 调用改为:

    s = run_blender_script(blender, "bl_decimate.py",
                           [args.input, out_fbx, str(args.tris), args.unwrap])

结果打印行之后、警告循环之前加:

    u = s["uv"]
    overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a"
    print("  UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" %
          (u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap))

文件 docstring 第 1 行的"并 Smart UV 重展"改为"并重展 UV(默认锐边 seam 整岛展开)"。

  • Step 4: 语法检查 + 全部单测

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_decimate.py model_decimate.py && python -m unittest discover -s tests -v Expected: 编译通过,40 个测试全部 PASS(29 原有 + 11 新增)

  • Step 5: Commit
cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: 减面 UV 改锐边 seam 展开(质量门回退 smart--unwrap 可选)"

Task 3: 双模式冒烟对比 + README

Files:

  • Modify: Tools/ModelTranslator/README.md

  • 产物 src/well1500_low.fbx/tmp 对比数据不入 git

  • Step 1: smart 模式跑基线timeout 300000

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000 --unwrap smart Expected: 正常完成,记录 UV: 模式=smart 岛数=N_smart 利用率=F_smart ...

  • Step 2: seam 模式(默认)跑对比timeout 300000

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000 Expected: UV: 模式=seam ...,且满足验收标准:

  • 岛数 显著低于 smart 基线(至少降一个数量级或 <1/5)
  • 利用率 ≥ smart 基线的 90%
  • 翻转 ≤ 2%、重叠 ≤ 2%(未触发回退警告)

不达标则按 systematic-debugging 排查(常见点:edges_select_sharp 的 sharpness 语义、MINIMUM_STRETCH 枚举名、pack_islands/select_overlap headless poll 失败——后两者已有降级路径,看警告判断),修复后重跑并把修复单独 commit。

  • Step 3: 用新低模快速复烘验证timeout 600000

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_bake.py src/well1500.fbx src/well1500_low.fbx --size 1024 --samples 16 Expected: 5 张 PNG 正常产出、无 error;用下面命令确认 normal/color 非空:

cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python-expr "
import bpy
for n in ('color', 'normal'):
    img = bpy.data.images.load(r'D:\UD\AI\AIC#Project\Tools\ModelTranslator\out_bake\well1500\well1500_%s.png' % n)
    uniq = len(set(round(v, 2) for v in img.pixels[:4000]))
    print('CHECK', n, 'unique=', uniq)
"

Expected: 两图 unique 明显 >1

  • Step 4: README 更新

README.md 中 model_decimate.py 说明条目("旧 UV 全删,Smart UV Project 重展"那句)改为:

- **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边标 seam + MINIMUM_STRETCH 整岛展开(碎岛少、接缝落在硬边),质量不达标(翻转/重叠 >2%)自动回退 Smart UV Project`--unwrap smart` 可强制旧行为;输出 `<名>_low.fbx``-o` 改目录),日志报 UV 岛数/利用率
  • Step 5: 全部单测最后过一遍 + Commit

Run: cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest discover -s tests -v Expected: 40/40 PASS

cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补 --unwrap 说明(well1500 双模式冒烟对比通过)"
  • Step 6: Unity 手动验证(用户操作)

如需上 Unity 验证:python model_translator.py out_bake/well1500/well1500.fbx 后拷 out/well1500/Client/Assets/ 查看接缝表现。此步由用户完成。