API Reference

Nodes

All node classes are importable from solid_node.node; the parameters they declare come from solid_node.parameters. A project is a tree of nodes: leaf nodes generate solids with an underlying modelling library, internal nodes combine their children.

Common node API

class solid_node.node.base.AbstractBaseNode(*args, **kwargs)

A mechanical project in solid-node is represented by a tree, and this is the abstract base class for all nodes. Above this class, there are two other base classes: LeafNode and InternalNode.

render()

Every node must implement render(): it builds the node at rest. Leaf nodes return an object of the underlying modelling library; internal nodes return a list of child node instances (or, on a declarative class, nothing), after placing the parts that do not move. It reads no driver, no time and no port — an assembly’s render() that read none runs once per instance; one that does read keeps re-running per binding and warns once per class. What moves belongs to AssemblyNode.simulate().

rotate(angle, axis)

Rotate this node by angle degrees around the vector axis (a list of three numbers, e.g. [0, 0, 1]). Applied in render() it is rest placement and persists; applied in simulate() it is motion — composed inside the rest placement, stated absolutely for its instant, and dropped before the assembly simulates again. Both apply in the viewer and to the mesh used by tests. Returns the node itself, so calls can be chained. In simulate(), angle may be an expression involving AssemblyNode.time or any declared driver.

translate(translation)

Translate this node by the vector translation, a list of three numbers, e.g. .translate([100, 0, 0]). Chains like rotate(), and the node itself is returned.

fn

Number of facets used to approximate curved surfaces, applied as OpenSCAD’s $fn to the generated code. Only meaningful for OpenSCAD-based nodes (Solid2Node, OpenScadNode); the OCCT-backed leaves (CadQueryNode, Build123dNode, the sheet leaves, MolejoNode) export high-resolution STLs on their own. Default is None, which keeps OpenSCAD’s coarse default.

name

The node’s name, used in viewer and test failure messages. Defaults to the class name; can be overridden with the name keyword argument of the constructor.

set_keyframe(time)

Set a fixed time for keyframes and tests. No-op for non-animated nodes; overridden by AssemblyNode.

clear_keyframe()

Drop a fixed time, returning to symbolic animation time. No-op for non-animated nodes; overridden by AssemblyNode.

assemble(root=None)

Renders this node and returns an optimized version with all operations applied

property mtime

Maximum mtime in source file of all nodes rendered inside this one.

Public and float, as it has always been: the published viewer document records it per node. Derived from mtime_ns rather than computed beside it, so the two can never disagree about which source file won. Nothing in the build path decides currency from this value.

Leaf nodes

class solid_node.node.leaf.LeafNode(*args, **kwargs)

This is a base class for all leaf nodes, which are nodes that generate solid structures. Each LeafNode subclass uses a different technology to generate a solid, and outputs the result as STL. LeafNode subclasses are in the solid_node.node.adapters.* namespace.

property time

Raise an exception, as leaf nodes cannot rely on time.

A part whose SHAPE follows the machine – a spring, a belt, a loom – is a flexible leaf (MolejoNode), and it reads no time either: its geometry is a pure function of the values its parent binds to its declared ports.

class solid_node.node.exact_leaf.ExactLeafNode(*args, **kwargs)

Base for the leaf adapters whose backend is a B-rep kernel.

Holds the exact-adapter contract that exact-geometry specifies and ADR-047 explains: because every exact backend converts its render result to one shared OCCT shape at the adapter boundary, everything after that conversion is one implementation rather than one per backend. A subclass supplies what genuinely differs – its namespace, and whatever validation its own API needs.

This is a framework-internal base, not a declared extension point: it lives here so that a correction to the contract lands once, not so that a project can subclass it.

It deliberately declares no namespace, inheriting LeafNode’s None, so it imposes none on a subclass.

Kept out of leaf.py on purpose. That module imports nothing heavier than base, and putting this here would make every adapter that imports LeafNode – Solid2Node included – pull in exact.py and through it cadquery, trimesh and OCP.

property exact

Whether this node exposes exact boundary-representation geometry.

class solid_node.node.Solid2Node(*args, **kwargs)

Represents a 3D object created using the SolidPython2 tool.

as_number(n)

Receives a solid2 function result and calculates its number. uses an openscad process internally to do the calculation.

class solid_node.node.CadQueryNode(*args, **kwargs)

