ModelTranslator: 移除 RizomUV 链路——减面默认 seam+UVPM4 排布
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
"""Blender 内运行:校验低模 FBX 的 UV 质量门;无 UV 或不达标就地 seam 重展并覆盖导出。
|
||||
调用:blender -b --factory-startup --python bl_uvgate.py -- <low.fbx> [mode_label]
|
||||
mode_label 仅用于日志/回退警告中标注上游展开方式(默认 rizom)。"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def seam_fallback_label(mode):
|
||||
"""回退产物标注:seam 系模式改 seam_fallback(保留 +uvpm 等后缀)。"""
|
||||
if mode.startswith("seam"):
|
||||
return "seam_fallback" + mode[len("seam"):]
|
||||
return mode
|
||||
|
||||
|
||||
def main():
|
||||
import bpy
|
||||
import json
|
||||
from bl_decimate import (join_meshes, uv_gate_ok, _collect_uv_metrics,
|
||||
_do_unwrap)
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
fbx = argv[0]
|
||||
mode_label = argv[1] if len(argv) > 1 else "rizom"
|
||||
warnings = []
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=fbx)
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
if not meshes:
|
||||
print("MT_SUMMARY " + json.dumps({"error": "FBX 中没有 mesh"},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
obj = join_meshes(meshes)
|
||||
|
||||
if obj.data.uv_layers:
|
||||
m = _collect_uv_metrics(obj)
|
||||
if uv_gate_ok(m["flipped"], m["overlap"]):
|
||||
m["mode"] = mode_label
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"uv": m, "reexported": False, "warnings": warnings},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
warnings.append("%s UV 质量不达标(翻转 %.1f%% 重叠 %s),回退 seam"
|
||||
% (mode_label, m["flipped"] * 100,
|
||||
"%.1f%%" % (m["overlap"] * 100)
|
||||
if m["overlap"] is not None else "未知"))
|
||||
else:
|
||||
warnings.append("%s 输出无 UV,回退 seam" % mode_label)
|
||||
|
||||
m = _do_unwrap(obj, "seam", warnings) # 内部自带 smart 回退
|
||||
m["mode"] = seam_fallback_label(m["mode"])
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.ops.export_scene.fbx(filepath=os.path.abspath(fbx), use_selection=True,
|
||||
path_mode='STRIP', embed_textures=False)
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"uv": m, "reexported": True, "warnings": warnings},
|
||||
ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--" in sys.argv:
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FBX 减面 CLI:减到指定三角面数并重展 UV(默认 RizomUV 自动展开),输出 <名>_low.fbx。
|
||||
"""FBX 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
|
||||
用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir]
|
||||
[--unwrap rizom|seam|smart] [--rizom exe] [--blender exe]"""
|
||||
[--unwrap seam|smart] [--blender exe]"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
@@ -12,10 +12,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=("rizom", "seam", "smart"), default="rizom",
|
||||
help="UV 展开:rizom=RizomUV 自动切缝(默认,不可用回退 seam),"
|
||||
"seam=锐边接缝整岛展开,smart=Smart UV Project")
|
||||
ap.add_argument("--rizom", default=None, help="rizomuv.exe 路径(默认自动发现)")
|
||||
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:
|
||||
@@ -26,25 +24,8 @@ def main():
|
||||
outdir = args.out or os.path.dirname(os.path.abspath(args.input))
|
||||
out_fbx = os.path.join(outdir, name + "_low.fbx")
|
||||
|
||||
if args.unwrap == "rizom":
|
||||
# 三步:Blender 减面(不展UV)-> RizomUV 展开 -> Blender 质量门(不达标就地 seam 重展)
|
||||
s = run_blender_script(blender, "bl_decimate.py",
|
||||
[args.input, out_fbx, str(args.tris), "none"])
|
||||
warnings = list(s["warnings"])
|
||||
# 惰性导入:seam/smart 路径不依赖 rizom 模块
|
||||
from rizom_unwrap import RizomError, find_rizomuv, rizom_unwrap
|
||||
try:
|
||||
rizom_unwrap(out_fbx, find_rizomuv(args.rizom))
|
||||
except RizomError as e:
|
||||
warnings.append("RizomUV 不可用:%s" % e)
|
||||
except Exception as e: # 意外异常也不中断管线:记警告,交 bl_uvgate 回退 seam
|
||||
warnings.append("RizomUV 异常:%r" % e)
|
||||
g = run_blender_script(blender, "bl_uvgate.py", [out_fbx, "rizom"])
|
||||
s["uv"] = g["uv"]
|
||||
s["warnings"] = warnings + g["warnings"]
|
||||
else:
|
||||
s = run_blender_script(blender, "bl_decimate.py",
|
||||
[args.input, out_fbx, str(args.tris), args.unwrap])
|
||||
s = run_blender_script(blender, "bl_decimate.py",
|
||||
[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))
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""RizomUV 自动展开:官方 RizomUVLink 驱动 rizomuv.exe 做 AutoSeam/Unfold/Pack。
|
||||
仅在系统 Python 中使用(不进 Blender);任何不可用/失败抛 RizomError,
|
||||
由 model_decimate.py 回退 Blender seam 展开。"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
DEFAULT_RIZOM = r"C:\Program Files\RizomUV2025\rizomuv.exe"
|
||||
MAP_RES = 2048 # Pack 目标贴图分辨率
|
||||
PADDING_PX = 4 # 岛间距(像素)= bl_decimate.PACK_MARGIN(0.002) * 2048
|
||||
TIMEOUT_S = 300 # 单条命令超时
|
||||
|
||||
|
||||
class RizomError(Exception):
|
||||
"""RizomUV 不可用或处理失败(上层据此回退)。"""
|
||||
|
||||
|
||||
def candidate_paths(cli_arg=None, env_val=None, reg_val=None, default=DEFAULT_RIZOM):
|
||||
"""rizomuv.exe 自动发现候选:环境变量 > 注册表 > 默认安装位置(显式 CLI 路径走独占分支不进此链)。"""
|
||||
return [p for p in (cli_arg, env_val, reg_val, default) if p]
|
||||
|
||||
|
||||
def registry_rizom_path():
|
||||
"""官方 README 的注册表查找(HKLM\\SOFTWARE\\Rizom Lab\\RizomUV VS RS 202X.Y),
|
||||
新版本优先;找不到或非 Windows 返回 None。"""
|
||||
try:
|
||||
import winreg
|
||||
except ImportError:
|
||||
return None
|
||||
for i in range(9, 1, -1):
|
||||
for j in range(10, -1, -1):
|
||||
if i == 2 and j < 2:
|
||||
continue
|
||||
key_path = "SOFTWARE\\Rizom Lab\\RizomUV VS RS 202%d.%d" % (i, j)
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key:
|
||||
val = winreg.QueryValue(key, "rizomuv.exe")
|
||||
# 注册表值是不带后缀的安装前缀(官方只取 dirname 用),拼回真实 exe
|
||||
return os.path.join(os.path.dirname(val), "rizomuv.exe")
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def find_rizomuv(cli_arg=None, use_registry=True, default=DEFAULT_RIZOM):
|
||||
"""返回第一个真实存在的 rizomuv.exe;都不存在抛 RizomError。
|
||||
显式指定 cli_arg 时独占生效:路径无效直接报错,不回落其它候选。"""
|
||||
if cli_arg:
|
||||
if os.path.isfile(cli_arg):
|
||||
return cli_arg
|
||||
raise RizomError("指定的 rizomuv.exe 不存在:%s" % cli_arg)
|
||||
reg = registry_rizom_path() if use_registry else None
|
||||
cands = candidate_paths(None, os.environ.get("RIZOMUV_EXE"), reg, default)
|
||||
for p in cands:
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
raise RizomError("找不到 rizomuv.exe(尝试过:%s)" % "; ".join(cands))
|
||||
|
||||
|
||||
def _import_link(exe):
|
||||
"""从 RizomUV 安装目录动态引入官方 RizomUVLink 模块(随安装自带,MIT)。
|
||||
同进程只支持单一安装路径(import 缓存,首次 import 后不会再切换目录)。"""
|
||||
link_dir = os.path.join(os.path.dirname(exe), "RizomUVLink")
|
||||
if not os.path.isdir(link_dir):
|
||||
raise RizomError("RizomUVLink 目录不存在:%s" % link_dir)
|
||||
if link_dir not in sys.path:
|
||||
sys.path.insert(0, link_dir)
|
||||
try:
|
||||
import RizomUVLink
|
||||
return RizomUVLink
|
||||
except Exception as e: # pyd 与 Python 版本不匹配等
|
||||
raise RizomError("RizomUVLink 导入失败:%s" % e)
|
||||
|
||||
|
||||
def _call(label, fn, params, timeout=TIMEOUT_S):
|
||||
"""带超时执行一条 RizomUV 命令;超时或 CZEx 抛 RizomError。"""
|
||||
out, err = [], []
|
||||
|
||||
def run():
|
||||
try:
|
||||
out.append(fn(params))
|
||||
except Exception as e:
|
||||
err.append(e)
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout)
|
||||
if t.is_alive():
|
||||
raise RizomError("%s 超时(%ds)" % (label, timeout))
|
||||
if err:
|
||||
raise RizomError("%s 失败:%s" % (label, err[0]))
|
||||
res = out[0] if out else None
|
||||
# 部分命令(如 Load 文件不存在)不抛 CZEx,而是返回 {'Error': {'Msg':..,'Code':..}}
|
||||
if isinstance(res, dict) and res.get("Error"):
|
||||
raise RizomError("%s 失败:%s" % (label, res["Error"]))
|
||||
return res
|
||||
|
||||
|
||||
def _check_pack_result(res):
|
||||
"""Pack 返回 Coverages 空列表 = 没有任何平面岛可打包——Unfold 未生效。
|
||||
实测这是 RizomUV 无有效授权时的静默失败形态(命令全部"成功"但解算不执行)。"""
|
||||
if isinstance(res, dict) and "Coverages" in res and not res["Coverages"]:
|
||||
raise RizomError("Pack 无任何平面岛(Unfold 未生效)——请检查 RizomUV 授权是否已激活")
|
||||
|
||||
|
||||
RETRY = 1 # 实例级重试次数:RizomUV IPC 有 2000ms 内部响应窗,偶发 not responding
|
||||
|
||||
|
||||
def rizom_unwrap(fbx_path, exe, map_res=MAP_RES, padding_px=PADDING_PX):
|
||||
"""AutoSeam 全自动切缝 -> Unfold -> Pack -> 覆盖保存 fbx_path。失败抛 RizomError。
|
||||
间歇性 IPC 失败自动换新实例重试一次。"""
|
||||
mod = _import_link(exe)
|
||||
fbx_abs = os.path.abspath(fbx_path)
|
||||
pad_uv = float(padding_px) / map_res
|
||||
last = None
|
||||
for attempt in range(1 + RETRY):
|
||||
if attempt:
|
||||
print("[警告] RizomUV 第 %d 次失败,换新实例重试" % attempt)
|
||||
try:
|
||||
return _unwrap_once(mod, exe, fbx_abs, map_res, pad_uv)
|
||||
except RizomError as e:
|
||||
last = e
|
||||
raise RizomError("重试后仍失败:%s" % last)
|
||||
|
||||
|
||||
def _unwrap_once(mod, exe, fbx_abs, map_res, pad_uv):
|
||||
try:
|
||||
link = mod.CRizomUVLink()
|
||||
except Exception as e: # 构造失败也归一为 RizomError,维持"失败只抛 RizomError"契约
|
||||
raise RizomError("RizomUVLink 实例创建失败:%s" % e)
|
||||
try:
|
||||
_call("RunRizomUV", lambda _p: link.RunRizomUV(exePath=exe), None, timeout=60)
|
||||
except RizomError as e:
|
||||
raise RizomError("RizomUV 启动失败(license/环境):%s" % e)
|
||||
try:
|
||||
_call("Load", link.Load, {"File.Path": fbx_abs, "File.XYZ": True})
|
||||
# AutoSeam:MOSAIC(QuasiDevelopable) + 切柄/连洞 + 防重叠后处理
|
||||
_call("Select(AutoSeam)", link.Select, {
|
||||
"PrimType": "Edge", "WorkingSet": "Visible",
|
||||
"Select": True, "ResetBefore": True,
|
||||
"Auto": {
|
||||
"QuasiDevelopable": {"Developability": 0.5, "IslandPolyNBMin": 1,
|
||||
"AreaMinRatio": 0.0, "FitCones": False,
|
||||
"Straighten": True},
|
||||
"HandleCutter": True,
|
||||
"PipesCutter": True,
|
||||
"SkeletonUnoverlap": {},
|
||||
"FlatteningMode": 0,
|
||||
"FlatteningUnfoldParams": {"BorderIntersections": True,
|
||||
"TriangleFlips": True},
|
||||
"StoreCoordsUVW": True,
|
||||
"Quality": 0.5,
|
||||
}})
|
||||
_call("Cut", link.Cut, {"PrimType": "Edge", "WorkingSet": "Visible"})
|
||||
r = _call("Unfold", link.Unfold, {"PrimType": "Edge", "WorkingSet": "Visible",
|
||||
"BorderIntersections": True,
|
||||
"TriangleFlips": True})
|
||||
if isinstance(r, dict) and r.get("BijectionFailedIslandIDs"):
|
||||
print("[警告] Unfold 有 %d 个岛双射失败" % len(r["BijectionFailedIslandIDs"]))
|
||||
# Scaling.Mode=3 (AVG_RATIO_OF_GROUP):官方文档建议首次展开后用该模式统一密度。
|
||||
# 注:pyd docstring 写的 Global 包装层已被 2025 应用侧拒绝(exe 内 Pack schema
|
||||
# 无 Global,打包属性直接为顶层参数),故扁平传参。
|
||||
r = _call("Pack", link.Pack, {
|
||||
"RootGroup": "RootGroup", "WorkingSet": "Visible",
|
||||
"ProcessTileSelection": False, "RecursionDepth": 1,
|
||||
"Translate": True,
|
||||
"MapResolution": map_res,
|
||||
"PaddingSize": pad_uv, "MarginSize": pad_uv,
|
||||
"Rotate": {"Step": 90.0},
|
||||
"Scaling": {"Mode": 3}})
|
||||
_check_pack_result(r)
|
||||
_call("Save", link.Save, {"File.Path": fbx_abs})
|
||||
finally:
|
||||
try:
|
||||
_call("Quit", link.Quit, {}, timeout=10)
|
||||
except Exception:
|
||||
print("[警告] RizomUV Quit 未确认,可能残留 rizomuv 进程占用 license")
|
||||
@@ -1,23 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import bl_uvgate as bg
|
||||
|
||||
|
||||
class TestSeamFallbackLabel(unittest.TestCase):
|
||||
def test_plain_seam(self):
|
||||
self.assertEqual(bg.seam_fallback_label("seam"), "seam_fallback")
|
||||
|
||||
def test_seam_with_uvpm_suffix(self):
|
||||
self.assertEqual(bg.seam_fallback_label("seam+uvpm"), "seam_fallback+uvpm")
|
||||
|
||||
def test_smart_fallback_untouched(self):
|
||||
self.assertEqual(bg.seam_fallback_label("smart_fallback"), "smart_fallback")
|
||||
self.assertEqual(bg.seam_fallback_label("smart_fallback+uvpm"),
|
||||
"smart_fallback+uvpm")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,95 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import rizom_unwrap as ru
|
||||
|
||||
|
||||
class TestCandidatePaths(unittest.TestCase):
|
||||
def test_priority_order(self):
|
||||
self.assertEqual(ru.candidate_paths("a.exe", "b.exe", "c.exe"),
|
||||
["a.exe", "b.exe", "c.exe", ru.DEFAULT_RIZOM])
|
||||
|
||||
def test_none_entries_skipped(self):
|
||||
self.assertEqual(ru.candidate_paths(None, None, None), [ru.DEFAULT_RIZOM])
|
||||
|
||||
def test_env_only(self):
|
||||
self.assertEqual(ru.candidate_paths(None, "b.exe", None),
|
||||
["b.exe", ru.DEFAULT_RIZOM])
|
||||
|
||||
|
||||
class TestFindRizomuv(unittest.TestCase):
|
||||
def test_missing_exe_raises(self):
|
||||
with mock.patch.dict(os.environ):
|
||||
os.environ.pop("RIZOMUV_EXE", None)
|
||||
with self.assertRaises(ru.RizomError):
|
||||
ru.find_rizomuv("Z:/no/such/rizomuv.exe", use_registry=False,
|
||||
default="Z:/no/default.exe")
|
||||
|
||||
def test_explicit_cli_path_is_exclusive(self):
|
||||
# 显式指定的路径不存在必须直接抛错,不得回落默认安装
|
||||
with self.assertRaises(ru.RizomError) as cm:
|
||||
ru.find_rizomuv("Z:/explicit/bad.exe")
|
||||
self.assertIn("Z:/explicit/bad.exe", str(cm.exception))
|
||||
|
||||
def test_registry_value_is_real_exe_if_present(self):
|
||||
p = ru.registry_rizom_path()
|
||||
if p is None:
|
||||
self.skipTest("本机无 RizomUV 注册表项")
|
||||
self.assertTrue(os.path.isfile(p), "注册表返回值应是真实 exe:%s" % p)
|
||||
|
||||
|
||||
class TestCall(unittest.TestCase):
|
||||
def test_timeout_raises(self):
|
||||
with self.assertRaises(ru.RizomError) as cm:
|
||||
ru._call("Slow", lambda p: time.sleep(1.0), None, timeout=0.05)
|
||||
self.assertIn("超时", str(cm.exception))
|
||||
|
||||
def test_error_dict_raises(self):
|
||||
with self.assertRaises(ru.RizomError):
|
||||
ru._call("Bad", lambda p: {"Error": {"Msg": "x", "Code": 1}}, {})
|
||||
|
||||
def test_normal_dict_passes_through(self):
|
||||
self.assertEqual(ru._call("Ok", lambda p: {"Data": 1}, {}), {"Data": 1})
|
||||
|
||||
|
||||
class TestRetry(unittest.TestCase):
|
||||
def test_second_attempt_succeeds(self):
|
||||
calls = []
|
||||
|
||||
def fake_once(*a, **kw):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise ru.RizomError("IPC 抖动")
|
||||
|
||||
with mock.patch.object(ru, "_import_link"), \
|
||||
mock.patch.object(ru, "_unwrap_once", side_effect=fake_once):
|
||||
ru.rizom_unwrap("x.fbx", "rizomuv.exe")
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_both_attempts_fail_raises(self):
|
||||
with mock.patch.object(ru, "_import_link"), \
|
||||
mock.patch.object(ru, "_unwrap_once",
|
||||
side_effect=ru.RizomError("持续失败")), \
|
||||
self.assertRaises(ru.RizomError):
|
||||
ru.rizom_unwrap("x.fbx", "rizomuv.exe")
|
||||
|
||||
|
||||
class TestPackCoverageGuard(unittest.TestCase):
|
||||
def test_empty_coverages_raises(self):
|
||||
with self.assertRaises(ru.RizomError) as cm:
|
||||
ru._check_pack_result({"Coverages": []})
|
||||
self.assertIn("授权", str(cm.exception))
|
||||
|
||||
def test_normal_coverages_pass(self):
|
||||
ru._check_pack_result({"Coverages": [0.72]}) # 不抛
|
||||
|
||||
def test_none_result_passes(self):
|
||||
ru._check_pack_result(None) # 老版本无返回信息时不误伤
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user