Test-driven CAD
Solid Node has a test runner and solid_node.test.TestCase extension to run tests with meshes. As an example, you could use assertNotIntersecting to verify that two gears do not overlap during movement, or assertIntersecting to verify that a handle is not detached during movement.
Tests can be written in two styles, both run by the same
solid test command:
mixing solid_node.test.TestCaseMixin into the node class, so tests live next to the rendering logic — used through most of this page;
in a separate companion file, extending solid_node.test.TestCase — shown at the end, and the style the larger V8 engine example uses.
A pin for the clock
To demonstrate testing, let’s make a pin holding the pointer and base of the simple clock together. First, to create a 6mm hole at the base, edit myproject/clock_base.py
class ClockBase(CadQueryNode):
def render(self):
wp = cq.Workplane("XY")
return wp.circle(100).extrude(2) \
.faces(">Z").workplane().hole(6)
Rendered — the base with its 6 mm hole:
And a hole in the pointer, at myproject/pointer.py
class Pointer(Solid2Node):
def render(self):
pointer = translate(-5, -5, 3)(
cube(10, 90, 10)
)
hole = cylinder(r=3, h=15)
return pointer - hole
Rendered — the pointer with its hole:
Now, you should see a hole through both pointer and base, while the pointer is rotating.
Let’s make a pin through them. Create the file myproject/pin.py:
from solid_node.node import Solid2Node
from solid2 import cube, cylinder, translate
class Pin(Solid2Node):
def render(self):
return cylinder(r=3, h=20)
Rendered — the pin:
And at myproject/myproject.py, assemble the pin together:
from solid_node.node import AssemblyNode
from .clock_base import ClockBase
from .pointer import Pointer
from .pin import Pin
class SimpleClock(AssemblyNode):
def __init__(self):
self.base = ClockBase()
self.pointer = Pointer()
self.pin = Pin()
super().__init__()
def render(self):
return [self.base, self.pointer, self.pin]
def simulate(self):
angle = -360 * self.time
self.pointer.rotate(angle, [0, 0, 1])
Rendered — the full clock with the pin fitted (press play):
You should see the pin rendered in viewer, with a tight fit. We want to test if this is functional: if in reality, this arrangement will work. So, let’s write a test.
TestCaseMixin
For that, we’ll use solid_node.test.TestCaseMixin. Our SimpleClock class will extend it, and we’ll add two tests to myproject/myproject.py:
from solid_node.node import AssemblyNode
from solid_node.test import TestCaseMixin
from .clock_base import ClockBase
from .pointer import Pointer
from .pin import Pin
class SimpleClock(AssemblyNode, TestCaseMixin):
def __init__(self):
self.base = ClockBase()
self.pointer = Pointer()
self.pin = Pin()
super().__init__()
def render(self):
return [self.base, self.pointer, self.pin]
def simulate(self):
angle = -360 * self.time
self.pointer.rotate(angle, [0, 0, 1])
def test_pin_runs_free_in_base(self):
self.assertNotIntersecting(self.base, self.pin)
def test_pin_runs_free_in_pointer(self):
self.assertNotIntersecting(self.pointer, self.pin)
On the command line, stop the solid develop command, and run solid test.
You should see two tests failing, as in practice there is a very small intersection between rendered meshes even though mathematically they should not. Let’s reduce the radius of our pin to 2.99, at myproject/pin.py:
class Pin(Solid2Node):
def render(self):
return cylinder(r=2.99, h=20)
Rendered — the slimmer pin:
Run the tests again. This time, the two tests will pass.
@testing_steps
Even though the test has passed, if you look closely, the hole in pointer and the pin are not really round, they are approximated by hexagons — the resolution problem from Modeling parts. We have tested that in the initial setup the pieces do not overlap, but our test can’t tell yet if the parts can freely move.
By using the decorator @testing_steps, we can test the intersection of pieces in several moments of the animation:
...
from solid_node.test import TestCaseMixin, testing_steps
class SimpleClock(AssemblyNode, TestCaseMixin):
...
@testing_steps(16)
def test_pin_runs_free_in_base(self):
self.assertNotIntersecting(self.base, self.pin)
@testing_steps(16)
def test_pin_runs_free_in_pointer(self):
self.assertNotIntersecting(self.pointer, self.pin)
The tests above will each run 16 times, at 16 different instants. Run the tests again, and you’ll see that the tests will pass and fail in a pattern: the base passes, because CadQuery renders it very roundly, but the hexagonal hole in the pointer catches the pin at some angles.
The fix is the fn property from
Modeling parts — set fn = 256 on Pointer
and Pin, and the 0.01 margin we left is enough to make the tests
pass at every step. You should take in consideration the approximation
error on holes whenever OpenScad-derived nodes, like Solid2Node and
OpenScadNode, take part in a fit.
Running tests on the full animation cycle can be very time consuming. We can keep test performance by applying the test to a slice of time
@testing_steps(4, end=0.125)
def test_pin_runs_free_in_base(self):
self.assertNotIntersecting(self.base, self.pin)
@testing_instant
While @testing_steps runs a test across a range of the animation, @testing_instant runs it at one specific instant:
from solid_node.test import TestCaseMixin, testing_instant
class SimpleClock(AssemblyNode, TestCaseMixin):
...
@testing_instant(0.5)
def test_pointer_at_half_turn(self):
self.assertNotIntersecting(self.pointer, self.pin)
Tests in a separate file
Instead of mixing TestCaseMixin into the node class, tests can live in their own file, extending solid_node.test.TestCase. The test runner looks for a companion file next to the node being tested:
for a node in a package, like windmill/__init__.py, it loads windmill/test.py;
for a node in a module, like myproject/pointer.py, it loads myproject/test_pointer.py.
The test class receives the built node as self.node, plus an alias named after the test class (CamelCase converted to snake_case, with the Test suffix dropped) — so a SimpleClockTest can also refer to the node as self.simple_clock. The clock tests from above, in a separate myproject/test_myproject.py:
from solid_node.test import TestCase, testing_steps
class SimpleClockTest(TestCase):
@testing_steps(4, end=0.125)
def test_pin_runs_free_in_base(self):
self.assertNotIntersecting(self.node.base, self.node.pin)
@testing_steps(4, end=0.125)
def test_pin_runs_free_in_pointer(self):
self.assertNotIntersecting(self.node.pointer, self.node.pin)
Both styles are run by the same solid test command, and can be combined — this is how the V8 engine keeps one test file per part.
Available assertions
Besides assertNotIntersecting and assertIntersecting, the test case provides mesh assertions for fits and clearances:
assertNotIntersecting(node1, node2) — the two meshes do not overlap
assertIntersecting(node1, node2) — the two meshes have some overlap
assertInside(node1, node2) — node2 is completely inside node1
assertClose(node1, node2, max_distance) — every point of node2 is at most max_distance away from node1
assertFar(node1, node2, min_distance) — every point of node2 is at least min_distance away from node1
assertIntersectVolumeAbove(node1, node2, min_volume) — the overlap volume is above min_volume
assertIntersectVolumeBelow(node1, node2, max_volume) — the overlap volume is below max_volume
Perturbation assertions
Two assertions verify a fit by perturbing a part and checking the consequence. Each comes in two mutually exclusive modes, selected by which of axis (rotation, the default) or along (translation) is given — passing both is an error:
assertBlockedBeyond(node, angle, against, axis=(0, 0, 1), volume_epsilon=0.0, along=None, directions=’both’) — rotated by
+angle/-angledegrees about axis, or displaced by+angle/-anglemm along the unit vector along, node must intersect against: the fit genuinely locks beyond its play. Use it to prove a key, a dog clutch or a hex socket actually engages — or, in translation mode, that a pin is genuinely captured in its bore.assertFreeWithin(node, angle, against, axis=(0, 0, 1), volume_epsilon=0.0, along=None, directions=’both’) — the anti-gaming twin: perturbed the same way (angle accepts a list in either mode, e.g. a journal sweep or a set of clearance distances), node must not touch against. A blocking test alone could be satisfied by an undersized bore that always rubs; asserting free play within a smaller angle/distance closes that loophole.
Both perturb node about/along its own local frame, not the world origin or world axes: the perturbation is inserted right before node’s own first placement Translation, so a rotation turns node about its own axis, and a translation is carried by any placement rotation that runs after it (node’s own, or an ancestor assembly’s) — along is a direction in node’s frame as it existed at that point in its own placement, not a fixed world vector.
directions=’both’ (the default) checks both signed directions and
requires both to agree; directions=’forward’ checks only
+angle, for contracts that are deliberately one-sided (e.g. a
sleeve blocked sliding inward by a lip but free to slide outward).
volume_epsilon (mm^3, default 0.0) guards against boolean-noise
slivers from a legitimate flush contact (the deprecated leaf sweep has the
same historical parameter): above zero, a perturbation
only counts as fouling once its intersection volume exceeds the
epsilon.
def test_dog_clutch_engages(self):
self.assertFreeWithin(self.sleeve, 2, self.gear)
self.assertBlockedBeyond(self.sleeve, 5, self.gear)
def test_pin_captured_in_bore(self):
self.assertFreeWithin(self.pin, 0.1, self.bore, along=(1, 0, 0))
self.assertBlockedBeyond(self.pin, 0.5, self.bore, along=(1, 0, 0))
Connectivity contracts
Connectivity asks whether geometry hangs together inside one printed solid; it is local and invariant under rigid placement. Collision asks whether separately placed parts clash in the world and can change at each animation instant. Solid Node keeps those frames distinct:
assertNoDisconnectedSolids(node)— starting atnode, descends through assemblies and stops at the first rigid node on each branch. Each selected solid’s own STL must contain exactly one connected component. Rigid ingredients inside a fusion are not checked independently.assertJoined(node1, node2, min_weld_volume=0.0)— proves two named features of the same printed solid meet directly, optionally with a minimum weld volume.
Neither assertion runs automatically. Declare the whole-solid contract as an ordinary counted test where it is wanted:
def test_solid_integrity(self):
self.assertNoDisconnectedSolids(self.node)
Because the assertion reads local STLs and composes no placement matrix, it has the same verdict beneath an animated assembly at every instant. Passing a subassembly scopes the check to that subtree.
Assembly integrity
assertNoSolidInterference(node) is the world-space complement to solid
integrity. It descends through assemblies and selects the first rigid node on
each branch: the same topmost printed-solid boundary used by
assertNoDisconnectedSolids. It then certifies that those solids have no
positive-volume overlap at the testing instant already selected by the
runner. Ingredients inside a rigid fusion are not treated as separate parts.
A rigid root contains only one selected solid, so the assertion passes without loading geometry. This makes both initial tests useful from a project’s first generated leaf through its later evolution into a nested assembly:
def test_solid_integrity(self):
self.assertNoDisconnectedSolids(self.node)
def test_assembly_integrity(self):
self.assertNoSolidInterference(self.node)
The assembly assertion uses current world transforms. Decorate the ordinary
test with @testing_steps or @testing_instant when
the contract must cover motion; the assertion itself neither accepts nor sets
a keyframe.
Empty intersections and exact zero-volume boundary contact pass. Every positive intersection volume reported by the geometry kernel fails, and the diagnostic names an offending pair. There is deliberately no public overlap epsilon. A volume waiver can hide a real narrow penetration and is not a production allowance. Where parts must run free, encode physical clearance in the model and add a pair-specific distance or fit contract with a manufacturing margin expressed in length.
Internally, the assertion places each selected solid’s cached Manifold, builds
one conservative world AABB per solid, and uses a sweep-and-prune index to emit
only the pairs whose boxes overlap. Each emitted pair meets an exact Manifold
intersection. Nothing is computed over the assembly as a whole, so the cost
tracks the number of interacting pairs rather than the model’s total triangle
count. This is a CPU geometry-kernel path (Manifold may use its own CPU
parallelism), not a GPU computation. The manifold3d dependency behind it
is conditional in the same sense as OpenSCAD: it is resolved at the faceted
operation that needs it, so a fully exact model runs its geometric
assertions without the compiled wheel, and a path that needs it and cannot
import it says so by name.
Gravity support
assertAssemblySupported(node, gravity=(0, 0, -1), max_drop=1.0, ground=None,
supports=None, stability_margin=0.0) answers the opposite question to
assembly integrity: not whether two parts share material, but whether the
assembly can exist. It selects the same topmost rigid solids, places them at
the testing instant the runner has already chosen, and proves two things about
them: that every one is transitively held against gravity, and that the whole
set can then stand.
A solid is directly supported by another when, displaced by max_drop
along the normalized gravity vector, it intersects that solid with
positive volume. A part resting on a face, sitting in its clearance gap, or
hanging by an engaged lip all land in their support; a part placed in mid-air
lands in nothing. Zero-volume boundary contact after the drop is not a hold,
just as it is not interference. Those relations form a support graph, and a
part is supported only if that graph leads it to a grounded solid: a block
resting on a floating bracket is reported along with the bracket. Two parts
leaning on each other are grounded exactly when one of them reaches the
ground, never by leaning.
def test_assembly_supported(self):
self.assertAssemblySupported(self.node)
Reaching ground is not standing up
A bar resting on a single support at one end reaches ground through a perfectly good support edge, and falls over. So once reachability holds, the assertion proves frictionless static equilibrium: that some distribution of push-only normal contact forces over the detected interfaces balances every non-anchored solid’s weight and the torque it makes about its own centre of mass — all of them simultaneously. That is a linear feasibility question, decided by one deterministic linear program, and its failure names the solid that cannot be balanced and whether force or torque is what does not close:
bar cannot rest in frictionless static equilibrium on its detected
contacts (unbalanced torque)
The interfaces come from the same displaced intersections the support edges do, meshed and classified so the contact points and normals lie on the supporter’s real, undisplaced surface. Detection runs in both directions: the drop finds what a solid lands on, and a symmetric lift — the same displacement against gravity — finds the overhead restraints. That second sweep is what lets an engaged couple balance legitimately: a pin cantilevering out of a snug hole is pushed up by the hole’s lower wall near the mouth and down by its upper wall at its inner end, and it passes without an exemption. Lift-detected contacts contribute interfaces only; they never add support-graph edges.
Because the drop is what finds a contact, max_drop bounds the interfaces
too: a drop that carries a feature past the face it rests on cannot extract the
patch resting on it, which is the same window the paragraph on max_drop
below describes.
With ground=None the assembly must hold itself together: the solids
reaching within max_drop of the assembly’s furthest extent along gravity
are grounded, which is also what an unmodelled floor would touch. For the
equilibrium phase that floor is a real body — a slab whose top plane lies at
that furthest extent — and it is the only anchored one, so a default-grounded
solid must balance on the footprint it actually lands on. A top-heavy part
standing on too small a foot now fails instead of being exempt for being
lowest. Pass ground — a node, or a sequence of nodes, each resolved to its
selected solid — for an assembly anchored somewhere else, hung from a ceiling
or bolted to a frame that is not modelled; those solids then become the only
seeds and the only anchored bodies, and no floor exists:
def test_hangs_from_the_rail(self):
self.assertAssemblySupported(self.node, ground=self.node.rail)
supports=[(supported, supporter), ...] declares holds the assertion
deliberately cannot prove — press fits, glue, friction — and keeps the
exemption visible in the test rather than hidden in a tolerance. A declared
edge grounds the supported solid and transmits an unrestricted wrench between
the pair, force and torque in both signs, which is what a glue joint or a press
fit really does. A declared supporter must still be grounded itself; declaring
an edge grounds nothing on its own:
def test_supported(self):
self.assertAssemblySupported(
self.node, supports=[(self.node.bushing, self.node.housing)])
stability_margin (mm, default 0.0) shrinks every contact patch toward
its own centroid before the equilibrium decision. At the default the check is
pure feasibility, so a knife-edge balance — the centre of mass exactly over a
patch boundary — is an equilibrium and passes. A positive margin demands that
much interior reserve in every patch and rejects it, which makes robustness an
explicit statement in the test rather than an assumption:
def test_stands_with_a_millimetre_to_spare(self):
self.assertAssemblySupported(self.node, stability_margin=1.0)
A negative stability_margin is a loud error, as are a zero gravity
vector, a non-positive max_drop, and a ground or supports entry
that resolves to no selected solid.
Choosing max_drop (mm) is the one real judgement the assertion asks for.
It must be larger than the design’s vertical clearance play, or a part
sitting in its own clearance gap reads as floating, and smaller than the
thinnest supporting feature’s thickness plus the gap above it, or the dropped
solid tunnels straight through its support and reads as floating again. The
1.0 default sits in the usual window between printed clearances (0.5 mm or
less) and printed walls (1.2 mm or more).
What passing means: support reachability, force balance, torque balance, and
toppling over the contacts the assertion detects. What it does not mean:
friction, adhesion, purely lateral (gravity-parallel) wall reactions, the
toppling of a single solid on the floor — a lone selected solid still passes
without any geometric work — and every dynamic effect. The frictionless model
is deliberately conservative: a hold that exists only through friction fails
and must be declared in supports, the same trade the reachability phase
already makes.
Internally it reuses the assembly-integrity machinery: the same cached Manifolds, the same conservative world AABBs, and the same sweep-and-prune index, asked a directed question — solid i displaced against solid j placed — so only pairs whose displaced and placed boxes overlap ever meet a Boolean, in the drop and lift sweeps alike. A pair of exact solids has its support edge decided by the boundary-representation kernel, as in assembly integrity, while contact patches and mass properties are read off the placed faceted geometry: statics needs a patch’s extent and direction, not Boolean validity. Zero or one selected solid passes without loading geometry.
Deprecated leaf-pair sweep
assertNoPairwiseIntersections(node, volume_epsilon=0.0) is retained for
compatibility but deprecated. It visits every leaf pair and preserves its
historical volume_epsilon behavior. New tests should use
assertNoSolidInterference and account for the deliberate scope change:
topmost rigid printed solids instead of every leaf, with no overlap epsilon.
Testing motion: scenarios
Everything on this page judges the machine at instants of the $t
timeline. A machine with drivers is also testable in
motion — an instruction triggered, an invariant held at a cadence, a
terminal state asserted at an exact tick — with ScenarioTest and the
stepped simulation loop: see Simulating and testing scenarios.
See the API Reference for details. All the standard unittest.TestCase assertions are available as well.