Represents a 3D object created using the CadQuery tool.

The exact-adapter contract – exact, shape(), as_scad() – is ExactLeafNode’s; CadQuery adds only its namespace and the CQ-editor metaclass.

class solid_node.node.Build123dNode(*args, **kwargs)

Represents a 3D object created using the build123d tool.

build123d is a boundary-representation backend over the same OCCT that CadQueryNode uses, so the exact-adapter contract – exact, shape(), as_scad() – is ExactLeafNode’s. What is build123d’s own is the namespace and the solid-shaped-result rule below.

Note that this module never imports build123d. See build123d_shape().

class solid_node.node.SheetLeafNode(*args, **kwargs)

Base for the leaf adapters whose part is cut from sheet stock.

A sheet part is authored as a two-dimensional profile plus a declared thickness, and its solid is that profile extruded from the XY plane along +Z. The extension point is therefore profile(), never render(): render() is owned here so that the solid in the tree and the cut file on disk cannot describe different parts. A leaf that authored the solid separately from the profile would be free to drift, and a laser cutter would happily cut the drift.

The direction matters. Authoring 2D and deriving 3D is a projection – one extrusion, no choices. Recovering 2D from an authored solid is an inverse problem: which plane to slice on, whether the thickness is even constant, how to reconstruct a curve from a slice. So the profile is the source and the solid is derived, not the other way round.

A subclass supplies what genuinely differs between backends: how a profile result is reduced to one planar face, how a face is extruded, and how a face is written as a cut file. The rules – one planar face, one part per leaf, on the XY plane, a positive thickness – are here, so every sheet backend rejects the same authoring mistakes with the same wording.

Like ExactLeafNode this is a framework-internal base: projects subclass a concrete sheet adapter, not this.

thickness

Thickness of the stock the part is cut from. Required and positive, declared as a class attribute or passed as a thickness= constructor argument.

dxf_file

Path of the node’s nominal cut file, written beside its .stl and .brep.

profile()

The part’s 2D profile, in the backend’s own terms.

This is what a sheet part’s subclass writes. It must produce one planar face – a single outer boundary with any holes strictly inside it – lying on the XY plane.

render()

The extrusion of the validated profile.

Not an extension point: overriding it would let the solid and the cut file describe different parts.

validated_profile()

The one planar face this part’s solid and cut file both come from, validated once per node.

class solid_node.node.Build123dSheetNode(*args, **kwargs)

A sheet part authored as a build123d profile.

The whole adapter is the three backend-specific steps SheetLeafNode asks for – reduce a profile result to one planar face, extrude it, write it as a cut file. The contract around them is the sheet base’s, and the exact-adapter contract under that is ExactLeafNode’s: the extrusion is an ordinary build123d Part, so namespace validation, the OCCT rewrap, and the STL/BREP writes are the ones every other exact adapter already goes through.

This is not a Build123dNode. Both drive build123d, but a Build123dNode renders a solid it authored, while a sheet part’s solid is derived and only its profile is authored – and the two must stay distinct types, since isinstance and the MRO backend walk both distinguish adapters that way.

Like the other build123d adapter, this module never imports build123d at import time: solid_node.node imports every adapter eagerly, and build123d costs about 1.6 seconds a project on another backend should not pay.

profile()

The part’s cut profile: a build123d Sketch, a Face, or a BuildSketch builder holding one.

class solid_node.node.OpenScadNode(*args, **kwargs)

A pure OpenScad node. You just need to declare the property “scad_source” with the path of your OpenScad source code. It must be placed in the same directory of the python file containing this node.

The scad file must contain a module with the same name of the file, or you may specify the property “module_name” with the module name.

scad_source

Path of the OpenScad source file, relative to the directory of the python file declaring the node.

module_name

Name of the module to call inside scad_source. Defaults to the file name without the .scad extension.

__init__(*args, name=None, **kwargs)

Receives args, an optional name keyword argument and a list of keyword arguments. The list of arguments and keyword arguments will be passed as parameter to the module.

Parameters:
  • *args – will be passed as arguments to the OpenScad module

  • argument (name keyword) – the name of this node, defaul to name of the class

  • **kwargs – will be passed as keyword arguments to the openscad module

class solid_node.node.JScadNode(*args, **kwargs)

A JScad node. You just need to declare the property “jscad_source” with the path of your JScad source code. It must be placed in the same directory of the python file containing this node.

