根因(真机排查):QuadRemesher 引擎在激进减面下非确定性地从封闭流形 输入打出孔洞/非流形边(实测同一输入约 85% 次数破碎),即"破碎面"。 修复:QR 导回后比对输入拓扑,若比输入新增开边(孔洞)/非流形边即判破损、 弃用 QR 回退 collapse(topology_acceptable 纯策略+单测;_edge_defects 计数)。 顺带修复水密门在导入后拒绝时未恢复 obj 选中态,导致 collapse 回退时 export use_selection 选不到对象、只导出 4KB 空 FBX 的 bug。 含用户改动:--reducer 默认改为 quad(配合水密门,破碎自动回退,安全), 及其 CLI 默认值测试。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
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_positive_nonfinal(self):
|
|
# 正整数但非终态标记 2 -> running(引擎进度信号,非成功)
|
|
self.assertEqual(qb.parse_progress("1"), ("running", ""))
|
|
|
|
def test_success_float_form(self):
|
|
self.assertEqual(qb.parse_progress("2.0"), ("success", ""))
|
|
|
|
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())
|
|
|
|
def test_invalid_override_does_not_fall_through(self):
|
|
# QR_ENGINE 权威:显式设了但无效,即便 APPDATA 下装了真引擎也返回 None
|
|
root = tempfile.mkdtemp()
|
|
os.environ["QR_ENGINE"] = os.path.join(root, "nope.exe")
|
|
os.environ["APPDATA"] = root
|
|
eng_dir = os.path.join(root, "Blender Foundation", "Blender", "5.0",
|
|
"scripts", "addons", "QuadRemesher", "Engine")
|
|
os.makedirs(eng_dir)
|
|
open(os.path.join(eng_dir, "xremesh.exe"), "w").close()
|
|
self.assertIsNone(qb.find_qr_engine())
|
|
|
|
def test_globs_addon_path_picks_last(self):
|
|
root = tempfile.mkdtemp()
|
|
os.environ.pop("QR_ENGINE", None)
|
|
os.environ["APPDATA"] = root
|
|
base = os.path.join(root, "Blender Foundation", "Blender")
|
|
for ver in ("4.2", "5.0"):
|
|
eng_dir = os.path.join(base, ver, "scripts", "addons",
|
|
"QuadRemesher", "Engine")
|
|
os.makedirs(eng_dir)
|
|
open(os.path.join(eng_dir, "xremesh.exe"), "w").close()
|
|
chosen = qb.find_qr_engine()
|
|
self.assertIsNotNone(chosen)
|
|
self.assertIn(os.path.join("5.0", "scripts"), chosen)
|
|
|
|
|
|
class TestTopologyAcceptable(unittest.TestCase):
|
|
def test_closed_source_clean_qr_ok(self):
|
|
# 封闭输入(0/0) + 水密 QR(0/0) -> 接受
|
|
self.assertTrue(qb.topology_acceptable(0, 0, 0, 0))
|
|
|
|
def test_closed_source_holey_qr_rejected(self):
|
|
# 封闭输入被 QR 打出开边 -> 拒绝
|
|
self.assertFalse(qb.topology_acceptable(0, 0, 8, 0))
|
|
|
|
def test_closed_source_nonmanifold_qr_rejected(self):
|
|
self.assertFalse(qb.topology_acceptable(0, 0, 0, 1))
|
|
|
|
def test_open_shell_source_same_ok(self):
|
|
# 输入本身是开放壳(10 开边),QR 未新增 -> 接受
|
|
self.assertTrue(qb.topology_acceptable(10, 0, 10, 0))
|
|
|
|
def test_open_shell_source_worse_rejected(self):
|
|
# QR 比输入引入更多开边 -> 拒绝
|
|
self.assertFalse(qb.topology_acceptable(10, 0, 12, 0))
|
|
|
|
def test_qr_fewer_defects_ok(self):
|
|
# QR 输出反而更少缺陷(不劣于输入)-> 接受
|
|
self.assertTrue(qb.topology_acceptable(10, 2, 8, 1))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|