diff --git a/Tools/ModelTranslator/qr_bridge.py b/Tools/ModelTranslator/qr_bridge.py new file mode 100644 index 00000000..facac441 --- /dev/null +++ b/Tools/ModelTranslator/qr_bridge.py @@ -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", "") diff --git a/Tools/ModelTranslator/tests/test_qr_bridge.py b/Tools/ModelTranslator/tests/test_qr_bridge.py new file mode 100644 index 00000000..4ac959a4 --- /dev/null +++ b/Tools/ModelTranslator/tests/test_qr_bridge.py @@ -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()