You need to have jscad cli tool installed in $PATH, and node dependencies installed in the directory you are running solid from.

jscad_source

Path of the JScad source file, relative to the directory of the python file declaring the node. The file must export a main function.

class solid_node.node.StlNode(*args, **kwargs)

A part that comes from a committed STL file.

Declare the file with stl_source, as a path relative to the directory of the module defining the subclass:

class Bracket(StlNode):

    stl_source = 'bracket.stl'

Select one part out of a multi-body file with body, a 0-based index into the file’s bodies ordered by centroid; a node that omits it on a multi-body file fails with the pack’s inventory. Correct the mesh by implementing adjust(self, mesh). Admit a mesh known not to be a closed solid with require_watertight = False.

The node’s geometry is its own artifact, materialized from the source file: the committed mesh is never imported in place, so fusion, piece identity, export and the viewer all see one thing. Producing it needs no external tool – not even OpenSCAD, since the artifact is stamped with the source mtime exactly as JScadNode stamps the one jscad produced for it.

adjust(mesh)

Optional hook correcting the selected body in code: receives a trimesh mesh, returns the corrected one. Whatever it returns is what the artifact holds — and what the watertight gate judges.

stl_source

Path of the committed .stl, relative to the directory of the python file declaring the node.

require_watertight

True by default: a mesh that does not enclose a solid fails at build, naming the defect. Set to False to admit an open mesh knowingly; the flag never changes geometry.

body

0-based index selecting one connected component of a multi-body file. A multi-body file with no body fails with a per-body inventory of centroid, bounds and volume.

class solid_node.node.FlexibleNode(*args, **kwargs)

Base for the leaf adapters whose part deforms with machine state.

A subclass declares one port per shape parameter, returns its backend’s shape object from render(), and implements the three backend hooks. Everything else – rigidity, the parameter-surface check, the resolved binding, the per-binding snapshot artifact and the mesh seam – is here, so a correction to the contract lands once.

class solid_node.node.MolejoNode(*args, **kwargs)

A flexible part authored as a molejo shape.

Declare one port per shape parameter and return the shape:

class ValveSpring(MolejoNode):

    height = TranslationalPort(unit='mm')

    def render(self):
        return Shape(
            profile=Circle(radius=2.0),
            path=[Helix(radius=14.0, turns=6.5, height=P.height)],
            path_samples=240, profile_samples=16,
        )

molejo.P.<name> refers to the port of that name; the parent assembly binds it with connect(), and the two name sets have to agree exactly – see FlexibleNode.

property shape_tolerance

The approximation tolerance shape() was built to, at the current binding: zero when every surface of the solid is analytic, and the backend’s declared approximation where some part of the sweep has no closed form the kernel can hold. Carried rather than hidden – a swept helix is honestly tolerant, and a caller reading an exact answer deserves to know how exact.

Internal nodes

class solid_node.node.internal.InternalNode(*args, **kwargs)

Internal nodes combine its children nodes in some way to make a node with several solids.

connect(source, sink)

Bind sink’s value from source, converting through the sink’s declared scale.

Causal and immediate: this is sugar over an assignment, run in the owning simulate(), so a run under a new driver snapshot rebinds every port absolutely. There is no registry, no connection graph and no deferred resolution – the wiring is re-executed because the simulate() that states it runs again. A parent’s simulate() runs before any child’s, so a child may read in its own simulate() what its parent bound. Called from a render() it binds once – render() runs once – and is reported as the deprecated form. Acausal connection (equations, solver orientation) is a separate, later design; nothing here should be read as a down payment on it.

source is a bound port or a plain value; the value may be a symbolic animation expression, which flows through unresolved exactly as an operation value does.

class solid_node.node.AssemblyNode(*args, **kwargs)

Represents a collection of components that can be moved relative to each other. This is an internal node that can contain instances of LeafNode or other internal nodes. The render method of this class returns a list of its child nodes.

clear_keyframe()

The inverse of set_keyframe: drop the fixed time so this assembly renders against solid2’s symbolic $t again, leaving any other bound driver in place.

set_keyframe(time)

Set a fixed time for keyframes and tests, propagating it down the tree so nested assemblies render numerically too.

Exactly the time-only surface over the snapshot: time is one driver among several, and keyframing binds that one entry.

set_state(**states)

