ModelTranslator: 减面 UV 改锐边 seam 展开(质量门回退 smart,--unwrap 可选)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
71416aa12a
commit
4e27b9ae9b
@@ -1,5 +1,5 @@
|
|||||||
"""Blender 内运行:FBX 减面到指定三角面数 + Smart UV 重展。
|
"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
|
||||||
调用:blender -b --factory-startup --python bl_decimate.py -- <src.fbx> <out.fbx> <target_tris>
|
调用:blender -b --factory-startup --python bl_decimate.py -- <src.fbx> <out.fbx> <target_tris> [seam|smart]
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -90,25 +90,134 @@ def _decimate(obj, ratio):
|
|||||||
_apply_modifier(obj, mod)
|
_apply_modifier(obj, mod)
|
||||||
|
|
||||||
|
|
||||||
def _smart_unwrap(obj):
|
SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam
|
||||||
"""删旧 UV 层后 Smart UV Project 重展。island_margin 0.002 ≈ 2048 图 4px。"""
|
PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
|
||||||
import bpy
|
|
||||||
import math
|
|
||||||
mesh = obj.data
|
def _clear_uv_layers(mesh):
|
||||||
while mesh.uv_layers:
|
while mesh.uv_layers:
|
||||||
mesh.uv_layers.remove(mesh.uv_layers[0])
|
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.context.view_layer.objects.active = obj
|
||||||
bpy.ops.object.mode_set(mode='EDIT')
|
bpy.ops.object.mode_set(mode='EDIT')
|
||||||
bpy.ops.mesh.select_all(action='SELECT')
|
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')
|
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():
|
def main():
|
||||||
import bpy
|
import bpy
|
||||||
import json
|
import json
|
||||||
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"
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
@@ -140,7 +249,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))
|
||||||
|
|
||||||
_smart_unwrap(obj)
|
uv_info = _do_unwrap(obj, unwrap_mode, warnings)
|
||||||
out_dir = os.path.dirname(os.path.abspath(out_fbx))
|
out_dir = os.path.dirname(os.path.abspath(out_fbx))
|
||||||
os.makedirs(out_dir, exist_ok=True)
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
# 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx
|
# 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx
|
||||||
@@ -150,7 +259,7 @@ def main():
|
|||||||
print("MT_SUMMARY " + json.dumps(
|
print("MT_SUMMARY " + json.dumps(
|
||||||
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
||||||
"tris_before": orig, "tris_after": cur, "target": target,
|
"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:
|
if __name__ == "__main__" and "--" in sys.argv:
|
||||||
|
|||||||
@@ -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]"""
|
用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] [--blender exe]"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +11,8 @@ def main():
|
|||||||
ap.add_argument("input", help="FBX 文件")
|
ap.add_argument("input", help="FBX 文件")
|
||||||
ap.add_argument("--tris", type=int, required=True, help="目标三角面数")
|
ap.add_argument("--tris", type=int, required=True, help="目标三角面数")
|
||||||
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",
|
||||||
|
help="UV 展开方式:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")
|
||||||
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:
|
||||||
@@ -21,9 +23,13 @@ def main():
|
|||||||
outdir = args.out or os.path.dirname(os.path.abspath(args.input))
|
outdir = args.out or os.path.dirname(os.path.abspath(args.input))
|
||||||
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.input, out_fbx, str(args.tris), args.unwrap])
|
||||||
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))
|
||||||
|
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"]:
|
for w in s["warnings"]:
|
||||||
print(" [警告] %s" % w)
|
print(" [警告] %s" % w)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user