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 root 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 root/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 root/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 root/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 root/__init__.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):
        angle = -360 * self.time
        self.pointer.rotate(angle, [0, 0, 1])
        return [self.base, self.pointer, self.pin]

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 root/__init__.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):
        angle = -360 * self.time
        self.pointer.rotate(angle, [0, 0, 1])
        return [self.base, self.pointer, self.pin]

    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 root command, and run solid test root.

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 root/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 root/__init__.py, it loads root/test.py;

  • for a node in a module, like root/pointer.py, it loads root/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 root/test.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 root command, and can be combined — this is how the V8 engine in Examples 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 (see assertNoPairwiseIntersections below): 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))

Adjacency sweep

  • assertNoPairwiseIntersections(node, volume_epsilon=0.0) — walks the assembled tree rooted at node down to its leaves and asserts that every pair of leaves is non-intersecting. This is a safety net that holds regardless of which specific contracts exist: any two parts you forgot to test against each other directly are still covered. Combine it with @testing_steps to sweep the whole animation. volume_epsilon (mm³) is for parts that legitimately abut flush: their boolean intersection is float noise, orders of magnitude below any real interference — above zero, only fouling volumes exceeding the epsilon count as intersections.

See the API Reference for details. All the standard unittest.TestCase assertions are available as well.