Bind named driver values for this instant, propagating them down the tree so nested assemblies render numerically too.

Entries MERGE into the current snapshot rather than replacing it: set_keyframe(t) is this method with a single entry, and it must go on touching nothing but time. A stepping loop that binds a full snapshot every tick is unaffected either way.

An entry is addressed either to the whole tree or to one instance in it. A dotted name is an instance-qualified driver id – set_state(**{‘x_axis.motor’: 8000}), passed as a mapping because a dotted name is not a Python identifier – and reaches only that instance’s subtree, so two instances of one class hold independent values for their same-named driver. A bare name propagates flat, which is the stage-2 form and still exactly right while the tree holds only one driver by that name; when it holds two, binding fails naming both qualified ids rather than quietly giving them one value. time is the one entry that is global by contract, and never needs qualifying.

Every other entry has to NAME a declared driver – a bare name some declaration in the tree bears, or a qualified id the tree publishes – and binding fails when it does not. Nothing could read such an entry: a driver is read as an attribute of the node that declares it, so a name with no declaration behind it binds a value that is unreachable, and the mistake would surface later, somewhere else, as a DIFFERENT driver’s unbound-read error.

simulate()

Move the machine for the current instant.

Run by the framework after render() on every enumeration of this assembly’s children, under whatever binding is current: symbolic $t when nothing is bound, plain numbers under set_state, set_keyframe, the test runner, the snapshot tool and the simulator. This is where drivers, self.time and ports are read and bound, and every operation applied here is motion: innermost on the part’s chain, before the rest placement render() gave it, and swept before the next run so the pose is absolute. The base does nothing, so super().simulate() chains; a part that does not move needs no simulate() at all. Structure is not decided here: omit() raises.

property time

The $t variable, the animation time from 0 to 1.

One entry of the snapshot, with the ADR-008 fallback: an assembly nobody bound a time on still animates symbolically, which is what the build and viewer paths depend on. Only time falls back – any other unbound driver fails loudly, because a default invented here would bind the simulation layer to a contract it never chose.

time is the one entry that is global by contract: it propagates flat and unqualified to every descendant, and never needs an instance path. What it MEANS is the binder’s choice – a keyframe binds the normalized 0..1 fraction, a running simulation binds the stepped clock in seconds – and this property just reports whatever was bound.

class solid_node.node.FusionNode(*args, **kwargs)

Represents a fusion of components into a single, inseparable unit. This internal node can contain LeafNode or other FusionNode instances. The render method of this class returns a list of its child nodes.

property time

You can’t use self.time with a FusionNode, as the resulting object is expected to be rigid.

Parameters

The knobs that decide what machine gets built, importable from solid_node.parameters and from nowhere else. A parameter is declared as a class attribute, is fixed for the life of an instance, enters the node’s build identity, and reads back inside render() as a plain number. Contrast Driver, which is a runtime input and changes every instant. See Declaring a machine.

class solid_node.parameters.Length(default=<object object>, *, min=None, max=None)

A linear dimension, in the project’s unit (millimetres). Signed by default; min=0 opts into non-negativity.

class solid_node.parameters.Angle(default=<object object>, *, min=None, max=None)

An angle in degrees. Its own axis: formally dimensionless, but keeping it apart is what refuses phase + rotor_fraction and lets trig demand an angle.

class solid_node.parameters.Count(default=<object object>, *, min=None, max=None)

A whole number of things: teeth, cylinders, sides.

class solid_node.parameters.Ratio(default=<object object>, *, min=None, max=None)

A dimensionless fraction; also what a Length over a Length is.

class solid_node.parameters.Scalar(default=<object object>, *, min=None, max=None)

The escape hatch: a number the algebra does not check.

class solid_node.parameters.Flag(default=<object object>)

A boolean selector, outside the algebra: it gates structure through omit(), never enters a formula.

class solid_node.parameters.Quantity(default=<object object>, *, min=None, max=None)

A declared parameter of one dimension: a token in formulas, a descriptor on the class, a plain value on the instance.

Subclass with a dimension mapping to declare a kind the framework does not name (class Torque(Quantity): dimension = {‘M’: 1, ‘L’: 2, ‘T’: -2}); the algebra needs nothing else.

solid_node.parameters.declared_parameters(node_class)

Every parameter declared on node_class, by name, declared and derived alike, base-first so a subclass redeclaration wins.

