What “check” and “test” mean for CAD
A geometry library has no natural notion of a passing test. There is no return value to compare, the output is a mesh, and the failures that matter are the ones that still render. This is the answer this project settled on.
OpenSCAD’s exit codes, measured
Re-verified on 2021.01, because the received wisdom is contradictory and mostly wrong. Every gate in this repository is shaped by this table.
Known issue (#38). Everything here is measured on 2021.01, the version CI pins. On 2026.10.15
tests/t_scoping.scadfails andjust cgalcannot pass, because that version prints noSimple: yesline.
| command | exit |
|---|---|
openscad -o x.stl with a failing assert |
1 — assert is a test framework |
openscad -o x.csg with a failing assert |
0 — never use .csg for tests |
openscad -o x.stl with an unknown named argument |
0 |
openscad --hardwarnings -o x.stl, same file |
1 — catches the torusSlice bug class |
openscad -o x.stl on a file emitting no geometry |
1 — hence the sentinel cube(0.001) |
openscad -o /dev/null |
1 — no suffix; always write into out/ |
Two consequences worth stating on their own, because both were once wrong here and neither is visible from a green build:
- Always render tests to
.stl, never.csg. A.csggate reports every failing test as green. --hardwarningsis mandatory in every gate. Without it an unknown named argument exits 0 — the exact bug that made every bend render as nothing for 19 months.
The tiers, in ascending cost
- Data assertions — milliseconds, no rendering. The bulk of the suite, and
only possible because the IR is data rather than geometry.
just test. - Guards — every file in
tests/guards/must fail. An assert nobody has watched fail is not known to work.just guards, folded intojust check. - Advisory warnings —
echo()calls, which--hardwarningsdoes not promote and which a guard therefore cannot cover. Silencing one would otherwise pass every gate.just warnings. - Mesh properties — forced CGAL, because a raw
polyhedron()is never validated in preview, so “renders clean” is not evidence of a valid solid. Plus this library’s ownut_volume(), which agrees with analytic values to 6 significant figures, so volume bands can be tight.just cgal.just checkalso measures the signed volume of every exported mesh (tools/stl_volume.py) and fails on a negative one. CGAL cannot stand in for that: it repairs an inside-out polyhedron in any boolean and says nothing. - Preview correctness —
just preview. Renders each example through OpenCSG, not CGAL, and fails if a closed part shows back faces. See below. Known issue (#37): an example is reported ok when the pixel checker prints nothing. - Declared engineering intent —
checks/, verified with partspec. A dev-time oracle; uniTube itself still links to nothing.just partspec.
Golden STL hashes were considered and rejected. They are brittle to facet
order, floating point, and any $fa/$fs change, and they train people to
ignore the suite.
Preview draws the mesh as given, including inside-out
Every tier above except just preview evaluates geometry with CGAL, and CGAL
repairs an inside-out polyhedron in any boolean without a word. F5 preview does
not. OpenCSG draws the operands of a difference() as they were handed to it.
So preview was the one place the winding defect showed
(history.md §9), and it showed as an assembly that looked solid.
Measured on examples/08_coaxial_jacket, previewed without ut_assemble()’s
render() wrapper, as the percentage of model pixels that are back faces:
| swept faces | 2021.01 | 2026.10.15 |
|---|---|---|
| inside-out, as emitted before the fix | 79.3% | 79.3% |
wound the way polyhedron() expects |
0.0% | 0.0% |
At 79.3% the part is a solid green slug: no bore, no liner, no annulus. Every
CGAL gate said Simple: yes, Volumes: 3, with 19/19 partspec assertions
passing.
This page used to give a different cause. It said OpenCSG ran out of depth
layers on a hollow assembly and painted the subtracted solid over the shell, and
that wrapping the CSG in render() cured it by evaluating the booleans for real.
The control above settles it: the same OpenCSG and the same model go from 79.3%
to 0.0% when only the winding changes. render() helped because it runs CGAL,
which repairs the orientation. polyhedron(convexity=) made no difference at 10,
20 or 40, which fits: convexity was never the problem.
The STL once recorded here as “exported from preview” — 9688 triangles, signed
volume −174446, and at the time 3429 non-manifold edges — is what an ordinary
-o x.stl export of that example produced on 2026.10.15 while the faces were
inside-out.
ut_assemble() still wraps its CSG in render(). With the winding right, the
unwrapped preview of that example is also correct. Whether the wrapper is still
worth a CGAL evaluation in preview is open; see the colouring note below before
removing it.
What the gate measures, and what it cannot
OpenSCAD’s Cornfield scheme paints front faces yellow and back faces green, and
a part viewed from outside shows no back faces. just preview fails an example
with more than 2% green. On 2021.01 every example reads 0.0%.
Three things about that colour, each measured on a cut tube:
| preview of | 2021.01 | 2026.10.15 |
|---|---|---|
difference() with a 160 mm cutter, not rendered |
cut, 28.9% green | cut, 28.9% green |
the same wrapped in render() |
cut, 0.0% | cut, 28.7% green |
difference() with a 1000 mm cutter, not rendered |
not cut, 0.0% | cut, 28.9% green |
OpenSCAD’s own cylinder minus cube(1000) |
not cut | not cut |
OpenSCAD’s own cylinder minus cube(160) |
cut | cut |
- OpenCSG draws a subtracted solid’s faces green. That is by design, on both
versions, so green is not only a symptom of a defect. Every boolean in this
library is wrapped in
render(), and on 2021.01 a rendered result is drawn all yellow, which is why the gate reads 0.0% there. 2026.10.15 keeps the green on a rendered result, so the gate does not apply to it (#38). - A cutter that contains the camera is not subtracted at all. The last two
rows use no uniTube geometry.
ut_cutaway()needs a cutter far larger than the part, so it evaluates its difference withrender(). It did not before, and while the swept faces were inside-out the two errors drew something that passed for a section. - The gate cannot see a cut that did not happen. An uncut part reads 0.0%,
the same as a clean one.
examples/12_section_viewpassed with no section drawn. Nothing yet asserts that a cutaway cuts in preview.
Simple: yes is necessary and nowhere near sufficient
Every silent failure this design has been reviewed against passed it:
- a sealed branch lumen —
Simple: yes, Volumes: 2, - a coaxial liner deleted entirely — 26% of the material gone,
- a tube swept through a bend tighter than its own radius, passing through
itself —
Simple: yes, Volumes: 2and a plausible positive volume, - a junction rendering as separate solids held together by a shared circle,
- and every swept solid inside-out: a plain tube exporting −5865.23 mm³ for +5865.23, and an assembly previewing as a solid slug.
A self-intersecting run logs both CGAL ERROR: assertion violation! and
Simple: yes, and openscad exits 0 — so just cgal greps for the error text
as well, and grepping only for Simple: yes was itself a bug here once.
Assert lumen patency with a keep_out down the bore, and size the region
inside the modelled bore: region.cylinder circumscribes its declared circle
while the modelled bore is inscribed in its own $fn, so a probe at nominal d
cannot pass.
The rule that makes any of it worth having
Validate every new check by deliberately reintroducing the bug it claims to
catch. A check that cannot fail is worth nothing. tests/guards/ holds those
reintroductions, and every one is watched to fail before it is trusted.
The partspec contracts in checks/ were validated the same way. Disabling the
bore extension produced 61.02 mm3 of material intrudes into the keep-out region
while watertight and solid_count both still passed. Collapsing the lumen
groups produced solid_count — measured 1, limit equals=2.
That standard is why just check would have caught the regression that started
this project, at commit time, in about eight lines of shell.