From 4e27b9ae9b54cac06ab3288bc333850186c76ba0 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 16 Jul 2026 11:54:59 +0800 Subject: [PATCH] =?UTF-8?q?ModelTranslator:=20=E5=87=8F=E9=9D=A2=20UV=20?= =?UTF-8?q?=E6=94=B9=E9=94=90=E8=BE=B9=20seam=20=E5=B1=95=E5=BC=80?= =?UTF-8?q?=EF=BC=88=E8=B4=A8=E9=87=8F=E9=97=A8=E5=9B=9E=E9=80=80=20smart?= =?UTF-8?q?=EF=BC=8C--unwrap=20=E5=8F=AF=E9=80=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- Tools/ModelTranslator/bl_decimate.py | 129 ++++++++++++++++++++++-- Tools/ModelTranslator/model_decimate.py | 10 +- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/Tools/ModelTranslator/bl_decimate.py b/Tools/ModelTranslator/bl_decimate.py index d553dd41..e4fbf806 100644 --- a/Tools/ModelTranslator/bl_decimate.py +++ b/Tools/ModelTranslator/bl_decimate.py @@ -1,5 +1,5 @@ -"""Blender 内运行:FBX 减面到指定三角面数 + Smart UV 重展。 -调用:blender -b --factory-startup --python bl_decimate.py -- +"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。 +调用:blender -b --factory-startup --python bl_decimate.py -- [seam|smart] """ import os import sys @@ -90,25 +90,134 @@ def _decimate(obj, ratio): _apply_modifier(obj, mod) -def _smart_unwrap(obj): - """删旧 UV 层后 Smart UV Project 重展。island_margin 0.002 ≈ 2048 图 4px。""" - import bpy - import math - mesh = obj.data +SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam +PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px + + +def _clear_uv_layers(mesh): while mesh.uv_layers: mesh.uv_layers.remove(mesh.uv_layers[0]) + + +def _unwrap_smart(obj): + """Smart UV Project(原路径,也是质量门的回退路径)。""" + import bpy + import math + _clear_uv_layers(obj.data) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.002) + bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=PACK_MARGIN) bpy.ops.object.mode_set(mode='OBJECT') +def _unwrap_seam(obj, warnings): + """锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> pack。碎岛远少于投影式。""" + import bpy + import math + _clear_uv_layers(obj.data) + obj.data.uv_layers.new() + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + # 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛 + bpy.ops.mesh.select_mode(type='EDGE') + bpy.ops.mesh.select_all(action='DESELECT') + bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(SEAM_ANGLE_DEG)) + bpy.ops.mesh.mark_seam(clear=False) + bpy.ops.mesh.select_all(action='SELECT') + try: + bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN) + except TypeError: # 老版本无 SLIM 枚举 + warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED") + bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN) + try: + bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN) + except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包 + warnings.append("pack_islands 不可用,沿用 unwrap 自带布局") + bpy.ops.object.mode_set(mode='OBJECT') + + +def _collect_uv_metrics(obj): + """bmesh 提取 UV 指标:岛数(UV 连续边并查集)、利用率、翻转占比、重叠占比。""" + import bmesh + bm = bmesh.new() + bm.from_mesh(obj.data) + uv = bm.loops.layers.uv.active + pairs = [] + for e in bm.edges: + if len(e.link_faces) != 2: + continue + f1, f2 = e.link_faces + cont = True + for v in e.verts: # 两面在该边两端 UV 一致 => UV 连续(同岛) + l1 = next(l for l in f1.loops if l.vert == v) + l2 = next(l for l in f2.loops if l.vert == v) + if (l1[uv].uv - l2[uv].uv).length > 1e-6: + cont = False + break + if cont: + pairs.append((f1.index, f2.index)) + signed = [] + for f in bm.faces: + area = 0.0 + loops = f.loops + for i in range(len(loops)): + a = loops[i][uv].uv + b = loops[(i + 1) % len(loops)][uv].uv + area += a.x * b.y - b.x * a.y + signed.append(area * 0.5) + n = len(bm.faces) + bm.free() + return {"islands": count_islands(n, pairs), + "fill": round(fill_ratio([abs(a) for a in signed]), 4), + "flipped": round(flipped_fraction(signed), 4), + "overlap": _overlap_fraction(obj)} + + +def _overlap_fraction(obj): + """uv.select_overlap 统计重叠 UV 面占比;op 上下文不可用返回 None。""" + import bpy + bpy.context.scene.tool_settings.use_uv_select_sync = True + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='DESELECT') + try: + bpy.ops.uv.select_overlap() + except RuntimeError: + return None + finally: + bpy.ops.object.mode_set(mode='OBJECT') + polys = obj.data.polygons + if not len(polys): + return 0.0 + return round(sum(1 for p in polys if p.select) / float(len(polys)), 4) + + +def _do_unwrap(obj, mode, warnings): + """按模式展开;seam 质量不达标自动回退 smart。返回 uv 指标 dict(含 mode)。""" + if mode == "seam": + _unwrap_seam(obj, warnings) + m = _collect_uv_metrics(obj) + overlap = m["overlap"] or 0.0 + if m["flipped"] <= UV_BAD_TOL and overlap <= UV_BAD_TOL: + m["mode"] = "seam" + return m + warnings.append("seam 展开质量不达标(翻转 %.1f%% 重叠 %s),回退 smart_project" + % (m["flipped"] * 100, + "%.1f%%" % (m["overlap"] * 100) if m["overlap"] is not None else "未知")) + mode = "smart_fallback" + _unwrap_smart(obj) + m = _collect_uv_metrics(obj) + m["mode"] = mode if mode == "smart_fallback" else "smart" + return m + + def main(): import bpy import json argv = sys.argv[sys.argv.index("--") + 1:] src, out_fbx, target = argv[0], argv[1], int(argv[2]) + unwrap_mode = argv[3] if len(argv) > 3 else "seam" warnings = [] bpy.ops.wm.read_factory_settings(use_empty=True) @@ -140,7 +249,7 @@ def main(): if not within_tolerance(target, cur) and cur > target: warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) - _smart_unwrap(obj) + uv_info = _do_unwrap(obj, unwrap_mode, warnings) out_dir = os.path.dirname(os.path.abspath(out_fbx)) os.makedirs(out_dir, exist_ok=True) # 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx @@ -150,7 +259,7 @@ def main(): print("MT_SUMMARY " + json.dumps( {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), "tris_before": orig, "tris_after": cur, "target": target, - "warnings": warnings}, ensure_ascii=False)) + "uv": uv_info, "warnings": warnings}, ensure_ascii=False)) if __name__ == "__main__" and "--" in sys.argv: diff --git a/Tools/ModelTranslator/model_decimate.py b/Tools/ModelTranslator/model_decimate.py index 71d7ae3b..527043fd 100644 --- a/Tools/ModelTranslator/model_decimate.py +++ b/Tools/ModelTranslator/model_decimate.py @@ -1,4 +1,4 @@ -"""FBX 减面 CLI:减到指定三角面数并 Smart UV 重展,输出 <名>_low.fbx。 +"""FBX 减面 CLI:减到指定三角面数并重展 UV(默认锐边 seam 整岛展开),输出 <名>_low.fbx。 用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] [--blender exe]""" import argparse import os @@ -11,6 +11,8 @@ def main(): ap.add_argument("input", help="FBX 文件") ap.add_argument("--tris", type=int, required=True, help="目标三角面数") ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)") + ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam", + help="UV 展开方式:seam=锐边接缝整岛展开(默认),smart=Smart UV Project") ap.add_argument("--blender", default=None) args = ap.parse_args() if args.tris <= 0: @@ -21,9 +23,13 @@ def main(): outdir = args.out or os.path.dirname(os.path.abspath(args.input)) out_fbx = os.path.join(outdir, name + "_low.fbx") s = run_blender_script(blender, "bl_decimate.py", - [args.input, out_fbx, str(args.tris)]) + [args.input, out_fbx, str(args.tris), args.unwrap]) print("== %s: %d -> %d 面(目标 %d)-> %s" % (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) + u = s["uv"] + overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a" + print(" UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" % + (u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap)) for w in s["warnings"]: print(" [警告] %s" % w)