The structural counterpart lives next door: declared_children in solid_node.node.declarative answers the same question about the children a class body declares.

solid_node.node.declared_children(node_class)

Every child declared on node_class, by attribute: a ChildDeclaration, a list of them, or a RepeatDeclaration.

Ports

Domain-typed connection points between nodes, importable from solid_node.node. A port is declared as a class attribute; the parent assembly binds it every simulate() with connect(). See Driving a machine.

class solid_node.node.Port(unit=None, out=False, scale=None)

A port declaration, made as a class attribute on a node.

A descriptor rather than an attribute created in __init__: the declaration must be readable off the CLASS (see declared_ports), and a node builds its children before calling super().__init__(), so there is no single point where instance ports could be created reliably. __get__ materializes the instance’s slot lazily instead.

class solid_node.node.RotationalPort(unit=None, out=False, scale=None)

A port carrying an angle: a shaft, a crank, a stepper’s count of microsteps around its own axis.

class solid_node.node.TranslationalPort(unit=None, out=False, scale=None)

A port carrying a position along an axis: a carriage, a piston, a lift.

class solid_node.node.SignalPort(unit=None, out=False, scale=None)

A port carrying a dimensionless command value: an enable, a duty cycle, a setpoint – something with no mechanical domain.

solid_node.node.declared_ports(node_class)

Every port declared on node_class, by name.

Reads the class dictionaries directly, so nothing is instantiated and no __get__ runs: a consumer can enumerate a mechanism’s connection points from the class alone. Walked base-first so a subclass redeclaring an inherited port wins.

Simulation

The stepped simulation layer lives in solid_node.simulation. See Driving a machine for drivers and instructions, and Simulating and testing scenarios for the loop and scenario tests.

class solid_node.simulation.Driver(default: object, range: tuple = None, unit: str = None, dtype: type = None, scale: float = None)

A driver declaration, made as a class attribute on an assembly.

Frozen on purpose: an attribute that could be assigned here would be state shared by every node of the class and every simulation over it. scale is design units per native unit – millimetres per microstep, say – and is what lets an instruction state a target in the units a maker thinks in while the state stays native.

range is expressed in DESIGN units, like an instruction target and unlike default, whatever scale says: it is the travel a maker means, and a presenter converts it through scale exactly once, the way native converts a target. It is presentation metadata and nothing anywhere clamps to it – a machine driven past its declared travel is a crash, which is a thing a simulation must be able to show rather than silently prevent.

It subclasses the node layer’s DriverDeclaration marker, and the dependency still runs one way: the node layer has to RECOGNIZE a declaration to qualify it and deliver a state entry to it, while what a driver means – native units, integer rounding, ramps – stays here. solid_node.node imports nothing from this package.

The read surface comes with the marker: DriverDeclaration is a descriptor, so a declaration made as x = Driver(…) is read self.x off the node that declares it, and the declaration itself off the class. That is node-layer business – it hands back what the node-layer snapshot holds – and it leaves the fields below untouched: the name a declaration is bound under is kept in a private non-field slot, so two identical declarations on different attributes still compare equal.

class solid_node.simulation.Instruction(targets, duration)

A named event source declared on an assembly.

targets are keyed by driver name and expressed in DESIGN units; duration is in seconds, converted to whole ticks by the simulation that triggers it. The targets are copied on the way in so a declaration cannot be edited through the mapping a caller happens to still hold.

class solid_node.simulation.RampProgram(start, target, ticks, dtype=None)

A linear ramp: delta distributed over ticks ticks.

For an integer driver the distribution is start + delta*k//n, so every intermediate value is a whole native unit and k == n lands exactly on the target – floor division cannot accumulate the error that repeated float addition would. A float driver gets the same shape without the integer guarantee, and lands exactly for the same reason: the endpoint is returned, not computed.

class solid_node.simulation.Sim(node, dt, meshes=False)

A stepped simulation over one assembly at a fixed dt.

Construction enumerates every driver in the node’s LINKED TREE and binds each one’s default through set_state by qualified id, so an assembly whose render() reads a driver – including one whose drivers live only on its children – is rendered under a complete snapshot from the very first render rather than failing on an unbound entry. That is the resolution stage 1 deferred to this layer: the framework invents no defaults, the declarations state them.

The bank, the trajectory, the programs and instruction targets all key by that same qualified id, so two instances of one mechanism step independently and the id a scenario writes is the id the serialized document publishes.

