diff --git a/Tools/ModelTranslator/README.md b/Tools/ModelTranslator/README.md index 0e9e7a8d..4b3d5c31 100644 --- a/Tools/ModelTranslator/README.md +++ b/Tools/ModelTranslator/README.md @@ -46,7 +46,7 @@ python model_bake.py src/well1500.fbx src/well1500_low.fbx # -> out_bake/well1 python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源) ``` -- **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 角度 +- **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 上跑一次,旋转步进调细到 15° 以利薄斜条对齐提升利用率;UVPM 启发式搜索在 headless 下会随机崩溃引擎故未启用),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度 - **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃 - 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定 diff --git a/Tools/ModelTranslator/bl_decimate.py b/Tools/ModelTranslator/bl_decimate.py index ce28d707..592f6666 100644 --- a/Tools/ModelTranslator/bl_decimate.py +++ b/Tools/ModelTranslator/bl_decimate.py @@ -28,9 +28,10 @@ UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_pr UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%) -def uv_gate_ok(flipped, overlap): - """UV 质量门:翻转与重叠占比都在阈值内。overlap 为 None(op 不可用)按 0 处理。""" - return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= UV_OVERLAP_TOL +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 def uvpm_mode_label(base, applied): @@ -38,11 +39,11 @@ def uvpm_mode_label(base, applied): return base + "+uvpm" if applied else base -def pick_best_candidate(candidates): +def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL): """从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。 - 每个 candidate 至少含 flipped/overlap/islands。""" + overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。""" passing = [c for c in candidates - if uv_gate_ok(c["flipped"], c["overlap"])] + if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)] if not passing: return None return min(passing, key=lambda c: c["islands"]) @@ -111,13 +112,39 @@ def _decimate(obj, ratio): _apply_modifier(obj, mod) +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') + + SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名 UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径 UVPM_TEX_SIZE = 2048 +UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用) SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度 STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数 +REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准) def _clear_uv_layers(mesh): @@ -257,9 +284,23 @@ def _uvpm_enable(): raise RuntimeError("扩展 %s 启用失败(未安装或版本不兼容)" % UVPM_EXT) +def _uvpm_set(p, name, value, warnings): + """防御式设 UVPM 属性:属性存在才设,否则记警告跳过—— + 名字不匹配(UVPM 版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。""" + if hasattr(p, name): + try: + setattr(p, name, value) + except Exception as e: + warnings.append("UVPM 属性 %s 设置失败(%s),跳过" % (name, e)) + else: + warnings.append("UVPM 属性 %s 不存在,跳过" % name) + + def _uvpm_repack(obj, warnings): """UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False, - 保底布局(pack_islands/smart_project)原样保留。""" + 保底布局(pack_islands/smart_project)原样保留。rotation_step 调细以利薄斜条对齐。 + 注:UVPM 启发式搜索(heuristic)在 headless 下会随机崩溃引擎进程(Engine process died), + 确定性/稳定性差,故不启用——只用旋转增强这一稳定杠杆。""" global _uvpm_failed if _uvpm_failed: return False @@ -270,6 +311,8 @@ def _uvpm_repack(obj, warnings): p.pixel_margin_enable = True p.pixel_margin = UVPM_PIXEL_MARGIN p.pixel_margin_tex_size = UVPM_TEX_SIZE + _uvpm_set(p, "rotation_enable", True, warnings) + _uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings) bpy.context.scene.tool_settings.use_uv_select_sync = True bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') @@ -288,11 +331,12 @@ def _uvpm_repack(obj, warnings): return False -def _do_unwrap(obj, mode, warnings): +def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL): """按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫 ——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在 候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发); - 全失败退 smart。排布 UVPM4 增强,只在最终 UV 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。""" + 全失败退 smart。overlap_tol 覆盖重叠门限(翻转仍严格)。排布 UVPM4 增强,只在最终 UV + 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。""" if mode == "seam": angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP) candidates = [] @@ -301,9 +345,9 @@ def _do_unwrap(obj, mode, warnings): m = _collect_uv_metrics(obj) m["angle"] = ang candidates.append(m) - if uv_gate_ok(m["flipped"], m["overlap"]): + if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol): break # 该档已过门;更低角度只会更碎,无需再试 - best = pick_best_candidate(candidates) + best = pick_best_candidate(candidates, overlap_tol) if best is not None: if best["angle"] != candidates[-1]["angle"]: # 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖) @@ -347,6 +391,7 @@ def main(): argv = sys.argv[sys.argv.index("--") + 1:] src, out_fbx, target = argv[0], argv[1], int(argv[2]) 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 warnings = [] bpy.ops.wm.read_factory_settings(use_empty=True) @@ -361,6 +406,7 @@ def main(): return obj = join_meshes(meshes) + _clean_mesh(obj, warnings) _triangulate(obj) orig = len(obj.data.polygons) cur = orig @@ -378,7 +424,7 @@ def main(): if not within_tolerance(target, cur) and cur > target: warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) - uv_info = _do_unwrap(obj, unwrap_mode, warnings) + uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol) 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 diff --git a/Tools/ModelTranslator/model_decimate.py b/Tools/ModelTranslator/model_decimate.py index d851ae4c..f5020e45 100644 --- a/Tools/ModelTranslator/model_decimate.py +++ b/Tools/ModelTranslator/model_decimate.py @@ -1,6 +1,6 @@ """FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。 用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] - [--unwrap seam|smart] [--blender exe]""" + [--unwrap seam|smart] [--max-overlap 0.15] [--blender exe]""" import argparse import os @@ -14,10 +14,14 @@ def main(): ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)") ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam", help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project") + ap.add_argument("--max-overlap", type=float, default=None, + help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛") ap.add_argument("--blender", default=None) args = ap.parse_args() if args.tris <= 0: ap.error("--tris 必须为正整数") + if args.max_overlap is not None and not (0.0 < args.max_overlap <= 1.0): + ap.error("--max-overlap 必须在 (0, 1] 内") blender = find_blender(args.blender) name = os.path.splitext(os.path.basename(args.input))[0] @@ -25,7 +29,8 @@ def main(): out_fbx = os.path.join(outdir, name + "_low.fbx") s = run_blender_script(blender, "bl_decimate.py", - [args.input, out_fbx, str(args.tris), args.unwrap]) + [args.input, out_fbx, str(args.tris), args.unwrap, + "" if args.max_overlap is None else str(args.max_overlap)]) print("== %s: %d -> %d 面(目标 %d)-> %s" % (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) diff --git a/Tools/ModelTranslator/tests/test_bl_decimate.py b/Tools/ModelTranslator/tests/test_bl_decimate.py index 4edc217a..3846eb62 100644 --- a/Tools/ModelTranslator/tests/test_bl_decimate.py +++ b/Tools/ModelTranslator/tests/test_bl_decimate.py @@ -91,6 +91,18 @@ class TestUvGateOk(unittest.TestCase): def test_overlap_none_treated_as_zero(self): self.assertTrue(bd.uv_gate_ok(0.01, None)) + 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)) + class TestUvpmModeLabel(unittest.TestCase): def test_applied_appends_suffix(self): @@ -131,6 +143,13 @@ class TestPickBestCandidate(unittest.TestCase): best = bd.pick_best_candidate(cands) self.assertEqual(best["islands"], 42) + 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) + if __name__ == "__main__": unittest.main() diff --git a/docs/superpowers/plans/2026-07-21-uv-island-reduction.md b/docs/superpowers/plans/2026-07-21-uv-island-reduction.md new file mode 100644 index 00000000..aa6a4299 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-uv-island-reduction.md @@ -0,0 +1,382 @@ +# 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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) " +``` + +--- + +## 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 只是放宽门限、不改这一性质)。 diff --git a/docs/superpowers/plans/2026-07-21-uvpm-packing-utilization.md b/docs/superpowers/plans/2026-07-21-uvpm-packing-utilization.md new file mode 100644 index 00000000..57dbbc96 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-uvpm-packing-utilization.md @@ -0,0 +1,265 @@ +# UVPM 排布利用率优化实现计划(旋转 + 启发式搜索) + +> **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:** 在 `_uvpm_repack` 中开启 UVPM4 的旋转与启发式搜索,把薄条 UV 岛塞得更紧,明显提升 gargoyle 3000 的 UV 利用率而不退化其他指标。 + +**Architecture:** 改动集中在 `bl_decimate.py` 的 `_uvpm_repack`(Blender 脚本)。因 UVPM4 属性名/默认值随版本可能不同,先用一次性探针脚本确认确切属性名与取值,再据实设置;属性设置走防御式 `_uvpm_set`(`hasattr` 才设),单个属性名不匹配只跳过、不拖垮整个 UVPM 排布。无纯函数可 TDD,靠探针 + 端到端验证(沿用本仓库约定)。 + +**Tech Stack:** Python 3、Blender 5.0 headless(`bpy`)、UVPackmaster 4 扩展。 + +--- + +## File Structure + +- `Tools/ModelTranslator/bl_decimate.py`(Modify):新增常量 `UVPM_ROTATION_STEP`/`UVPM_HEURISTIC_TIME` 与辅助 `_uvpm_set`;在 `_uvpm_repack` 的 margin 设置后、pack 前开启旋转+启发式。 +- `Tools/ModelTranslator/README.md`(Modify):注明 UVPM 排布已开旋转+启发式。 +- `Tools/ModelTranslator/tests/bl_probe_uvpm.py`(Create then Delete):一次性探针,不入库。 + +--- + +## Task 1: UVPM4 属性探针(spike) + +确认 UVPM4 `default_main_props` 的旋转/启发式属性名、默认值、类型,并验证 headless 下开启后能跑通 pack。结果决定 Task 2 的确切属性名与取值。 + +**Files:** +- Create: `Tools/ModelTranslator/tests/bl_probe_uvpm.py`(用完删除,不入库) + +- [ ] **Step 1: 写探针脚本** + +Create `Tools/ModelTranslator/tests/bl_probe_uvpm.py`: + +```python +"""探针:枚举 UVPM4 default_main_props 的旋转/启发式/边距属性,并在测试网格上 +验证 headless 开启后能跑通 pack。运行: +blender -b --factory-startup --python tests/bl_probe_uvpm.py""" +import bpy +import addon_utils + +# headless GPU 补丁(同 _uvpm_enable:UVPM 导入期建视口 shader 会 SystemError) +import gpu +_orig = gpu.shader.from_builtin +def _safe(*a, **k): + try: + return _orig(*a, **k) + except SystemError: + return None +gpu.shader.from_builtin = _safe + +UVPM_EXT = "bl_ext.user_default.uvpackmaster4" +if addon_utils.enable(UVPM_EXT, default_set=True) is None: + print("UVPM_PROBE {'error': 'enable failed'}") + raise SystemExit + +p = bpy.context.scene.uvpm4_props.default_main_props + +# 枚举与 旋转/启发式/搜索/边距 相关的属性名 + 当前值 +keys = ("rot", "heurist", "search", "margin", "iter") +props = {} +for n in dir(p): + if n.startswith("_"): + continue + if any(k in n.lower() for k in keys): + try: + props[n] = repr(getattr(p, n)) + except Exception as e: + props[n] = "ERR:%r" % e +print("UVPM_PROPS " + repr(props)) + +# 测试 pack:UV 球 smart_project,尽力开启旋转/启发式,跑一次 pack +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() + +def try_set(name, val): + if not hasattr(p, name): + return "absent" + try: + setattr(p, name, val) + return "ok=%r" % getattr(p, name) + except Exception as e: + return "ERR:%r" % e + +tried = { + "rotation_enable": try_set("rotation_enable", True), + "rotation_step": try_set("rotation_step", 90), + "heuristic_enable": try_set("heuristic_enable", True), + "heuristic_search_time": try_set("heuristic_search_time", 3), + "heuristic_max_wait_time": try_set("heuristic_max_wait_time", 3), +} +bpy.context.scene.tool_settings.use_uv_select_sync = True +try: + ret = bpy.ops.uvpackmaster4.pack(mode_id='pack.single_tile', pack_op_type='0') + tried["pack"] = sorted(ret) +except Exception as e: + tried["pack"] = "ERR:%r" % e +finally: + bpy.ops.object.mode_set(mode='OBJECT') +print("UVPM_TRY " + repr(tried)) +``` + +- [ ] **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_uvpm.py 2>&1 | grep -E "UVPM_PROPS|UVPM_TRY|UVPM_PROBE" +``` +Expected: 两行 `UVPM_PROPS {...}`(可用属性名+默认值)与 `UVPM_TRY {...}`(各属性设置结果 + `pack` 返回 `['FINISHED']`)。 + +- [ ] **Step 3: 记录结论** + +从输出确认并记录: +- 旋转属性的确切名(`rotation_enable` 是否存在?步进属性名与类型/取值范围,如 `rotation_step` 是 int 度数还是 enum)。 +- 启发式属性的确切名(`heuristic_enable`?时间上限是 `heuristic_search_time` 还是 `heuristic_max_wait_time`?单位/类型)。 +- `pack` 是否返回 `FINISHED`(确认 headless 可跑)。 +- 若启发式需要非 `'0'` 的 `pack_op_type` 或专门 mode,记录之。 +这些结论用于 Task 2 的确切属性名与常量取值。 + +- [ ] **Step 4: 删除探针脚本** + +```bash +cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && rm tests/bl_probe_uvpm.py +``` +(探针一次性、不入库;结论写入 Task 2 的 commit 正文。) + +--- + +## Task 2: `_uvpm_repack` 开启旋转 + 启发式 + +新增防御式 `_uvpm_set` 辅助与常量,在 `_uvpm_repack` 的 margin 设置后、pack 前开启旋转与启发式搜索。**属性名与取值以 Task 1 探针结论为准**——下方代码用最可能的名字,执行时按探针实测替换/校正。 + +**Files:** +- Modify: `Tools/ModelTranslator/bl_decimate.py`(常量区 + `_uvpm_repack` 之前新增 `_uvpm_set` + `_uvpm_repack` 内部) + +**Task 1 探针结论(已确认,用于本任务)**:`default_main_props` 上 `rotation_enable`(bool, 默认 True)、`rotation_step`(int 度, 默认 90)、`heuristic_enable`(bool, 默认 False)、`heuristic_search_time`(int 秒, 默认 0=无限) 均存在且可设;旋转默认已开、pre-rotation 默认开(`pre_rotation_disable=False`);`pack` 返回 `{'FINISHED','PASS_THROUGH'}`,现有 `if 'FINISHED' not in ret` 判定已兼容;无需改 `pack_op_type`。真正需显式开的是 **heuristic**,并把 rotation_step 调细以利薄斜条对齐。 + +- [ ] **Step 1: 新增常量** + +在 `bl_decimate.py` 常量区 `UVPM_TEX_SIZE = 2048` 那一行之后追加: + +```python +UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐 +UVPM_HEURISTIC_TIME = 10 # UVPM 启发式搜索秒数上限(默认 0=无限,必须给正值上限) +``` + +- [ ] **Step 2: 新增 `_uvpm_set` 辅助** + +在 `bl_decimate.py` 的 `_uvpm_repack` 函数**之前**新增: + +```python +def _uvpm_set(p, name, value, warnings): + """防御式设 UVPM 属性:属性存在才设,否则记警告跳过—— + 名字不匹配(UVPM 版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。""" + if hasattr(p, name): + try: + setattr(p, name, value) + except Exception as e: + warnings.append("UVPM 属性 %s 设置失败(%s),跳过" % (name, e)) + else: + warnings.append("UVPM 属性 %s 不存在,跳过" % name) +``` + +- [ ] **Step 3: 在 `_uvpm_repack` 开启旋转 + 启发式** + +在 `_uvpm_repack` 中,现有三行 margin 设置(`p.pixel_margin_enable = True` / `p.pixel_margin = UVPM_PIXEL_MARGIN` / `p.pixel_margin_tex_size = UVPM_TEX_SIZE`)之后、`bpy.context.scene.tool_settings.use_uv_select_sync = True` 之前,插入(属性名以 Task 1 为准替换): + +```python + _uvpm_set(p, "rotation_enable", True, warnings) + _uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings) + _uvpm_set(p, "heuristic_enable", True, warnings) + _uvpm_set(p, "heuristic_search_time", UVPM_HEURISTIC_TIME, warnings) +``` + +注意: +- 属性名已由 Task 1 探针确认全部存在(`rotation_enable`/`rotation_step`/`heuristic_enable`/`heuristic_search_time`)——用上面这四行即可,无需改名。 +- `pack_op_type` 保持 `'0'`(探针确认启发式开启下 `'0'` 正常返回 FINISHED,无需改)。 +- 这些设置在现有 `_uvpm_repack` 的 `try/except Exception` 内;`_uvpm_set` 的 `hasattr` 守卫确保单属性问题不触发整体回退。 + +- [ ] **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 3 端到端验证。) + +- [ ] **Step 5: 提交** + +```bash +cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py && git commit -m "ModelTranslator: _uvpm_repack 开启旋转+启发式搜索(防御式设属性) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` +(在 commit 正文补一句 Task 1 探针确认的确切属性名/取值,便于追溯。) + +--- + +## Task 3: 端到端验证 + README + +**Files:** +- Modify: `Tools/ModelTranslator/README.md` + +- [ ] **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: 端到端主验收(gargoyle 3000)+ 计时** + +Run: +```bash +cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && rm -f src/gargoyle_low_uv.png && PYTHONIOENCODING=utf-8 python -c "import time,subprocess,sys; t=time.time(); r=subprocess.run([sys.executable,'model_decimate.py','src/gargoyle.fbx','--tris','3000','--max-overlap','0.15'],capture_output=True,text=True,encoding='utf-8'); print(r.stdout[-800:]); print('ELAPSED %.1fs'%(time.time()-t))" +``` +Expected: `模式=seam@45+uvpm`;岛数≈390(排布不改岛数);**利用率明显高于基线 52.3%**;翻转 0%;重叠 ≤13.3%(不高于基线)。记录利用率与 `ELAPSED` 秒数(pack 增量)。若有 `UVPM 属性 … 不存在` 警告说明属性名未对齐,需回 Task 2 用探针名校正。 + +- [ ] **Step 3: 观察图肉眼确认** + +用 Read 工具打开 `src/gargoyle_low_uv.png`,确认薄条岛塞得更紧、空白(黑块)区域明显减少。 + +- [ ] **Step 4: 回归 well1500(正常 seam@66 情形)** + +Run: +```bash +cd "d:/UD/AI/AIC#Project/Tools/ModelTranslator" && PYTHONIOENCODING=utf-8 python model_decimate.py src/well1500.fbx --tris 5000 2>&1 | tail -4 +``` +Expected: 成功,无异常退出;`模式=seam@NN+uvpm`;利用率不低于其历史水平(排布增强不应变差)。 + +- [ ] **Step 5: 更新 README** + +在 `Tools/ModelTranslator/README.md` 的 `- **model_decimate.py**:…` 那条中,把 `排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次)` 这一处描述扩写为: + +```markdown +排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次,已开旋转+启发式搜索以提升薄条岛的利用率) +``` + +(只改这一处短语,不动其余。) + +- [ ] **Step 6: 提交** + +```bash +cd "d:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 注明 UVPM 已开旋转+启发式搜索 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +- [ ] **Step 7: 汇报前后对比** + +在最终汇报里给出:基线(seam@45 / 390 岛 / 52.3% / 翻转 0% / 重叠 13.3%)vs 现在(利用率 / 岛数 / 翻转 / 重叠 / pack 耗时),并附观察图观察结论。 + +--- + +## Self-Review 记录 + +- **Spec 覆盖**:①探针确认属性名→Task 1;②`_uvpm_repack` 开旋转+启发式(防御式 `_uvpm_set` + 常量)→Task 2;③验证(gargoyle 主验收 + well1500 回归 + 观察图)→Task 3 Step 2-4;README→Task 3 Step 5;错误处理(属性名不匹配跳过、整体失败回退、启发式时间上限)→Task 2 `_uvpm_set` 守卫 + 常量。全部有对应任务。 +- **占位符**:探针脚本、`_uvpm_set`、常量、各命令均为完整内容。属性名标注"以 Task 1 为准"是 spike-first 设计的必要性质(执行时由 Task 1 输出定稿),非未决占位——`hasattr` 守卫保证即便名字有出入也不崩,Task 3 Step 2 的"属性不存在"警告会兜住并要求回改。 +- **类型/命名一致**:`_uvpm_set(p, name, value, warnings)`(Task 2 Step 2 定义,Step 3 调用一致);常量 `UVPM_ROTATION_STEP`/`UVPM_HEURISTIC_TIME`(Task 2 Step 1 定义,Step 3 使用一致);`p` 即 `scene.uvpm4_props.default_main_props`(与现有 `_uvpm_repack` 一致)。 +- **已知风险**:UVPM4 属性名/取值由 Task 1 探针定;启发式若需特定 `pack_op_type` 在 Task 2 据实调整;薄条几何天然限制上限,验收以"明显上升"而非硬数字(spec 一致)。 + +## 执行修订(2026-07-21) + +Task 3 端到端验证暴露:**UVPM 启发式在 headless 下随机崩溃引擎**(well1500 3/3、gargoyle 1/2)。经 systematic-debugging 确认根因为 heuristic(rotation 无辜),且崩溃非确定。用户裁定:**弃用启发式,只留 rotation_step=15**。`_uvpm_repack` 移除重试/启发式,回到单次 UVPM(rotation 15)。最终:gargoyle 52.3→55.6%、well1500 稳定 +uvpm,零崩溃、确定性。 diff --git a/docs/superpowers/specs/2026-07-21-uv-island-reduction-design.md b/docs/superpowers/specs/2026-07-21-uv-island-reduction-design.md new file mode 100644 index 00000000..06f11aca --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-uv-island-reduction-design.md @@ -0,0 +1,109 @@ +# UV 减岛优化:--max-overlap 参数 + 减面前网格清理 + +日期:2026-07-21 +范围:`Tools/ModelTranslator/bl_decimate.py`、`Tools/ModelTranslator/model_decimate.py` + +## 背景与动机 + +减面工具的 seam 角度扫描(66→55→45→35°)在质量门(翻转 ≤2%、重叠 ≤8%)失败时下探角度抢救。角度越低 → seam 越多 → UV 岛越碎。实测 gargoyle 3000 面走到 `seam@35`,产生 **686 个碎岛、利用率仅 38.2%**。 + +关键事实: +- **UVPackmaster 只排布、不合并岛**,无法减少岛数。 +- 低利用率是**岛数过多的症状**——每岛一圈 padding,岛越碎、总周长越大、留白越多。 +- 两个问题同源同解:**减少 seam/岛数**(展开阶段),利用率随之回升。 + +用户诉求:**岛数优先**,愿意用少量重叠/利用率换更少的岛。 + +## 目标 + +1. 让更高的 seam 角度(岛更少)能通过质量门被选中——通过**可运行时覆盖的重叠容忍**(新增 `--max-overlap`)。翻转门限保持严格。 +2. 减面前清理源网格(焊接重合点 + 去退化面),给 collapse 更干净的流形,从源头减少被迫加的 seam。 +3. 默认行为不变;减岛为显式开启。 + +## 非目标(YAGNI) + +- 不改 UVPM 排布逻辑(它减不了岛)。 +- 不放宽翻转门限 `UV_FLIP_TOL`(翻转无法靠排布修复)。 +- 不改减面后低模(清理只在减面前的高模上做,避免改变目标面数)。 +- 不改 `model_bake.py` / `model_translator.py` / `uv_preview.py`。 +- 不做减面后的碎岛后处理/岛合并(无通用可靠算子)。 + +## 设计 + +### 1. `--max-overlap` 参数穿透 + +重叠容忍从模块常量升级为可运行时覆盖,翻转门限不动。 + +**`bl_decimate.py`**: +- `uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL)`:`return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol`。加默认参数,现有调用与测试(`uv_gate_ok(0.0, 0.0)` 等)不受影响。 +- `pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL)`:把 `overlap_tol` 透传给 `uv_gate_ok`。 +- `_do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL)`:扫描循环内 `uv_gate_ok(m["flipped"], m["overlap"], overlap_tol)` 与 `pick_best_candidate(candidates, overlap_tol)` 均用它。 +- `main()`:argv 增加第 5 个可选位置参数 max_overlap;解析 `overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL`;传入 `_do_unwrap`。 + +**`model_decimate.py`**: +- 新增 `--max-overlap`(`type=float`,默认 `None`)。 +- 传参:`run_blender_script(..., [args.input, out_fbx, str(args.tris), args.unwrap, "" if args.max_overlap is None else str(args.max_overlap)])`(第 5 位;None → 空串,Blender 侧回落到内置默认)。 +- 校验:`--max-overlap` 若给定需在 (0, 1] 内,否则 `ap.error`。 + +日志与 `mode` 标注不变。放宽后,若 `seam@55` 重叠 12% ≤ 15%,即被选中,岛数远少于 `seam@35`。 + +### 2. 减面前网格清理 + +**`bl_decimate.py`** 新增 `_clean_mesh(obj, warnings)`,在 `main()` 的 `obj = join_meshes(meshes)` 之后、`_triangulate(obj)` 之前调用: + +``` +- 进入 EDIT、全选 +- remove_doubles(threshold=weld) # 焊接重合点 +- dissolve_degenerate() # 去零面积/退化面 +- 回 OBJECT +``` + +- 焊接阈值**相对局部包围盒对角线**:新常量 `REL_WELD = 1e-4`(对角线的 0.01%)。用 `obj.bound_box`(局部坐标 8 角)算对角线 `diag`,`weld = diag * REL_WELD`。避免 cm/m 单位差异导致绝对阈值焊过头或焊不动。`diag <= 0`(退化输入)时跳过焊接、记警告。 +- 两步各自 try/except(`RuntimeError`):失败记警告、跳过、不中断。 +- 清理在三角化前做(remove_doubles/dissolve_degenerate 对任意网格有效;更干净的流形让后续三角化+collapse 少产狭长三角)。 + +### 3. 数据流 + +``` +FBX → join → _clean_mesh(焊接+去退化) → triangulate → decimate(±3%) + → _do_unwrap(overlap_tol=max_overlap or 8%): + seam 66→55→45→35(各 unwrap+stretch+均衡,用 overlap_tol 判门) + 首个过门即选(角度高→岛少);pick_best_candidate(overlap_tol) 兜底择优 + 全失败 → smart + → UVPM 一次 → 抽 UV 几何 sidecar → export fbx + → MT_SUMMARY(json) +``` + +## 错误处理 + +| 失败点 | 处理 | +|---|---| +| `remove_doubles` / `dissolve_degenerate` 不可用 | try/except RuntimeError 记警告,跳过该步 | +| 包围盒对角线 ≤ 0 | 跳过焊接,记警告 | +| `--max-overlap` 越界 (0,1] | `model_decimate` 侧 `ap.error` 提前拒绝 | +| max_overlap 空/缺省 | Blender 侧回落 `UV_OVERLAP_TOL`(默认 8%),行为不变 | + +原则:清理与门限放宽均为可选增强,单点失败降级不中断,FBX 必产出。 + +## 测试与验证 + +1. **纯函数 TDD**(`tests/test_bl_decimate.py`): + - `uv_gate_ok` 带 `overlap_tol`:默认值下同现状;显式 `overlap_tol=0.15` 时 overlap=0.12 通过、0.16 不过;翻转仍按 `UV_FLIP_TOL` 严格(overlap_tol 放宽不影响翻转判定)。 + - `pick_best_candidate` 带 `overlap_tol`:放宽后原本不过门(overlap 介于 8%~15%)的候选变为过门并可被选中;无参时同现状。 +2. **现有单测不回归**:`python -m unittest tests.test_bl_decimate tests.test_uv_preview tests.test_unity_assets`。 +3. **端到端**: + - `python model_decimate.py src/gargoyle.fbx --tris 3000 --max-overlap 0.15`:对比基线(686 岛 / 38.2% / seam@35 / 重叠 6.3%)——期望走更高角度、**岛数明显下降、利用率上升**;产出 FBX + UV 观察图。 + - `python model_decimate.py src/gargoyle.fbx --tris 3000`(无 `--max-overlap`):确认默认行为不回归(仍 8% 门限)。注意网格清理默认开启,默认跑的指标可能与历史基线略有不同——如变化记录并说明。 + +## 验收标准 + +- `--max-overlap 0.15` 下 gargoyle 3000:UV 岛数显著低于 686 且利用率高于 38.2%;`mode` 反映实际生效的更高角度(如 `seam@55`/`seam@45`)。 +- 默认路径(无 `--max-overlap`):翻转门限严格不变;流程不崩,FBX + 观察图照常产出。 +- 网格清理任一算子不可用时降级不中断。 +- 现有单测全绿;新增纯函数测试通过。 + +## 实现注意 + +- `_do_unwrap`/`pick_best_candidate`/`uv_gate_ok` 的新参数一律 keyword-default,保持向后兼容与现有测试不改。 +- argv 第 5 位为空串时必须回落默认,不能 `float("")` 崩溃。 +- `_clean_mesh` 改变面数(去退化会略减面),但发生在 decimate 之前,decimate 的 ±3% 修正逻辑照常把面数拉到目标,无影响。 diff --git a/docs/superpowers/specs/2026-07-21-uvpm-packing-utilization-design.md b/docs/superpowers/specs/2026-07-21-uvpm-packing-utilization-design.md new file mode 100644 index 00000000..5d44c729 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-uvpm-packing-utilization-design.md @@ -0,0 +1,113 @@ +# UVPM 排布利用率优化:开启旋转 + 启发式搜索 + +日期:2026-07-21 +范围:`Tools/ModelTranslator/bl_decimate.py`(`_uvpm_repack`)、`README.md` + +## 背景与动机 + +减岛优化后 gargoyle 3000 面走 seam@45、390 岛,但 UV 利用率仅 **52.3%**——约 48% 为空白(烘焙时填黑,用户观感为"错误的黑色块")。 + +根因定位(已查 `_uvpm_repack` `bl_decimate.py:286-314`): +- 当前 UVPM 调用只设 `pixel_margin`(4px@2048),**旋转/启发式搜索全用 UVPM4 默认值**:`bpy.ops.uvpackmaster4.pack(mode_id='pack.single_tile', pack_op_type='0')`。 +- gargoyle 减面后 UV 由大量**又长又斜的薄条岛**(尖刺/薄翅缘几何)主导。薄斜条在缺乏精细旋转对齐时外接矩形浪费极大——这是 packer 效率的天敌。 +- UVPM 的旋转 + 启发式迭代搜索正是为此类不规则岛设计的,当前未启用。 + +用户选择:**只调 UVPM 排布**(不碰边距/烘焙),启发式耗时可接受(离线工具,设时间上限)。 + +## 目标 + +开启 UVPM4 的旋转与启发式搜索,把薄条岛塞得更紧,**明显提升 gargoyle 3000 的 UV 利用率**,且不退化其他指标(岛数、翻转、重叠不变差)。 + +## 非目标(YAGNI) + +- 不改像素边距 `pixel_margin`(4px)与 `model_bake` 的烘焙 padding(避免渗色,用户明确排除)。 +- 不改展开/减岛/清理逻辑(`_do_unwrap`、`_clean_mesh`、`--max-overlap` 均不动)。 +- 不动 `average_islands_scale`(纹素均匀,保留)。 +- 不追求硬性利用率数字(薄条几何天然受限),只求"明显上升"。 + +## 设计 + +### 1. UVPM4 属性探针(实现首步 spike) + +因 UVPM4 属性名/默认值随版本可能不同,先用一次性脚本确认,避免猜 API: + +- 启用 UVPM4(复用 `_uvpm_enable` 的 headless GPU 补丁思路)。 +- 枚举 `bpy.context.scene.uvpm4_props.default_main_props` 中旋转/启发式相关属性:名字、默认值、类型/取值范围(候选:`rotation_enable`、`rotation_step`、`rotation_step_value`、`heuristic_enable`、`heuristic_search_time`、`heuristic_max_wait_time` 等)。 +- 在测试网格(UV 球 + smart_project)上开启这些属性跑一次 `uvpackmaster4.pack`,确认 headless 不报错、返回 `FINISHED`。 +- 产出:确切属性名与合理取值,供第 2 节定稿;脚本用完删除、不入库。 + +### 2. `_uvpm_repack` 开启旋转 + 启发式 + +新增防御式辅助与常量,在 `_uvpm_repack`(`bl_decimate.py:294-305`)的 `pixel_margin` 设置之后、`pack` 调用之前设置属性: + +```python +# 常量(值以探针结论为准,示意) +UVPM_ROTATION_STEP = <探针定> # 旋转步进(度),更细利于薄条对齐 +UVPM_HEURISTIC_TIME = <探针定> # 启发式搜索秒数上限(如 5–10) + +def _uvpm_set(p, name, value, warnings): + """防御式设 UVPM 属性:属性存在才设,否则记警告跳过—— + 名字不匹配(版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。""" + if hasattr(p, name): + setattr(p, name, value) + else: + warnings.append("UVPM 属性 %s 不存在,跳过" % name) +``` + +在 `_uvpm_repack` 内(属性名以探针为准): + +```python +_uvpm_set(p, "rotation_enable", True, warnings) +_uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings) +_uvpm_set(p, "heuristic_enable", True, warnings) +_uvpm_set(p, "heuristic_search_time", UVPM_HEURISTIC_TIME, warnings) +``` + +- 属性设置在现有 `try/except Exception`(`bl_decimate.py:293-314`)内——但用 `_uvpm_set` 的 `hasattr` 守卫,使**单个属性名不匹配只跳过该项、不抛异常**,从而不会触发整体失败回退(那会丢掉 UVPM 全部排布)。 +- 旋转后的 UV 对烘焙无影响(烘焙按 UV 原样采样)。 +- 边距、`pack` 调用签名(`mode_id`/`pack_op_type`)保持不变,除非探针发现启发式必须换 `pack_op_type`(若如此,在第 2 节据实调整并记录)。 + +### 3. 数据流(不变,仅 UVPM 内部行为增强) + +``` +… → _do_unwrap → 选定 UV → _uvpm_repack(现在:margin + 旋转 + 启发式搜索)→ 抽 UV 几何 → export +``` + +## 错误处理 + +| 失败点 | 处理 | +|---|---| +| UVPM 某属性名不存在(版本差异) | `_uvpm_set` 记警告、跳过该属性,其余照设、pack 照跑 | +| UVPM 启用/pack 整体失败 | 现有 `_uvpm_repack` try/except:记警告、`_uvpm_failed=True`、回退内置 pack(行为不变) | +| 启发式耗时过长 | 由 `UVPM_HEURISTIC_TIME` 上限约束 | + +原则:排布增强单点失败降级,不影响主流程与 FBX 产出。 + +## 测试与验证 + +1. **现有单测不回归**:`python -m unittest tests.test_bl_decimate tests.test_uv_preview tests.test_unity_assets`(本改动无纯函数,主要确认无破坏)。 +2. **端到端(主验收)**:`python model_decimate.py src/gargoyle.fbx --tris 3000 --max-overlap 0.15`,对比基线(seam@45 / 390 岛 / **利用率 52.3%** / 翻转 0% / 重叠 13.3%): + - 利用率**明显上升**; + - 岛数不变(排布不改岛数)、翻转仍 0%、重叠仍 ≤ 门限; + - 记录 pack 耗时增量; + - Read 观察图确认薄条更紧、黑块变少。 +3. **回归**:`python model_decimate.py src/well1500.fbx --tris 5000`(正常走 seam@66 的模型)确认排布增强不破坏,流程不崩、产物正常。 + +## 验收标准 + +- gargoyle 3000(`--max-overlap 0.15`):UV 利用率明显高于 52.3%;岛数/翻转/重叠不退化。 +- 属性名不匹配时降级不崩、仍出 UVPM 排布(或内置兜底)。 +- well1500 回归正常。 +- 现有单测全绿。 + +## 实现注意 + +- 属性名/取值一律以第 1 节探针结论为准;spike 未确认前不写死具体名。 +- `_uvpm_set` 的 `hasattr` 守卫是关键:防止版本差异把整个 UVPM 排布拖垮。 +- 若探针发现启发式需要非 `'0'` 的 `pack_op_type` 或专门 `mode_id`,在计划中据实调整并在 README 注明。 + +## 实现修订(2026-07-21,系统调试后) + +实现中发现 **UVPM 启发式搜索在 headless 下会随机崩溃引擎进程**(`Error: Engine process died unexpectedly`):well1500 崩 3/3、gargoyle 崩 1/2(同模型重跑结果不一致),旋转步进无辜。启发式因此不可靠、且使输出非确定。 + +**最终落地**:弃用启发式,只保留 `rotation_step=15`(稳定、零崩溃、确定性)。实测利用率提升较小但可靠——gargoyle 3000(`--max-overlap 0.15`)52.3%→**55.6%**,well1500 5000 稳定 `+uvpm`。移除 `_uvpm_pack_once`/重试与 `UVPM_HEURISTIC_TIME`;`_uvpm_repack` 回到单次 UVPM(rotation 15)。验收从"明显上升"下修为"稳定小幅上升且零崩溃/确定性"。