668 lines
29 KiB
Python
668 lines
29 KiB
Python
"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
|
||
调用:blender -b --factory-startup --python bl_decimate.py --
|
||
<src.fbx> <out.fbx> <target_tris> [unwrap:seam|smart] [overlap_tol] [reducer:collapse|quad] [qr_input_cap]
|
||
"""
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
import qr_bridge
|
||
|
||
QR_POLL_INTERVAL = 0.3 # progress.txt 轮询间隔(秒)
|
||
|
||
TOLERANCE = 0.03 # 面数相对误差容忍
|
||
MAX_RETRY = 2 # ratio 修正轮数上限
|
||
|
||
|
||
def _progress(msg):
|
||
"""打印阶段进度;mt_run 会把 MT_PROGRESS 行实时转发给上层 UI。"""
|
||
print("MT_PROGRESS " + msg, flush=True)
|
||
|
||
|
||
def decimate_ratio(target, cur):
|
||
"""Decimate collapse ratio;cur <= target 或非法时返回 1.0(不减面)。"""
|
||
if cur <= 0 or cur <= target:
|
||
return 1.0
|
||
return target / float(cur)
|
||
|
||
|
||
def within_tolerance(target, actual, tol=TOLERANCE):
|
||
"""减面结果是否落在目标 ±tol 内。"""
|
||
if target <= 0:
|
||
return False
|
||
return abs(actual - target) <= target * tol
|
||
|
||
|
||
UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_project
|
||
UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%)
|
||
UV_MAX_ISLAND_FRAC = 0.25 # 质量门:最大单岛 UV 面积占比超此值判不合格——挡"光滑面欠切并成巨型
|
||
# 畸变大岛"(翻转/重叠都能过、但拉伸爆表的布局);越低越逼向 smart/更低角度
|
||
|
||
|
||
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL,
|
||
max_island_frac=0.0, island_frac_tol=UV_MAX_ISLAND_FRAC):
|
||
"""UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol,最大单岛占比按 island_frac_tol。
|
||
overlap / max_island_frac 为 None(op 不可用或未测)按 0 处理。"""
|
||
return (flipped <= UV_FLIP_TOL
|
||
and (overlap or 0.0) <= overlap_tol
|
||
and (max_island_frac or 0.0) <= island_frac_tol)
|
||
|
||
|
||
def uvpm_mode_label(base, applied):
|
||
"""UV 模式标注:UVPackmaster 排布生效时加 +uvpm 后缀。"""
|
||
return base + "+uvpm" if applied else base
|
||
|
||
|
||
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,
|
||
c.get("max_island_frac", 0.0))]
|
||
if not passing:
|
||
return None
|
||
return min(passing, key=lambda c: c["islands"])
|
||
|
||
|
||
def island_labels(n_faces, adjacent_pairs):
|
||
"""union-find 给每个面标 UV 岛代表根:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。
|
||
返回长度 n_faces 的标签列表(同岛同值)。"""
|
||
parent = list(range(n_faces))
|
||
|
||
def find(i):
|
||
while parent[i] != i:
|
||
parent[i] = parent[parent[i]]
|
||
i = parent[i]
|
||
return i
|
||
|
||
for a, b in adjacent_pairs:
|
||
ra, rb = find(a), find(b)
|
||
if ra != rb:
|
||
parent[ra] = rb
|
||
return [find(i) for i in range(n_faces)]
|
||
|
||
|
||
def count_islands(n_faces, adjacent_pairs):
|
||
"""UV 岛数量(并查集)。"""
|
||
return len(set(island_labels(n_faces, adjacent_pairs)))
|
||
|
||
|
||
def max_island_area_fraction(labels, abs_areas):
|
||
"""最大 UV 岛面积占总 UV 面积的比例:衡量是否有单座岛霸占纹素预算——
|
||
光滑面欠切时会并成一座巨型畸变大岛,此比例随之飙高。无面积返回 0.0。"""
|
||
total = sum(abs_areas)
|
||
if total <= 0:
|
||
return 0.0
|
||
acc = {}
|
||
for lbl, a in zip(labels, abs_areas):
|
||
acc[lbl] = acc.get(lbl, 0.0) + a
|
||
return max(acc.values()) / total if acc else 0.0
|
||
|
||
|
||
def flipped_fraction(signed_areas):
|
||
"""UV 面签名面积为负(翻转)的占比。"""
|
||
if not signed_areas:
|
||
return 0.0
|
||
return sum(1 for a in signed_areas if a < 0) / float(len(signed_areas))
|
||
|
||
|
||
def fill_ratio(abs_areas):
|
||
"""UV 空间利用率:面积和(重叠会虚高,截断到 1.0)。"""
|
||
return min(1.0, sum(abs_areas))
|
||
|
||
|
||
# ---------------- 以下仅在 Blender 内执行 ----------------
|
||
|
||
|
||
def join_meshes(objs):
|
||
"""多 mesh join 成单对象,返回结果对象(bl_bake 复用)。"""
|
||
import bpy
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
for o in objs:
|
||
o.select_set(True)
|
||
bpy.context.view_layer.objects.active = objs[0]
|
||
if len(objs) > 1:
|
||
bpy.ops.object.join()
|
||
return bpy.context.view_layer.objects.active
|
||
|
||
|
||
def _apply_modifier(obj, mod):
|
||
import bpy
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.modifier_apply(modifier=mod.name)
|
||
|
||
|
||
def _triangulate(obj):
|
||
mod = obj.modifiers.new("mt_tri", 'TRIANGULATE')
|
||
_apply_modifier(obj, mod)
|
||
|
||
|
||
def _decimate(obj, ratio):
|
||
mod = obj.modifiers.new("mt_dec", 'DECIMATE')
|
||
mod.decimate_type = 'COLLAPSE'
|
||
mod.ratio = ratio
|
||
mod.use_collapse_triangulate = True
|
||
_apply_modifier(obj, mod)
|
||
|
||
|
||
def _edge_defects(obj):
|
||
"""(开边数, 非流形边数):开边=只挂 1 面(孔洞边界),非流形=挂 >2 面。
|
||
用于 QR 输出水密门——QR 引擎在激进减面下会非确定性地打出孔洞/非流形。"""
|
||
import bmesh
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
boundary = sum(1 for e in bm.edges if len(e.link_faces) == 1)
|
||
nonman = sum(1 for e in bm.edges if len(e.link_faces) > 2)
|
||
bm.free()
|
||
return boundary, nonman
|
||
|
||
|
||
def _fill_small_holes(obj, max_sides, warnings):
|
||
"""补掉 <=max_sides 边的小洞(QR 薄部位零星孔洞);异常降级不中断。"""
|
||
import bpy
|
||
try:
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
bpy.ops.mesh.fill_holes(sides=max_sides)
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
except RuntimeError as e:
|
||
warnings.append("fill_holes 失败(%s),跳过补洞" % e)
|
||
|
||
|
||
def _remesh_quad(obj, target_tris, warnings):
|
||
"""Quad Remesher 重拓扑 obj(headless 直调 xremesh.exe,绕开 modal 操作符)。
|
||
成功返回 True,并把导回的 quad 网格设为新 active object(原 obj 已删);
|
||
任何失败——含 QR 输出破损未过水密门——记 warning 返回 False,交上层回退 collapse。"""
|
||
import bpy
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
engine = qr_bridge.find_qr_engine()
|
||
if not engine:
|
||
warnings.append("未找到 QuadRemesher 引擎(xremesh.exe),回退 collapse")
|
||
return False
|
||
work = os.path.join(tempfile.gettempdir(), "mt_qr")
|
||
os.makedirs(work, exist_ok=True)
|
||
in_fbx = os.path.join(work, "inputMesh.fbx").replace("\\", "/")
|
||
out_fbx = os.path.join(work, "retopo.fbx").replace("\\", "/")
|
||
prog = os.path.join(work, "progress.txt").replace("\\", "/")
|
||
settings = os.path.join(work, "RetopoSettings.txt")
|
||
try:
|
||
for f in (out_fbx, prog):
|
||
if os.path.isfile(f):
|
||
os.remove(f)
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
src_boundary, src_nonman = _edge_defects(obj) # QR 输入拓扑基线
|
||
bpy.ops.export_scene.fbx(filepath=in_fbx, use_selection=True)
|
||
target_quads = qr_bridge.tris_to_target_quads(target_tris)
|
||
with open(settings, "w") as fp:
|
||
# exact=True:尊重目标四边形数。自适应(exact=False)在高细节硬表面模型上会
|
||
# 把面数炸开数倍(实测 well1500 目标 2500→11500 quads),并连带 seam 展开重叠爆炸。
|
||
fp.write(qr_bridge.build_settings(in_fbx, out_fbx, prog, target_quads,
|
||
exact=True))
|
||
proc = subprocess.Popen([engine, "-s", settings])
|
||
except Exception as e:
|
||
warnings.append("QR 启动失败(%s),回退 collapse" % e)
|
||
return False
|
||
|
||
t0 = time.time()
|
||
next_beat = t0 + 2.0 # 每 ~2s 打一次心跳,表明 QR 仍在跑
|
||
state = "running"
|
||
while True:
|
||
try:
|
||
with open(prog) as fp:
|
||
state, msg = qr_bridge.parse_progress(fp.read())
|
||
except IOError:
|
||
state, msg = "running", ""
|
||
if state in ("success", "error"):
|
||
break
|
||
now = time.time()
|
||
if now >= next_beat:
|
||
_progress("QuadRemesher 重拓扑中… %ds" % int(now - t0))
|
||
next_beat = now + 2.0
|
||
if proc.poll() is not None:
|
||
# 进程已退出:给 progress/输出一点落地时间后再判一次
|
||
time.sleep(QR_POLL_INTERVAL)
|
||
try:
|
||
with open(prog) as fp:
|
||
state, msg = qr_bridge.parse_progress(fp.read())
|
||
except IOError:
|
||
state, msg = "running", ""
|
||
break
|
||
if time.time() - t0 > qr_bridge.QR_TIMEOUT:
|
||
proc.kill()
|
||
proc.wait()
|
||
warnings.append("QR 超时(%ds),回退 collapse" % int(qr_bridge.QR_TIMEOUT))
|
||
return False
|
||
time.sleep(QR_POLL_INTERVAL)
|
||
|
||
if state == "error":
|
||
warnings.append("QR 重拓扑失败(%s),回退 collapse" % msg)
|
||
return False
|
||
if state != "success" or not (
|
||
os.path.isfile(out_fbx) and os.path.getsize(out_fbx) > 0):
|
||
warnings.append("QR 无有效输出,回退 collapse")
|
||
return False
|
||
|
||
try:
|
||
before = set(bpy.data.objects)
|
||
bpy.ops.import_scene.fbx(filepath=out_fbx)
|
||
new = [o for o in bpy.data.objects
|
||
if o not in before and o.type == 'MESH']
|
||
if not new:
|
||
warnings.append("QR 导回无 mesh,回退 collapse")
|
||
return False
|
||
retopo = new[0]
|
||
# 补小洞:QR 常在薄部位留零星小洞,先补掉再判软门
|
||
_fill_small_holes(retopo, qr_bridge.QR_HOLE_MAX_SIDES, warnings)
|
||
# 软水密门:补洞后仍比输入多出超容忍的破损才判灾难性,弃用 QR 回退 collapse
|
||
qr_boundary, qr_nonman = _edge_defects(retopo)
|
||
if not qr_bridge.topology_acceptable(src_boundary, src_nonman,
|
||
qr_boundary, qr_nonman,
|
||
tol=qr_bridge.QR_CATASTROPHIC_TOL):
|
||
warnings.append("QR 输出灾难性破损(开边 %d→%d,非流形 %d→%d),回退 collapse"
|
||
% (src_boundary, qr_boundary, src_nonman, qr_nonman))
|
||
bpy.data.objects.remove(retopo, do_unlink=True) # 弃坏网格,原 obj 完好
|
||
# FBX 导入把选中挪给了 retopo;恢复 obj 选中+active,供 collapse 与最终导出
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
return False
|
||
bpy.ops.object.select_all(action='DESELECT')
|
||
retopo.select_set(True)
|
||
bpy.context.view_layer.objects.active = retopo
|
||
bpy.data.objects.remove(obj, do_unlink=True)
|
||
return True
|
||
except Exception as e:
|
||
warnings.append("QR 导回失败(%s),回退 collapse" % e)
|
||
return False
|
||
|
||
|
||
def _clean_mesh(obj, warnings):
|
||
"""减面前清理:按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形,
|
||
减少被迫加的 seam。各步失败降级不中断。"""
|
||
import bpy
|
||
import mathutils
|
||
bb = [mathutils.Vector(c) for c in obj.bound_box]
|
||
diag = (bb[0] - bb[6]).length
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
if diag > 0.0:
|
||
try:
|
||
bpy.ops.mesh.remove_doubles(threshold=diag * REL_WELD)
|
||
except RuntimeError as e:
|
||
warnings.append("remove_doubles 失败(%s),跳过焊接" % e)
|
||
else:
|
||
warnings.append("包围盒对角线为 0,跳过焊接")
|
||
try:
|
||
bpy.ops.mesh.dissolve_degenerate()
|
||
except RuntimeError as e:
|
||
warnings.append("dissolve_degenerate 失败(%s),跳过去退化" % e)
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
|
||
|
||
SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam
|
||
PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
|
||
UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名
|
||
UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径
|
||
UVPM_TEX_SIZE = 2048
|
||
UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用)
|
||
SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0, 30.0, 25.0, 20.0, 15.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度;
|
||
# 下探到 15° 是为 QR 光滑四边网格——锐边少、高角度标不到 seam 会重叠爆炸回退 smart(拉糊),
|
||
# 实测 guang 到 30° 才过门。早停取首个过门的最高角度=岛最少,对已在高角度过门的模型无影响。
|
||
STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数
|
||
REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准)
|
||
|
||
|
||
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=PACK_MARGIN)
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
|
||
|
||
def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG):
|
||
"""锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。
|
||
seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。"""
|
||
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')
|
||
bpy.ops.mesh.select_mode(type='EDGE')
|
||
# 清掉上一档角度残留 seam(同一对象多角度扫描时必须;同时丢弃源资产自带 seam,保证确定性)
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
bpy.ops.mesh.mark_seam(clear=True)
|
||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||
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.minimize_stretch(iterations=STRETCH_ITERS)
|
||
except (RuntimeError, TypeError) as e:
|
||
warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e)
|
||
try: # 统一纹素密度,避免大岛霸占分辨率
|
||
bpy.ops.uv.average_islands_scale()
|
||
except (RuntimeError, TypeError) as e:
|
||
warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e)
|
||
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()
|
||
labels = island_labels(n, pairs)
|
||
abs_areas = [abs(a) for a in signed]
|
||
return {"islands": len(set(labels)),
|
||
"fill": round(fill_ratio(abs_areas), 4),
|
||
"flipped": round(flipped_fraction(signed), 4),
|
||
"overlap": _overlap_fraction(obj),
|
||
"max_island_frac": round(max_island_area_fraction(labels, abs_areas), 4)}
|
||
|
||
|
||
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)
|
||
|
||
|
||
_uvpm_failed = False # 进程内失败一次即不再重试(避免重复警告与启动开销)
|
||
|
||
|
||
def _uvpm_enable():
|
||
"""启用 UVPM4 扩展(幂等)。headless 下先补丁 GPU shader 创建——
|
||
UVPM4 导入期为视口覆盖层建 shader,后台模式无 GPU 绘图会 SystemError;
|
||
覆盖层仅交互用,pack 不受影响。引擎路径经注册表自动发现。"""
|
||
import bpy
|
||
import addon_utils
|
||
if bpy.app.background:
|
||
import gpu
|
||
orig = gpu.shader.from_builtin
|
||
if not getattr(orig, "_mt_safe", False):
|
||
def _safe(*a, **k):
|
||
try:
|
||
return orig(*a, **k)
|
||
except SystemError:
|
||
return None
|
||
_safe._mt_safe = True
|
||
gpu.shader.from_builtin = _safe
|
||
# default_set=True 才建 preferences.addons 条目(UVPM4 register 依赖);
|
||
# factory-startup 关闭偏好自动保存,不会写盘
|
||
if addon_utils.enable(UVPM_EXT, default_set=True) is None:
|
||
raise RuntimeError("扩展 %s 启用失败(未安装或版本不兼容)" % UVPM_EXT)
|
||
|
||
|
||
def _uvpm_set(p, name, value, warnings):
|
||
"""防御式设 UVPM 属性:属性存在才设,否则记警告跳过——
|
||
名字不匹配(UVPM 版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。"""
|
||
if hasattr(p, name):
|
||
try:
|
||
setattr(p, name, value)
|
||
except Exception as e:
|
||
warnings.append("UVPM 属性 %s 设置失败(%s),跳过" % (name, e))
|
||
else:
|
||
warnings.append("UVPM 属性 %s 不存在,跳过" % name)
|
||
|
||
|
||
def _uvpm_repack(obj, warnings):
|
||
"""UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False,
|
||
保底布局(pack_islands/smart_project)原样保留。rotation_step 调细以利薄斜条对齐。
|
||
注:UVPM 启发式搜索(heuristic)在 headless 下会随机崩溃引擎进程(Engine process died),
|
||
确定性/稳定性差,故不启用——只用旋转增强这一稳定杠杆。"""
|
||
global _uvpm_failed
|
||
if _uvpm_failed:
|
||
return False
|
||
import bpy
|
||
try:
|
||
_uvpm_enable()
|
||
p = bpy.context.scene.uvpm4_props.default_main_props
|
||
p.pixel_margin_enable = True
|
||
p.pixel_margin = UVPM_PIXEL_MARGIN
|
||
p.pixel_margin_tex_size = UVPM_TEX_SIZE
|
||
_uvpm_set(p, "rotation_enable", True, warnings)
|
||
_uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings)
|
||
bpy.context.scene.tool_settings.use_uv_select_sync = True
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.mode_set(mode='EDIT')
|
||
bpy.ops.mesh.select_all(action='SELECT')
|
||
try:
|
||
ret = bpy.ops.uvpackmaster4.pack(mode_id='pack.single_tile',
|
||
pack_op_type='0')
|
||
finally:
|
||
bpy.ops.object.mode_set(mode='OBJECT')
|
||
if 'FINISHED' not in ret:
|
||
raise RuntimeError("pack 返回 %s" % sorted(ret))
|
||
return True
|
||
except Exception as e:
|
||
_uvpm_failed = True
|
||
warnings.append("UVPackmaster 排布不可用(%s),沿用内置 pack 布局" % e)
|
||
return False
|
||
|
||
|
||
def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL):
|
||
"""按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫
|
||
——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在
|
||
候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发);
|
||
全失败退 smart。质量门含最大单岛占比(UV_MAX_ISLAND_FRAC):有机光滑面在高角度会并成巨型
|
||
畸变大岛——翻转/重叠虽过但拉伸爆表,此门挡住后早停被迫继续下探更低角度(切碎大岛),仍不达标
|
||
才退 smart(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,
|
||
m["max_island_frac"]):
|
||
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 最大岛%.0f%%)" % (
|
||
int(c["angle"]), c["flipped"] * 100,
|
||
"%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知",
|
||
c.get("max_island_frac", 0.0) * 100)
|
||
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
|
||
|
||
|
||
def _collect_uv_polygons(obj):
|
||
"""提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。
|
||
无 UV 层返回空列表。"""
|
||
import bmesh
|
||
bm = bmesh.new()
|
||
bm.from_mesh(obj.data)
|
||
uv = bm.loops.layers.uv.active
|
||
polys = []
|
||
if uv is not None:
|
||
for f in bm.faces:
|
||
polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops])
|
||
bm.free()
|
||
return polys
|
||
|
||
|
||
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"
|
||
overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL
|
||
# 直接调 bl_decimate 缺省用 collapse(安全确定性);model_decimate CLI 默认 quad,
|
||
# 但总显式传第 6 位,故此兜底仅用于直接 Blender 调用,两者默认不同是有意为之
|
||
reducer_arg = argv[5] if len(argv) > 5 else "collapse"
|
||
qr_input_cap = int(argv[6]) if len(argv) > 6 else qr_bridge.QR_INPUT_CAP
|
||
warnings = []
|
||
|
||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
_progress("导入 FBX…")
|
||
bpy.ops.import_scene.fbx(filepath=src)
|
||
|
||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||
skipped = [o.name for o in bpy.data.objects if o.type != 'MESH']
|
||
if skipped:
|
||
warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped))
|
||
if not meshes:
|
||
print("MT_SUMMARY " + json.dumps({"error": "FBX 中没有 mesh"}, ensure_ascii=False))
|
||
return
|
||
obj = join_meshes(meshes)
|
||
|
||
_progress("清理网格…")
|
||
_clean_mesh(obj, warnings)
|
||
_triangulate(obj)
|
||
orig = len(obj.data.polygons) # 高模三角面数(tris_before)
|
||
cur = orig
|
||
used_reducer = "collapse"
|
||
did_quad = False
|
||
if reducer_arg == "quad":
|
||
# 预 collapse 到中等密度:给 QR 一个不炸洞的输入(高密度直接 QR 必破)
|
||
pre = qr_bridge.precollapse_target(len(obj.data.polygons), qr_input_cap)
|
||
if pre is not None:
|
||
_progress("预塌陷到中等密度 (%d 面)…" % pre)
|
||
_decimate(obj, decimate_ratio(pre, len(obj.data.polygons)))
|
||
_triangulate(obj)
|
||
cur = len(obj.data.polygons) # 预 collapse 后更新,QR 失败时 collapse 从此续减
|
||
_progress("QuadRemesher 四边重拓扑…")
|
||
if _remesh_quad(obj, target, warnings):
|
||
obj = bpy.context.view_layer.objects.active # QR 导回的 quad 对象
|
||
# 保四边:不三角化,直接导出四边网格(FBX/烘焙/Unity 均支持四边)
|
||
cur = len(obj.data.polygons)
|
||
used_reducer = "quad"
|
||
did_quad = True
|
||
else:
|
||
used_reducer = "quad->collapse"
|
||
if not did_quad:
|
||
if cur <= target:
|
||
warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target))
|
||
else:
|
||
_progress("塌陷减面到 %d 面…" % target)
|
||
# collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮
|
||
for _ in range(1 + MAX_RETRY):
|
||
_decimate(obj, decimate_ratio(target, cur))
|
||
_triangulate(obj) # collapse triangulate 后仍可能残留非三角面
|
||
cur = len(obj.data.polygons)
|
||
if within_tolerance(target, cur) or cur <= target:
|
||
break
|
||
# 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受
|
||
if not within_tolerance(target, cur) and cur > target:
|
||
warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target))
|
||
|
||
_progress("展开并排布 UV…")
|
||
uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol)
|
||
uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png"
|
||
uv_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json"
|
||
uv_preview = None
|
||
try:
|
||
polys = _collect_uv_polygons(obj)
|
||
os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True)
|
||
with open(uv_polys_json, "w", encoding="utf-8") as fp:
|
||
json.dump(polys, fp)
|
||
uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成
|
||
except Exception as e:
|
||
warnings.append("UV 几何导出失败(%s),跳过观察图" % e)
|
||
out_dir = os.path.dirname(os.path.abspath(out_fbx))
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
_progress("导出低模 FBX…")
|
||
# 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx
|
||
bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True,
|
||
path_mode='STRIP', embed_textures=False)
|
||
|
||
print("MT_SUMMARY " + json.dumps(
|
||
{"src": os.path.basename(src), "fbx": os.path.basename(out_fbx),
|
||
"tris_before": orig, "tris_after": cur, "target": target,
|
||
"reducer": used_reducer,
|
||
"uv": uv_info,
|
||
"uv_preview": uv_preview,
|
||
"uv_polys": uv_polys_json if uv_preview else None,
|
||
"warnings": warnings},
|
||
ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__" and "--" in sys.argv:
|
||
main()
|