meshes=True additionally assembles the node and builds its STLs, which is what a scenario asserting on geometry needs and what a scenario asserting on state should not pay for. Per-tick re-renders never touch the artifact path, so this happens once, here.

trajectory

The recorded (tick, states) history of the run, one entry per stepped tick.

property assertion_stats

(calls, mean seconds per call) across every cadence slot.

at(t)

The registrar for instant t, in seconds.

property cadence_costs

What each cadence slot has cost so far, in declaration order: a scenario report states assertion cost per slot.

every(period, fn, *args)

Call fn(*args) every period seconds of simulated time.

The arguments are bound now and the call deferred, which is exactly right for an assertion whose subject is a node: the node is the same object at every tick, and what changes is the snapshot bound into it.

run(duration)

Step for duration seconds of simulated time.

Actions already due at the current tick run first, so a scenario opening with at(0.0).trigger(…) takes effect on the first tick rather than one tick late. Then each tick advances every program, binds the whole snapshot, records it, and only then runs what was scheduled – an action must see the state its tick produced, never the one before it.

property state

a fresh dict, so a caller holding one holds a value and not a view of the running simulation.

Type:

The current snapshot by qualified driver id

property time

The exact instant this simulation stands at, in seconds.

Computed from the integer tick count on every access, never accumulated: k*dt is a fixed point, and a float advanced by += dt drifts off the instant a scenario names (ADR-050’s reasoning applied to simulation time).

trigger(name)

Start the named instruction’s ramps at the current tick.

name is qualified the same way a driver id is: an instruction declared on a child is x_axis.Home, one declared on the root keeps its bare name. Its targets are class-local, so they resolve against the declaring node’s own path – which is what makes “home the X axis” home only the X axis.

class solid_node.simulation.ScenarioTest(methodName='runTest')

Base for scenario test classes.

Declare node (the assembly class this scenario steps) and dt (the fixed step, in seconds). Set meshes = True when a scenario asserts on geometry – it is what makes the run build STLs, and a scenario about state alone should not pay for them.

scenario_node()

The assembly this scenario steps, built once.

Under the solid test runner node is already the built instance the runner handed over; under pytest it is still the declared class, and building it here is what keeps one scenario class running under both.

simulation(dt=None)

A fresh simulation over this scenario’s node.

Fresh per call, never per class: the state bank a simulation owns is the whole of what a run mutates, so two scenarios of one class share nothing but the geometry they were built from.

solid_node.simulation.qualified_drivers(root)

Every driver declared in root’s tree, by qualified id.

Binding is not a side effect to apologize for: finding the children means rendering, and a state-consuming assembly cannot be rendered under no snapshot at all, so the walk binds each declaration’s own default as it descends. The framework still invents nothing – the declarations state the values, and a caller with a real snapshot (a simulation tick, a serialization pass) binds over them immediately.

solid_node.simulation.qualified_instructions(root)

Every instruction declared in root’s tree, by qualified name.

{qualified_name: (node, path, instruction)}. An instruction is declared with class-local target names, so the declaring node’s path is what turns {‘motor’: 0.0} into a target on x_axis.motor; a root-declared instruction keeps its bare name, exactly as a root-declared driver does.

Testing

The testing API lives in solid_node.test. See Test-driven CAD for a walkthrough of both ways of writing tests: mixing TestCaseMixin into a node class, or writing a TestCase in a separate file.

class solid_node.test.TestCase(methodName='runTest')

assertNoDisconnectedSolids(node) checks that every topmost rigid solid in a subtree is one connected body. assertNoSolidInterference(node) checks that those same printed solids have no positive-volume world-space overlap at the runner’s current keyframe; exact boundary contact passes and there is no public overlap epsilon. assertAssemblySupported(node, gravity=(0, 0, -1), max_drop=1.0, ground=None, supports=None, stability_margin=0.0) checks the physical inverse over the same selection: that every printed solid is transitively held against gravity, proved by dropping it max_drop into whatever holds it, and that the assembly can then stand — that push-only normal forces over the contacts detected by that drop and by a symmetric lift balance every solid’s weight and torque. stability_margin (mm) shrinks each contact patch toward its centroid first, so a balance that lives on a patch boundary can be rejected. The older assertNoPairwiseIntersections leaf sweep is deprecated and retained only for compatibility.

