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/-angle degrees about axis, or displaced by +angle/-angle mm 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 at node, 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.