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:
@@ -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`,正好命中转换器的纯网格贴图命名约定
|
||||||
|
|
||||||
|
|||||||
@@ -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 为 None(op 不可用)按 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
|
||||||
|
|||||||
@@ -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 为 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) <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_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) <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_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 只是放宽门限、不改这一性质)。
|
||||||
@@ -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) <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-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,零崩溃、确定性。
|
||||||
@@ -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% 修正逻辑照常把面数拉到目标,无影响。
|
||||||
@@ -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)。验收从"明显上升"下修为"稳定小幅上升且零崩溃/确定性"。
|
||||||
Reference in New Issue
Block a user