assertAssemblySupported(node, gravity=(0, 0, -1), max_drop=1.0, ground=None, supports=None, stability_margin=0.0)

Assert every printed solid below node is held against gravity.

The same topmost rigid solids assertNoSolidInterference compares are placed in world coordinates at the testing instant already selected by the runner. 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, while a part floating in space lands in nothing. Zero-volume boundary contact after the drop is not a hold, exactly as it is not interference. Those relations form a support graph, and every selected solid must reach a grounded solid through it; the failure names every solid that does not.

Reaching ground is not standing up, so a second phase then proves FRICTIONLESS STATIC EQUILIBRIUM: that some distribution of push-only normal forces over the detected contact interfaces balances every non-anchored solid’s weight and the torque it makes about its own centre of mass, all of them at once. Interfaces come from the same displaced intersections, on the supporter’s real undisplaced surface, in both directions – the drop, and a lift against gravity that finds the overhead restraints completing a couple (a cantilevered pin in a snug hole balances on its hole’s lower and upper walls). Lift contacts never add support-graph edges. The failure names every solid that cannot be balanced and whether its force or its torque is what does not close.

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, and for the equilibrium phase that floor is the only anchored body – a top-heavy solid standing on too small a foot fails instead of being exempt for being lowest. ground (a node or a sequence of nodes, each resolved to its selected solid) replaces that default for an assembly anchored somewhere else – hung from a ceiling, bolted to an unmodelled frame: those solids are then the only seeds and the only anchored bodies, and no floor exists.

supports=[(supported, supporter), ...] declares holds this assertion deliberately cannot prove – press fits, glue, friction – keeping the exemption visible in the test. A declared edge grounds the supported solid and transmits an unrestricted wrench between the pair, force and torque in both signs. A declared supporter must still be grounded itself; declaring an edge grounds nothing by itself. A ground or supports entry resolving to no selected solid, a zero gravity vector, a non-positive max_drop and a negative stability_margin are errors.

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 with the centre of mass exactly over a patch boundary passes; a positive margin demands that much interior reserve and rejects it.

Choosing max_drop (mm, default 1.0): 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 the same part reads as floating again. The default sits in the usual window between printed clearances (0.5mm or less) and printed walls (1.2mm or more). The same window bounds the contact patches: a drop that tunnels past a supporting face cannot extract the interface resting on it.

What this assertion claims: support reachability, force balance, torque balance and toppling over the contacts it detects. What it does NOT claim: friction, adhesion, purely lateral (gravity-parallel) wall reactions, the toppling of a single solid on the floor (a lone solid still passes without geometric work), and every dynamic effect. A hold that is real but outside frictionless statics belongs in supports.

assertBlockedBeyond(node, angle, against, axis=None, volume_epsilon=0.0, along=None, directions='both')

Torque-fit / linear-stop engagement contract: perturbed by angle degrees about axis (rotation mode, the default, axis=(0, 0, 1) when omitted) or by angle mm along the unit vector along (translation mode – give one selector or the other, never both), node must intersect against – the fit must genuinely lock beyond its play. See the class comment above for the local-frame semantics shared by both modes.

directions (default ‘both’) checks +angle and -angle separately, and BOTH must foul. ‘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). Any other value is a loud error.

volume_epsilon (mm^3, default 0.0 keeps exact is_empty strictness): when > 0, a perturbation only counts as blocked if the fouling volume exceeds volume_epsilon – so a flush contact that produces boolean noise (see assertNoPairwiseIntersections) never masquerades as a genuine lock in either direction.

assertClose(node1, node2, max_distance)

Require node1-to-node2 distance to be below max_distance.

assertFar(node1, node2, min_distance)

Require node1-to-node2 distance to be above min_distance.

assertFreeWithin(node, angle, against, axis=None, volume_epsilon=0.0, along=None, directions='both')

Anti-gaming twin of assertBlockedBeyond: perturbed by angle degrees about axis (rotation mode, the default) or by angle mm along the unit vector along (translation mode – give one selector or the other, never both), node must NOT intersect against – so a blocking test elsewhere cannot be gamed by an oversized bore/pocket/sleeve that never truly touches. angle accepts a list/tuple in either mode (e.g. a journal/freewheel sweep of angles, or a set of clearance distances), each checked in turn. See the class comment above for the local-frame semantics shared by both modes.

