Merge: ModelTranslator UV 减岛 + UVPM 排布利用率优化

--max-overlap 放宽重叠门限抢救更高 seam 角度(gargoyle 3000: 686→390 岛);
减面前网格清理(焊接+去退化);UVPM rotation 15° 提利用率(52.3→55.6%)。
UVPM 启发式因 headless 随机崩引擎弃用(系统调试查明,已文档记录)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-21 16:21:53 +08:00
co-authored by Claude Opus 4.8
8 changed files with 954 additions and 15 deletions
+1 -1
View File
@@ -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 资源) 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 崩溃 - **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`,正好命中转换器的纯网格贴图命名约定 - 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定
+58 -12
View File
@@ -28,9 +28,10 @@ UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_pr
UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%) UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%)
def uv_gate_ok(flipped, overlap): def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL):
"""UV 质量门:翻转与重叠占比都在阈值内。overlap 为 None(op 不可用)按 0 处理。""" """UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= UV_OVERLAP_TOL overlap 为 Noneop 不可用)按 0 处理。"""
return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol
def uvpm_mode_label(base, applied): def uvpm_mode_label(base, applied):
@@ -38,11 +39,11 @@ def uvpm_mode_label(base, applied):
return base + "+uvpm" if applied else base 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。 """从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。
每个 candidate 至少含 flipped/overlap/islands。""" overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。"""
passing = [c for c in candidates 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: if not passing:
return None return None
return min(passing, key=lambda c: c["islands"]) return min(passing, key=lambda c: c["islands"])
@@ -111,13 +112,39 @@ def _decimate(obj, ratio):
_apply_modifier(obj, mod) _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 SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam
PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名 UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径 UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
UVPM_TEX_SIZE = 2048 UVPM_TEX_SIZE = 2048
UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用)
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度 SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数 STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
def _clear_uv_layers(mesh): def _clear_uv_layers(mesh):
@@ -257,9 +284,23 @@ def _uvpm_enable():
raise RuntimeError("扩展 %s 启用失败(未安装或版本不兼容)" % UVPM_EXT) 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): def _uvpm_repack(obj, warnings):
"""UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False, """UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False,
保底布局(pack_islands/smart_project)原样保留。""" 保底布局(pack_islands/smart_project)原样保留。rotation_step 调细以利薄斜条对齐。
注:UVPM 启发式搜索(heuristic)在 headless 下会随机崩溃引擎进程(Engine process died),
确定性/稳定性差,故不启用——只用旋转增强这一稳定杠杆。"""
global _uvpm_failed global _uvpm_failed
if _uvpm_failed: if _uvpm_failed:
return False return False
@@ -270,6 +311,8 @@ def _uvpm_repack(obj, warnings):
p.pixel_margin_enable = True p.pixel_margin_enable = True
p.pixel_margin = UVPM_PIXEL_MARGIN p.pixel_margin = UVPM_PIXEL_MARGIN
p.pixel_margin_tex_size = UVPM_TEX_SIZE 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.scene.tool_settings.use_uv_select_sync = True
bpy.context.view_layer.objects.active = obj bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode='EDIT') bpy.ops.object.mode_set(mode='EDIT')
@@ -288,11 +331,12 @@ def _uvpm_repack(obj, warnings):
return False 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,首个过门者即选并停扫 """按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫
——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在 ——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在
候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发); 候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发);
全失败退 smart。排布 UVPM4 增强,只在最终 UV 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。""" 全失败退 smart。overlap_tol 覆盖重叠门限(翻转仍严格)。排布 UVPM4 增强,只在最终 UV
上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。"""
if mode == "seam": if mode == "seam":
angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP) angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP)
candidates = [] candidates = []
@@ -301,9 +345,9 @@ def _do_unwrap(obj, mode, warnings):
m = _collect_uv_metrics(obj) m = _collect_uv_metrics(obj)
m["angle"] = ang m["angle"] = ang
candidates.append(m) candidates.append(m)
if uv_gate_ok(m["flipped"], m["overlap"]): if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol):
break # 该档已过门;更低角度只会更碎,无需再试 break # 该档已过门;更低角度只会更碎,无需再试
best = pick_best_candidate(candidates) best = pick_best_candidate(candidates, overlap_tol)
if best is not None: if best is not None:
if best["angle"] != candidates[-1]["angle"]: if best["angle"] != candidates[-1]["angle"]:
# 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖) # 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖)
@@ -347,6 +391,7 @@ def main():
argv = sys.argv[sys.argv.index("--") + 1:] argv = sys.argv[sys.argv.index("--") + 1:]
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" 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 = [] warnings = []
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -361,6 +406,7 @@ def main():
return return
obj = join_meshes(meshes) obj = join_meshes(meshes)
_clean_mesh(obj, warnings)
_triangulate(obj) _triangulate(obj)
orig = len(obj.data.polygons) orig = len(obj.data.polygons)
cur = orig cur = orig
@@ -378,7 +424,7 @@ def main():
if not within_tolerance(target, cur) and cur > target: if not within_tolerance(target, cur) and cur > target:
warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%" % (MAX_RETRY, 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_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_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
uv_preview = None uv_preview = None
+7 -2
View File
@@ -1,6 +1,6 @@
"""FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。 """FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] 用法: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 argparse
import os import os
@@ -14,10 +14,14 @@ def main():
ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)") ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)")
ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam", ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project") 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) ap.add_argument("--blender", default=None)
args = ap.parse_args() args = ap.parse_args()
if args.tris <= 0: if args.tris <= 0:
ap.error("--tris 必须为正整数") 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) blender = find_blender(args.blender)
name = os.path.splitext(os.path.basename(args.input))[0] 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") out_fbx = os.path.join(outdir, name + "_low.fbx")
s = run_blender_script(blender, "bl_decimate.py", 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" % print("== %s: %d -> %d 面(目标 %d-> %s" %
(s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx))
@@ -91,6 +91,18 @@ class TestUvGateOk(unittest.TestCase):
def test_overlap_none_treated_as_zero(self): def test_overlap_none_treated_as_zero(self):
self.assertTrue(bd.uv_gate_ok(0.01, None)) 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): class TestUvpmModeLabel(unittest.TestCase):
def test_applied_appends_suffix(self): def test_applied_appends_suffix(self):
@@ -131,6 +143,13 @@ class TestPickBestCandidate(unittest.TestCase):
best = bd.pick_best_candidate(cands) best = bd.pick_best_candidate(cands)
self.assertEqual(best["islands"], 42) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -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 为 Noneop 不可用)按 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## Task 4: `model_decimate.py` 新增 `--max-overlap`
CLI 参数 + 校验 + 透传给 bl_decimateargv 第 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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_candidateTask 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 只是放宽门限、不改这一性质)。
@@ -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_enableUVPM 导入期建视口 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))
# 测试 packUV 球 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) <noreply@anthropic.com>"
```
(在 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) <noreply@anthropic.com>"
```
- [ ] **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-4README→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 确认根因为 heuristicrotation 无辜),且崩溃非确定。用户裁定:**弃用启发式,只留 rotation_step=15**。`_uvpm_repack` 移除重试/启发式,回到单次 UVPM(rotation 15)。最终:gargoyle 52.3→55.6%、well1500 稳定 +uvpm,零崩溃、确定性。
@@ -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 3000UV 岛数显著低于 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% 修正逻辑照常把面数拉到目标,无影响。
@@ -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 = <探针定> # 启发式搜索秒数上限(如 510
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` 回到单次 UVPMrotation 15)。验收从"明显上升"下修为"稳定小幅上升且零崩溃/确定性"。