ModelTranslator: add QR bridge pure logic
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
025ce77397
commit
55654e8d33
@@ -0,0 +1,63 @@
|
|||||||
|
"""QR 桥纯逻辑:引擎发现、面数换算、settings 生成、progress 解析。不 import bpy。
|
||||||
|
减面可选后端 Quad Remesher 用——headless 直接调 Engine/xremesh.exe,绕开 modal 操作符。"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
QR_TIMEOUT = 120.0 # 引擎轮询超时(秒)
|
||||||
|
|
||||||
|
|
||||||
|
def find_qr_engine():
|
||||||
|
"""定位 QuadRemesher 的 xremesh.exe;找不到返回 None。
|
||||||
|
顺序:QR_ENGINE 环境变量 -> 标准 addon 路径(取最高 Blender 版本目录)。"""
|
||||||
|
env = os.environ.get("QR_ENGINE")
|
||||||
|
if env and os.path.isfile(env):
|
||||||
|
return env
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if not appdata:
|
||||||
|
return None
|
||||||
|
pattern = os.path.join(
|
||||||
|
appdata, "Blender Foundation", "Blender", "*",
|
||||||
|
"scripts", "addons", "QuadRemesher", "Engine", "xremesh.exe")
|
||||||
|
hits = sorted(glob.glob(pattern))
|
||||||
|
return hits[-1] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
def tris_to_target_quads(tris):
|
||||||
|
"""三角面数换算 QR 目标四边形数:1 quad ≈ 2 tris,至少 1。"""
|
||||||
|
return max(1, int(tris) // 2)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings(in_fbx, out_fbx, prog, target_quads,
|
||||||
|
adaptive=50, exact=False, hard_edges=True):
|
||||||
|
"""生成 RetopoSettings.txt 文本(xremesh.exe -s 读取)。"""
|
||||||
|
lines = [
|
||||||
|
"HostApp=Blender",
|
||||||
|
'FileIn="%s"' % in_fbx,
|
||||||
|
'FileOut="%s"' % out_fbx,
|
||||||
|
'ProgressFile="%s"' % prog,
|
||||||
|
"TargetQuadCount=%d" % target_quads,
|
||||||
|
"CurvatureAdaptivness=%d" % adaptive,
|
||||||
|
"ExactQuadCount=%d" % (1 if exact else 0),
|
||||||
|
"UseVertexColorMap=False",
|
||||||
|
"UseMaterialIds=0",
|
||||||
|
"UseIndexedNormals=0",
|
||||||
|
"AutoDetectHardEdges=%d" % (1 if hard_edges else 0),
|
||||||
|
]
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_progress(text):
|
||||||
|
"""解析 progress.txt -> (state, msg)。
|
||||||
|
首行 '2'=success;<0=error(次行为文本);空/分数/非法=running。"""
|
||||||
|
lines = text.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return ("running", "")
|
||||||
|
try:
|
||||||
|
v = float(lines[0])
|
||||||
|
except ValueError:
|
||||||
|
return ("running", "")
|
||||||
|
if v == 2:
|
||||||
|
return ("success", "")
|
||||||
|
if v < 0:
|
||||||
|
return ("error", lines[1] if len(lines) > 1 else "")
|
||||||
|
return ("running", "")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
import qr_bridge as qb
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrisToTargetQuads(unittest.TestCase):
|
||||||
|
def test_half(self):
|
||||||
|
self.assertEqual(qb.tris_to_target_quads(10000), 5000)
|
||||||
|
|
||||||
|
def test_odd_floors(self):
|
||||||
|
self.assertEqual(qb.tris_to_target_quads(3), 1)
|
||||||
|
|
||||||
|
def test_at_least_one(self):
|
||||||
|
self.assertEqual(qb.tris_to_target_quads(1), 1)
|
||||||
|
self.assertEqual(qb.tris_to_target_quads(0), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSettings(unittest.TestCase):
|
||||||
|
def _s(self, **kw):
|
||||||
|
return qb.build_settings("/i.fbx", "/o.fbx", "/p.txt", 500, **kw)
|
||||||
|
|
||||||
|
def test_core_lines_present(self):
|
||||||
|
s = self._s()
|
||||||
|
self.assertIn('FileIn="/i.fbx"', s)
|
||||||
|
self.assertIn('FileOut="/o.fbx"', s)
|
||||||
|
self.assertIn('ProgressFile="/p.txt"', s)
|
||||||
|
self.assertIn("TargetQuadCount=500", s)
|
||||||
|
self.assertIn("HostApp=Blender", s)
|
||||||
|
self.assertTrue(s.endswith("\n"))
|
||||||
|
|
||||||
|
def test_hard_edges_toggle(self):
|
||||||
|
self.assertIn("AutoDetectHardEdges=1", self._s(hard_edges=True))
|
||||||
|
self.assertIn("AutoDetectHardEdges=0", self._s(hard_edges=False))
|
||||||
|
|
||||||
|
def test_exact_toggle(self):
|
||||||
|
self.assertIn("ExactQuadCount=0", self._s(exact=False))
|
||||||
|
self.assertIn("ExactQuadCount=1", self._s(exact=True))
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseProgress(unittest.TestCase):
|
||||||
|
def test_success(self):
|
||||||
|
self.assertEqual(qb.parse_progress("2\n"), ("success", ""))
|
||||||
|
|
||||||
|
def test_error_with_text(self):
|
||||||
|
self.assertEqual(qb.parse_progress("-3\n重拓扑失败"), ("error", "重拓扑失败"))
|
||||||
|
|
||||||
|
def test_error_no_text(self):
|
||||||
|
self.assertEqual(qb.parse_progress("-3"), ("error", ""))
|
||||||
|
|
||||||
|
def test_running_fraction(self):
|
||||||
|
self.assertEqual(qb.parse_progress("0.5"), ("running", ""))
|
||||||
|
|
||||||
|
def test_running_empty(self):
|
||||||
|
self.assertEqual(qb.parse_progress(""), ("running", ""))
|
||||||
|
|
||||||
|
def test_running_garbage(self):
|
||||||
|
self.assertEqual(qb.parse_progress("abc"), ("running", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindQrEngine(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._env = dict(os.environ)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(self._env)
|
||||||
|
|
||||||
|
def test_env_var_priority(self):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
os.environ["QR_ENGINE"] = path
|
||||||
|
self.assertEqual(qb.find_qr_engine(), path)
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
def test_missing_returns_none(self):
|
||||||
|
os.environ["QR_ENGINE"] = os.path.join(tempfile.gettempdir(), "nope_xremesh.exe")
|
||||||
|
os.environ["APPDATA"] = tempfile.mkdtemp()
|
||||||
|
self.assertIsNone(qb.find_qr_engine())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user