directions (default ‘both’) checks +angle and -angle separately, and NEITHER may foul. ‘forward’ checks only +angle – for contracts that are deliberately one-sided. Any other value is a loud error.

volume_epsilon (mm^3, default 0.0 keeps exact is_empty strictness): when > 0, a perturbation only counts as fouling if its volume exceeds volume_epsilon, so flush contact within the play window (boolean noise, not real engagement) does not wrongly fail this assertion.

assertInside(node1, node2)

Make sure node2 is completely inside node1

assertIntersectVolumeAbove(node1, node2, min_volume)

Make sure the volume of the intersection between node1 and node2 is greater than min_volume.

assertIntersectVolumeBelow(node1, node2, max_volume)

Make sure the volume of the intersection between node1 and node2 is lesser than max_volume.

assertIntersecting(node1, node2)

Make sure node1 and node1 have some intersection

assertJoined(node1, node2, min_weld_volume=0.0)

Assert node1 and node2 fuse into ONE connected body, i.e. that they are genuinely the same printed part.

This is the one legitimate case in which two features must share volume, and it is the exact inverse of the adjacency rule that governs distinct parts. min_weld_volume (mm^3) additionally requires the shared volume welding them to be substantial rather than a numerical lick of contact.

Both nodes must belong to the SAME solid. The comparison runs in that solid’s frame, so two nodes from different solids would each be placed at their own part’s origin – discarding the distance the assembly holds between the parts, and reporting two features that share nothing as welded. Being asked whether two separate parts are one part is a question about the model, not the geometry, so it fails as such rather than being silently answered in the wrong frame.

assertNoDisconnectedSolids(node)

Assert every printed solid in node is one connected body.

Each selected solid is read from its own STL with no placement operations composed. Connectivity is invariant under rigid placement, and rigid descendants are ingredients of the enclosing solid rather than independent parts.

assertNoPairwiseIntersections(node, volume_epsilon=0.0)

Deprecated compatibility assertion over every leaf pair.

New whole-assembly tests should use assertNoSolidInterference. This method retains its historical traversal and verdicts: walk the assembled tree rooted at node down to its leaves (a node with no children is a leaf; every other node’s children are walked recursively) and assert that every pair is non-intersecting.

volume_epsilon (mm^3, default 0.0 keeps exact is_empty strictness): two parts that legitimately abut flush (e.g. shaft segments whose end faces meet exactly) can produce a non-empty boolean of pure float noise – a sliver mesh with volume on the order of 1e-13 mm^3, indistinguishable to is_empty from real interference. When volume_epsilon > 0, an intersection only counts as real interference if its volume exceeds volume_epsilon; a genuine overlap comfortably above the epsilon is still reported.

assertNoSolidInterference(node)

Assert the printed solids below node share no volume.

The topmost rigid nodes are placed in world coordinates at the testing instant already selected by the runner. Empty and zero-volume boundary contact pass; every positive candidate intersection reported by the kernel fails. There is intentionally no public overlap epsilon: manufacturing clearances are length-based project contracts, not a globally permitted volume of interpenetration.

The spatial index is the sole verification path. Positive-volume interference is by definition material shared by SOME two solids, and any such pair has overlapping conservative world bounds – so a complete broad phase reduces the assembly question to the pairs it emits. Triple overlap and full containment are covered by that same argument, not special-cased. Completeness is proved in tests/test_broad_phase_culling.py rather than re-checked here against a whole-assembly volume comparison: that comparison cost time proportional to the assembly’s total triangle count on every passing run, could not name an offending pair, and only ever re-tested framework code that does not change between runs (ADR-040).

assertNotIntersecting(node1, node2)

Test that node1 and node 2 do not intersect

set_node(node)

This sets the “node” property on the test, and also an alias matching the class name, for testing convenience.

class solid_node.test.TestCaseMixin(methodName='runTest')

For convenience, nodes can inherit TestCaseMixin to implement tests together with rendering logic.

solid_node.test.testing_steps(steps, start=0, end=1)

Use this decorator to run the test in several steps of the animation. Use start and end to define the range in that will be divided in those steps.

solid_node.test.testing_instant(instant)

Use this decorator on a test to define a specific instant of the animation that should be used to run the test

Decorators

solid_node.node.decorators.property_as_number(method)

Use this decorator to convert a OpenScad property to a number