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/-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 (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.