Files

699 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Blender 内运行:FBX/GLB/glTF 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
调用:blender -b --factory-startup --python bl_decimate.py -- <src> <out.fbx> <target_tris> [seam|smart]
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SUPPORTED_MODEL_EXTENSIONS = (".fbx", ".glb", ".gltf")
FBX_YUP_EXPORT_VERSION = 2
def model_format(path):
"""Return the Blender importer key for a supported model path."""
ext = os.path.splitext(path)[1].lower()
if ext == ".fbx":
return "fbx"
if ext in (".glb", ".gltf"):
return "gltf"
raise ValueError(
"unsupported model format %r (supported: %s)"
% (ext or "<none>", ", ".join(SUPPORTED_MODEL_EXTENSIONS)))
def import_model_objects(path):
"""Import FBX/GLB/glTF and return only objects created by the importer."""
import bpy
before = set(bpy.data.objects)
fmt = model_format(path)
if fmt == "fbx":
bpy.ops.import_scene.fbx(filepath=path)
else:
bpy.ops.import_scene.gltf(filepath=path)
return [obj for obj in bpy.data.objects if obj not in before]
def restore_source_yup(obj):
"""Blender Z-up 顶点还原为混元/glTF Y-up(-90°X)。须在 transform_apply 之后调用。"""
import math
import bpy
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
obj.rotation_mode = "XYZ"
obj.rotation_euler = (-math.pi / 2.0, 0.0, 0.0)
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
def snap_origin_bottom_center(obj):
"""原点放到底面中点:X/Z 居中,Y = 最低点。须已是 Y-up 且 transform 已 apply。"""
from mathutils import Vector
verts = obj.data.vertices
if not verts:
return
xs = [v.co.x for v in verts]
ys = [v.co.y for v in verts]
zs = [v.co.z for v in verts]
origin = Vector((
(min(xs) + max(xs)) * 0.5,
min(ys),
(min(zs) + max(zs)) * 0.5,
))
for v in verts:
v.co -= origin
obj.data.update()
obj.location = (0.0, 0.0, 0.0)
def prepare_game_export(obj, restore_axis=None):
"""最终游戏 FBX:Y-up,原点在底面中点。
restore_axis: True 强制 -90°X;False 跳过;None 则导入后物体仍带约 +90°X 才还原。
烘焙结束时顶点已是 Z-up、旋转已 apply,必须传 True。
导出后保证 Y-up、原点底面中点;项目目标为解剖正面 +Z,须独立验收。
解剖正面沿用输入。axis_forward='-Z' 是坐标转换设置,不代表人物脸部朝 -Z。
"""
import math
import bpy
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
if restore_axis is None:
obj.rotation_mode = "XYZ"
restore_axis = abs(obj.rotation_euler.x) > math.radians(45.0)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
if restore_axis:
restore_source_yup(obj)
snap_origin_bottom_center(obj)
def export_fbx_source_yup(filepath):
"""Export prepared Y-up geometry as an actual Y-up FBX scene.
prepare_game_export stores Y-up vertices inside Blender's Z-up workspace.
Passing those straight to the FBX exporter adds a -90 X object rotation,
so consumers applying the complete FBX transform see a horizontal model.
Restore Blender Z-up on temporary copies, then bake the export conversion.
Leave source meshes, UVs, transforms and selection unchanged for callers.
"""
import math
import bpy
from mathutils import Matrix
selected = list(bpy.context.selected_objects)
active = bpy.context.view_layer.objects.active
meshes = [obj for obj in selected if obj.type == "MESH"]
if not meshes:
raise ValueError("Select at least one prepared Y-up mesh for FBX export")
copies = []
source_names = [(source, source.name) for source in meshes]
try:
bpy.ops.object.select_all(action="DESELECT")
for source, source_name in source_names:
duplicate = source.copy()
duplicate.data = source.data.copy()
copies.append(duplicate)
bpy.context.scene.collection.objects.link(duplicate)
source.name = source_name + "__fbx_export_source"
duplicate.name = source_name
duplicate.parent = None
duplicate.matrix_world = Matrix.Rotation(math.pi / 2, 4, "X") @ source.matrix_world
duplicate.select_set(True)
bpy.context.view_layer.objects.active = copies[0]
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={"MESH"},
axis_forward="-Z",
axis_up="Y",
bake_space_transform=True,
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_ALL",
bake_anim=False,
path_mode="STRIP",
embed_textures=False,
)
return validate_fbx_yup_scene(filepath)
finally:
for duplicate in copies:
mesh = duplicate.data
bpy.data.objects.remove(duplicate, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
for obj in selected:
obj.select_set(True)
for source, source_name in source_names:
source.name = source_name
bpy.context.view_layer.objects.active = active
def validate_fbx_yup_scene(filepath):
"""Reject the old extra node rotation even when raw vertices look Y-up."""
from io_scene_fbx import parse_fbx
root, _ = parse_fbx.parse(str(filepath))
def properties(element):
block = next(e for e in element.elems if e.id == b"Properties70")
return {p.props[0].decode(): list(p.props[4:]) for p in block.elems}
settings = properties(next(e for e in root.elems if e.id == b"GlobalSettings"))
if settings.get("UpAxis") != [1] or settings.get("UpAxisSign") != [1]:
raise ValueError("Exported FBX scene is not +Y-up")
objects = next(e for e in root.elems if e.id == b"Objects")
for model in (e for e in objects.elems if e.id == b"Model"):
values = properties(model)
if any(abs(v) > 1e-5 for v in values.get("Lcl Rotation", [0, 0, 0])):
raise ValueError("Exported FBX retains an unbaked node rotation")
if any(abs(v - 1) > 1e-5 for v in values.get("Lcl Scaling", [1, 1, 1])):
raise ValueError("Exported FBX retains an unbaked node scale")
return {"exportVersion": FBX_YUP_EXPORT_VERSION, "sceneUp": "+Y",
"nodeRotationBaked": True, "nodeScaleBaked": True,
"unitScaleFactor": settings.get("UnitScaleFactor", [1])[0]}
TOLERANCE = 0.03 # 面数相对误差容忍
MAX_RETRY = 2 # ratio 修正轮数上限
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 面积占比超此值判不合格(挡光滑面巨型畸变岛)
def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL,
max_island_frac=0.0, island_frac_tol=UV_MAX_ISLAND_FRAC):
"""UV 质量门:翻转 / 重叠 / 最大单岛占比。overlap/max_island_frac 为 None 按 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。"""
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:返回每个面所属岛根标签列表。"""
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):
"""union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。"""
return len(set(island_labels(n_faces, adjacent_pairs)))
def max_island_area_fraction(labels, abs_areas):
"""最大单岛 UV 面积占比。"""
if not labels or not abs_areas:
return 0.0
totals = {}
for lab, area in zip(labels, abs_areas):
totals[lab] = totals.get(lab, 0.0) + abs(area)
total = sum(totals.values())
if total <= 0.0:
return 0.0
return max(totals.values()) / total
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 _voxel_remesh(obj, voxel_size):
import bpy
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
mod = obj.modifiers.new("mt_voxel", "REMESH")
mod.mode = "VOXEL"
mod.voxel_size = max(float(voxel_size), 1e-4)
_apply_modifier(obj, mod)
def _voxel_fallback(obj, target, warnings):
"""碎叶/非流形上 collapse 会卡在高面数;复制试体素尺寸后重网格,再 collapse。"""
import bpy
import mathutils
bb = [mathutils.Vector(c) for c in obj.bound_box]
diag = (bb[0] - bb[6]).length
if diag <= 0.0:
return len(obj.data.polygons)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
best_size = diag / 16.0
best_diff = None
for div in (12, 16, 20, 24, 32):
size = diag / float(div)
bpy.ops.object.duplicate()
dup = bpy.context.view_layer.objects.active
_voxel_remesh(dup, size)
cur = len(dup.data.polygons)
diff = abs(cur - target)
if best_diff is None or diff < best_diff:
best_diff = diff
best_size = size
bpy.data.objects.remove(dup, do_unlink=True)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
_voxel_remesh(obj, best_size)
cur = len(obj.data.polygons)
warnings.append(
"collapse 卡住后改用体素重网格 voxel=%.5f → %d 面" % (best_size, cur)
)
if cur > target:
_decimate(obj, decimate_ratio(target, cur))
_triangulate(obj)
cur = len(obj.data.polygons)
return cur
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) # 首选不过门时下探
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)
labels = island_labels(n, pairs)
abs_areas = [abs(a) for a in signed]
bm.free()
return {"islands": len(set(labels)) if n else 0,
"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。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.get("max_island_frac", 0.0)):
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 大岛%.1f%%)" % (
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
warnings = []
bpy.ops.wm.read_factory_settings(use_empty=True)
imported = import_model_objects(src)
meshes = [o for o in imported if o.type == 'MESH']
skipped = [o.name for o in imported if o.type != 'MESH']
if skipped:
warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped))
if not meshes:
print("MT_SUMMARY " + json.dumps({"error": "输入模型中没有 mesh"}, ensure_ascii=False))
return
obj = join_meshes(meshes)
_clean_mesh(obj, warnings)
_triangulate(obj)
orig = len(obj.data.polygons)
cur = orig
if cur <= target:
warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target))
else:
# 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))
if cur > max(target * 2, target + 500):
cur = _voxel_fallback(obj, target, 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_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)
# 仅导出合并后的对象;整场景导出会把 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,
"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()