Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
62c9a9e10This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env python3
"""Export warns about a mesh that is not a closed manifold solid.
The GUI writes files through belfryscad.exporters rather than the C++
writers, so it has to run the evaluator's own check or the two front ends
would disagree about what counts as sound. This drives the real check
function, not a copy of it.
Qt widgets crash pytest in this project, so this runs standalone.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from PySide6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
QSurfaceFormat.setDefaultFormat(fmt)
from PySide6.QtWidgets import QApplication # noqa: E402
failures = []
def check(label, cond, detail=""):
print(("PASS " if cond else "FAIL ") + label + (f" -- {detail}" if not cond and detail else ""))
if not cond:
failures.append(label)
class _Body:
"""Stands in for a ColoredBody: only .body.to_mesh() is read."""
class _Mesh:
def __init__(self, verts, tris):
import numpy as np
self.vert_properties = np.asarray(verts, dtype="float32").reshape(-1, 3)
self.tri_verts = np.asarray(tris, dtype="uint32").reshape(-1, 3)
class _Inner:
def __init__(self, verts, tris, empty=False):
self._m = _Body._Mesh(verts, tris)
self._empty = empty
def is_empty(self):
return self._empty
def to_mesh(self):
return self._m
def __init__(self, verts, tris, empty=False):
self.body = _Body._Inner(verts, tris, empty)
TETRA_V = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]
TETRA_T = [0, 2, 1, 0, 1, 3, 1, 2, 3, 0, 3, 2]
def main():
app = QApplication.instance() or QApplication(sys.argv)
from belfryscad.window.main_window import MainWindow
from openscad_cpp_evaluator import check_mesh
look = MainWindow._check_export_bodies
# --- the check itself, on known meshes -----------------------------
d = check_mesh(TETRA_V, TETRA_T)
check("a closed tetrahedron passes", d["ok"] and d["watertight"], d["summary"])
holed = check_mesh(TETRA_V, TETRA_T[:9])
check("a missing face is reported as boundary edges",
holed["boundary_edges"] == 3 and not holed["ok"], holed["summary"])
flipped = list(TETRA_T)
flipped[1], flipped[2] = flipped[2], flipped[1]
wound = check_mesh(TETRA_V, flipped)
check("a reversed face is caught as inconsistent winding",
wound["inconsistent_edges"] == 3 and not wound["orientable"], wound["summary"])
check("and is not mistaken for a hole", wound["boundary_edges"] == 0, wound["summary"])
# --- what export would say -----------------------------------------
check("a sound body produces no warning", look([_Body(TETRA_V, TETRA_T)]) == [])
msgs = look([_Body(TETRA_V, TETRA_T[:9])])
check("an unsound body produces one warning", len(msgs) == 1, str(msgs))
if msgs:
check("the warning names the part and the defect",
"part 1" in msgs[0] and "boundary edge" in msgs[0], msgs[0])
msgs = look([_Body(TETRA_V, TETRA_T), _Body(TETRA_V, TETRA_T[:9])])
check("only the unsound part is named", len(msgs) == 1 and "part 2" in msgs[0], str(msgs))
check("an empty body is skipped, not reported",
look([_Body(TETRA_V, TETRA_T, empty=True)]) == [])
# --- the merged mesh is what gets written -----------------------------
# Checking the parts is not the same as checking the file. A Menger
# sponge is thousands of individually perfect cubes whose concatenation
# is riddled with duplicate faces, and the per-part check passed it.
from types import SimpleNamespace
import numpy as np
look_mesh = MainWindow._check_export_mesh
good = SimpleNamespace(
vert_properties=np.asarray(TETRA_V, dtype=np.float32).reshape(-1, 3),
tri_verts=np.asarray(TETRA_T, dtype=np.uint32).reshape(-1, 3))
check("a sound merged mesh produces no warning", look_mesh(good) == [])
# Two coincident tetrahedra: each is perfect, the concatenation is not.
doubled = SimpleNamespace(
vert_properties=np.asarray(TETRA_V + TETRA_V, dtype=np.float32).reshape(-1, 3),
tri_verts=np.asarray(TETRA_T + [i + 4 for i in TETRA_T],
dtype=np.uint32).reshape(-1, 3))
per_part = look([_Body(TETRA_V, TETRA_T), _Body(TETRA_V, TETRA_T)])
check("each part on its own looks fine", per_part == [], str(per_part))
# The two copies use different indices, so an index-based check sees two
# sound solids. Welding by position -- what reading the file does -- is
# what turns them into the doubled faces a slicer chokes on, and the
# export check has to weld for that reason.
merged = look_mesh(doubled)
check("but the merged mesh is caught once welded", len(merged) == 1, str(merged))
if merged:
check("and names the defect", "duplicate face" in merged[0]
or "non-manifold" in merged[0], merged[0])
check("a merged-mesh check that fails is swallowed too",
look_mesh(SimpleNamespace(vert_properties=None, tri_verts=None)) == [])
# --- the real thing: a sponge must export manifold --------------------
import subprocess, tempfile, textwrap
from belfryscad import exporters
from openscad_cpp_evaluator import Evaluator, check_mesh
with tempfile.TemporaryDirectory() as td:
src = Path(td) / "sponge.scad"
src.write_text(textwrap.dedent("""
module sponge(n, s) {
if (n == 0) cube(s, center=true);
else {
t = s/3;
for (x=[-1:1], y=[-1:1], z=[-1:1])
if (abs(x)+abs(y)+abs(z) > 1)
translate([x*t, y*t, z*t]) sponge(n-1, t);
}
}
sponge(2, 27);
"""))
ev = Evaluator(echo_fn=lambda m: None)
out = ev.evaluate(str(src))
bodies = out[0] if isinstance(out[0], list) else out
check("the sponge really is many separate bodies", len(bodies) > 100, str(len(bodies)))
mesh = exporters.merge_bodies_to_mesh(bodies)
v = np.asarray(mesh.vert_properties, dtype=np.float32).ravel().tolist()
t = np.asarray(mesh.tri_verts, dtype=np.uint32).ravel().tolist()
d = check_mesh(v, t)
check("merging abutting bodies gives a manifold mesh", d["ok"], d["summary"])
check("and drops the shared interior faces rather than doubling them",
len(t) // 3 < len(bodies) * 12, f"{len(t)//3} tris from {len(bodies)} cubes")
check("export of the sponge warns about nothing", look_mesh(mesh) == [],
str(look_mesh(mesh)))
# --- an open shell must not vanish on export --------------------------
# Manifold rejects anything that isn't a closed solid, so converting an
# open shell gave an empty Manifold and merge_bodies_to_mesh returned
# nothing to write. Exporting a surface with a missing face produced a
# valid 84-byte STL containing zero triangles, silently.
open_tris = [r for r in
[[0, 2, 1], [0, 3, 2], [1, 2, 3], [0, 1, 3]] if r != [0, 1, 3]]
open_body = _Body(TETRA_V, [i for r in open_tris for i in r])
reported = []
merged = exporters.merge_bodies_to_mesh([open_body], reported)
check("an open shell still produces a mesh to write", merged is not None)
if merged is not None:
check("with its triangles intact rather than an empty file",
len(np.asarray(merged.tri_verts)) == 3,
str(len(np.asarray(merged.tri_verts))))
check("and the export path is told which part was open", reported == [1],
str(reported))
# A closed solid alongside it must still be unioned, not passed through.
reported = []
both = exporters.merge_bodies_to_mesh(
[_Body(TETRA_V, TETRA_T), open_body], reported)
check("a closed part beside an open one is still reported correctly",
reported == [2], str(reported))
check("and both survive into the mesh",
len(np.asarray(both.tri_verts)) == 7,
str(len(np.asarray(both.tri_verts))))
check("a mesh with no bodies at all is still None",
exporters.merge_bodies_to_mesh([]) is None)
check("and one with only empty bodies too",
exporters.merge_bodies_to_mesh([_Body(TETRA_V, TETRA_T, empty=True)]) is None)
# --- slivers are reported, but not as "not manifold" -------------------
# A closed cube whose top face is fanned through a point on its diagonal:
# manifold, with one zero-area triangle. CSG emits these routinely.
sliver = SimpleNamespace(
vert_properties=np.asarray(
[0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0,
0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1,
0.5, 0.5, 1], dtype=np.float32).reshape(-1, 3),
tri_verts=np.asarray(
[0, 2, 1, 0, 3, 2,
4, 5, 8, 5, 6, 8, 4, 8, 6, 4, 6, 7,
0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5,
2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7], dtype=np.uint32).reshape(-1, 3))
msgs = look_mesh(sliver)
check("a mesh with a sliver is still reported on", len(msgs) == 1, str(msgs))
if msgs:
check("but not called non-manifold, because it is one",
"not a closed manifold" not in msgs[0], msgs[0])
check("and the reason given is the zero-area triangle",
"zero-area triangle" in msgs[0] and " 1 " in msgs[0], msgs[0])
# --- stripping on export ----------------------------------------------
class _Win:
logged = []
def log(self, m): type(self).logged.append(m)
_strip_export_slivers = MainWindow._strip_export_slivers
w = _Win(); _Win.logged = []
out = w._strip_export_slivers(sliver)
ov = np.asarray(out.vert_properties, dtype=np.float32)[:, :3]
ot = np.asarray(out.tri_verts, dtype=np.uint32).ravel()
d = check_mesh(ov.ravel().tolist(), ot.tolist())
check("stripping removes the zero-area face", d["degenerate_faces"] == 0, d["summary"])
check("and leaves no holes behind it", d["boundary_edges"] == 0, d["summary"])
check("the solid is still manifold", d["manifold"], d["summary"])
check("what it did is logged", any("zero-area" in m for m in _Win.logged),
str(_Win.logged))
check("the log says it split a neighbour to stay closed",
any("splitting" in m for m in _Win.logged), str(_Win.logged))
def volume(vv, tt):
tri = vv[np.asarray(tt, dtype=np.uint32).reshape(-1, 3)]
return float(abs(np.einsum("ij,ij->i", tri[:, 0],
np.cross(tri[:, 1], tri[:, 2])).sum()) / 6)
before_v = volume(np.asarray(sliver.vert_properties, dtype=np.float32)[:, :3],
np.asarray(sliver.tri_verts, dtype=np.uint32).ravel())
check("no geometry moves -- volume is unchanged",
abs(volume(ov, ot) - before_v) < 1e-6, f"{before_v} -> {volume(ov, ot)}")
w = _Win(); _Win.logged = []
same = w._strip_export_slivers(good)
check("a clean mesh is returned untouched and unlogged",
same is good and _Win.logged == [], str(_Win.logged))
class _Boom:
vert_properties = None
tri_verts = None
w = _Win(); _Win.logged = []
check("a stripping failure returns the mesh rather than blocking export",
w._strip_export_slivers(_Boom) is _Boom)
# A check that raises must never stop a save -- the file matters more
# than the diagnosis.
class _Broken:
class _Inner:
def is_empty(self): return False
def to_mesh(self): raise RuntimeError("nope")
body = _Inner()
try:
check("a failing check is swallowed rather than blocking export",
look([_Broken()]) == [])
except Exception as e: # noqa: BLE001
check("a failing check is swallowed rather than blocking export", False, str(e))
print("\n" + ("ALL PASS" if not failures else f"{len(failures)} FAILED: {failures}"))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())