This is the synthesis document: the current architecture of solid-node in one place. It sits between two other records and is derived from them:
openspec/specs/— the behavioral contracts: what the system observably does, requirement by requirement.docs/adrs/— the decision log: each ADR is a delta explaining why one piece is the way it is.
ADRs accumulate; this document integrates. When an OpenSpec change is archived and it shifted the structure described here, updating this document is part of landing the change — same rule as the specs.
A solid-node project is a Python program that evaluates to a tree of nodes. Leaves generate solid geometry; internal nodes compose and place it. From that single tree, the framework derives everything else:
your_project.py
│ load_node() (BUILD)
▼
node tree (NODE)
render() → simulate() → validate() → as_scad()
│
┌───────────────┼──────────────────┐
▼ ▼ ▼
.scad files world meshes serialized ops
→ openscad (trimesh / ($t expressions)
→ .stl cache manifold3d) │
(BUILD) (TEST-FRAMEWORK) ▼
│ viewer.json / manifest.json
▼ → solid-node-viewer (separate
dev loop, OpenSCAD snapshot, AGPL package, own process)
export models evaluates $t per frame
Three architectural commitments shape almost every subsystem:
- Geometry follows the strongest backend path available
(ADR-004/044/045/046/047). Every backend still produces SCAD and an STL.
Solid2 and raw
.scadleaves, and faceted fusions containing them, render through OpenSCAD. The OCCT backends — CadQuery and build123d — preserve BREP geometry, and all-exact fusions compose and tessellate in OCCT without OpenSCAD, whichever of the two produced each child. JSCAD produces its STL through its ownjscadtool, and a flexible part through molejo's evaluators — mesh, STL and B-rep from one analytic spec. OpenSCAD is therefore conditional on the paths that invoke it, not a universal framework prerequisite. The same rule governs themanifold3dmesh engine (ADR-052): it decides faceted geometry, so it is required by comparisons involving a part without exact geometry and byassertAssemblySupported, whose statics phase is faceted for every body — and by nothing else. Both are resolved once per process at the point of use and report by name when absent. - The build artifact is the currency, mtime is its clock
(ADR-006/026/033/050/081). STLs are cached per parameter-hashed identity
and validated by mtime equality against the max source mtime, in integer
nanoseconds — never as a float, which cannot survive the
os.utimeround trip off a filesystem coarser than a nanosecond (ADR-050). That equality is guarded by a recorded metadata fingerprint of every tracked contributor, so a future-dated source cannot hide an edit to an older one (ADR-081). Base-mesh and Manifold caches key the path together with its strong observable identity — device, inode, size, integer-nanosecond mtime and ctime — so a same-size restored-mtime replacement cannot serve old faceted geometry (ADR-085). Artifact freshness remains the one invalidation concept the whole system shares. The source set behind that clock is a node's own file plus the project-local modules it imports, transitively (ADR-033), so a contributing module edit invalidates the nodes that read it — and only those. Beneath the clock, content decides: when the stamp or source fingerprint moved, a digest of what the node can see of its sources — its own file minus the sibling node classes it never names (ADR-071) — is what says whether the artifact is still the one those sources produce. - One kinematic truth, recomputed absolutely, consumed everywhere (ADR-023/027/028). A node's placement is its operation list. Every consumer — SCAD output, world-space meshes for assertions, the two browser renderers — folds that same list, own-ops-first then ancestors, later operations outermost. Nothing tracks incremental mutations; every consumer recomputes pose from declared state, which is what makes re-renders idempotent and caching safe.
AbstractBaseNode roots a composite tree (ADR-001): InternalNode
subclasses return child lists from render(), LeafNode subclasses
return one geometry object, and validation enforces the split on every
assembly. Users implement render(); the framework owns the
non-overridable assemble() pipeline — render → validate → as_scad
→ SCAD generation → optimized STL import → apply operations — memoized
per instance (ADR-002).
A node is authored in one of two forms, freely mixed in one tree. The
constructor form builds children in __init__ and forwards
parameters to super().__init__(). The declarative form states
them in the class body: typed parameters (Length, Angle, Count,
Ratio, Flag, Scalar), derived parameters as bare formulas over
them, and children as calls — because every node class carries
NodeMeta, a call inside a node class body yields a ChildDeclaration
rather than an instance, recognized by the body's marked namespace on
the stack (ADR-061). Each parent instance realizes its own children
last in __init__, in declaration order, into its instance dict under
the declaring attribute, so naming, qualification and the serialized
document see the tree they always saw; a literal list declares
enumerated children and repeat(count) count-many identical ones,
named <attr>-<index> without renumbering. Tokens, a Flag among them, pass to children by
reference and resolve top-down from the root's values, so
Engine(bore=32.0) moves the whole machine and --set bore=32.0 on any
node-loading command does the same from the shell. A parameter is a
data descriptor: the token on the class, a plain float, int or
bool on the instance, assignment refused, base attributes protected
from shadowing; a declaration without a default fails at
instantiation, never at class definition, when neither the parent nor
the caller supplies it. A guard over several parameters at once is the
node's check(), called once its parameters are resolved and before any
child is realized; an exception refuses the instance (ADR-065). A formula's dimension is a mapping from axis to
exponent — products add, quotients subtract, sums require equality,
Angle its own axis, Count and Ratio dimensionless — checked on
import, with solid_node.math's emitted primitives carrying their own
rules and its compositions inheriting theirs, and a project free to
subclass Quantity with new exponents (ADR-062). By the time any render() runs every parameter is a plain
value; backends, uniq_id and the serializer never see a token. Reading
a parameter off a sibling declaration is refused: shared values are
declared on the ancestor and passed down.
The public surface is split by concern above the node package, so an
import line says what each name is for: build parameters come from
solid_node/parameters.py, node classes from solid_node/node/,
runtime inputs from solid_node/simulation/, the test case from
solid_node/test.py (ADR-062, amended). The parameter module holds the
declaration descriptor every other declaration follows, the exponent
algebra, the kinds and the parameter enumerator, and imports nothing at
all — not the framework, not a third party — because it is on the
import path of every node module in every project.
solid_node/node/declarative.py keeps the structural half, the child
declarations and NodeMeta, and imports the parameter module; the node
package exports no parameter kind, and solid_node/math.py reaches the
formula algebra sideways rather than down into the node package.
Automatic child names come from one parent-attribute snapshot per traversal.
Public direct attributes win in insertion order, then the first public
list/tuple membership supplies <attr>-<index>; private attributes and the
framework's linked children list never name. Every returned sibling is
linked from that common snapshot before recursion, so lookup is constant-time
per child and user code in an earlier child cannot rename a later sibling
mid-traversal. Reassignment, replacement and even same-length list reordering
are visible when the next traversal takes its new snapshot. An explicit
name= still wins and every link refreshes the parent.
On a declarative internal node render() places at rest and selects
and may return nothing, in which case the framework's render wrapper —
the same __init_subclass__ hook that installs the lifecycle —
substitutes the realized declared children in declaration order minus
those omit() marked; a returned list keeps its contract untouched,
and a class with nothing to position needs no render() at all
(ADR-064). Declaration is independent of realized count: a zero repeat or
omission of every child substitutes [], which an assembly carries through
its ordinary lifecycle and serializes explicitly as children: []
(ADR-082). An omitted child is not linked, built, exported, fused or
serialized. Structure varies with parameters, never with time: omit()
raises in simulate(), and on the legacy path the wrapper records the
omitted set of an instance's first render and raises on a later render
whose set differs. The reference's rename of render() is dropped
(ADR-066): render also means to make.
Two concrete internal nodes encode the rigid/non-rigid axis
(ADR-003): FusionNode (rigid union, no time) and AssemblyNode
(non-rigid, animatable). Rigidity is static and determined by node type;
a fusion rejects any non-rigid child during validation, enforcing "fuse
solids, then assemble them" (ADR-039). An empty assembly is valid grouping
and produces no STL of its own; an empty fusion is rejected before artifact
generation because a rigid fusion must denote at least one solid (ADR-082).
Only rigid nodes produce STLs, which
is why cached geometry must be time-invariant. A topmost rigid node is the
first rigid node on a branch below an assembly, or a rigid root itself; its
STL is the complete printed solid for that branch.
The axis has three cases, not two, because leaf and rigid are not
the same word (ADR-057). Beside the rigid leaves and the non-rigid
assembly stands one non-rigid leaf kind, FlexibleNode: a part whose
shape follows machine state. It composes with the rules above rather than
relaxing them — a fusion rejects it as it rejects any non-rigid child, it
enters no cached-artifact set so the time-invariance precondition is
untouched, it is never a topmost rigid node and contributes no printed
piece, and time raises on it as on any leaf.
Leaf adapters (ADR-004) wrap the backends: Solid2Node,
CadQueryNode and Build123dNode (both export to STL and re-import),
OpenScadNode (scad_source + module call), JScadNode (shells out to the
jscad CLI), StlNode (stl_source, a mesh materialized with
no backend at all), and StepNode (step_source + part, a STEP
document's own kernel writing its artifacts). Every node exposes derived read-only exactness (ADR-044): the
OCCT adapters are exact, the other leaf adapters are faceted, and an
internal node is exact only when every child is. Exactness does not require
one backend — every exact adapter converts its render result to one shared
OCCT shape at the adapter boundary, so the exact layer holds a single type and
a fusion may mix CadQuery and build123d children (ADR-047). Because that
conversion makes everything after it backend-neutral, the contract itself —
exact, shape(), as_scad() — lives once on ExactLeafNode, the internal
base every exact adapter extends; each supplies only its namespace and any
validation its own API needs. Adapters remain distinct types regardless of the
bases they share. Because build123d
groups solids, sketches and curves under one namespace, Build123dNode
additionally rejects a render result that is not a solid, and accepts a
BuildPart builder by taking its finished .part.
One leaf kind has no modelling backend at all. StlNode is a part that
arrives as an STL mesh: it declares stl_source beside its wrapper
module, resolves and tracks it like JScadNode does its .js, and
materializes its own artifact from it inside as_scad() — selected body,
adjust correction, binary export, stamped with the source mtime, so no
external tool runs for the leaf at all (ADR-054). Three rules make the import
honest rather than credulous. A mesh that is not watertight is refused at
materialization, naming the file and the defect and writing nothing, unless
the node declares require_watertight = False; nothing is ever auto-repaired.
A multi-body file is a pack of parts, one of which the node selects by body,
a 0-based index into the components ordered by centroid (x, then y, then z),
with the failure of an unselected pack carrying the full inventory. And
normalization is code — an adjust(self, mesh) hook over the trimesh — not
constructor knobs. Because the wrapper module carries body and adjust, it
joins the node's tracked source set (ADR-055) — a rule StepNode below
also needs and for the same reason.
StlNode is faceted: exact is false, mesh-only is settled doctrine for
imported meshes, and a fusion containing one is faceted and unions through the
OpenSCAD/CGAL path (ADR-045).
A second external-file leaf reads the other file every vendor
publishes, and it is exact rather than faceted. StepNode declares
step_source beside its wrapper module exactly as StlNode declares
stl_source, and derives ExactLeafNode directly rather than writing
its own as_scad(): a STEP product is a boundary representation the
moment it is read, so shape(), the .brep, exact fusion and declared
tessellation precision (ADR-077) all come from that base whole, and no
external tool of any kind produces its artifacts. part selects one
product out of the document by name — the candidates being every
top-level XCAF label except a root that is itself an assembly, so a
bare single-part file and the commoner file wrapping one part in an
assembly root both select themselves, while a multi-component root is
never handed to a node by omission. A wrong or missing selection fails
with the document's own inventory — name, kind, occurrence count, solid
count, bounding box, volume, one line per product — the failure again
the discovery tool. The selected shape is always the product's own
frame, never an occurrence's placed copy; adjust(self, shape)
corrects it, and a shape holding no solid after adjust fails
admission naming what it does hold, with the explicit
solids_from_faces(shape, tolerance) helper the only sanctioned way to
turn a face-only vendor part into one. A subclass that declares no
color takes it from the document's own surface colour (converted from
XCAF's linear RGB to the framework's sRGB #RRGGBB) through a property
resolved lazily, so a build whose artifacts are current never opens the
file for it. The XCAF document itself is read and transferred at most once per
strong source observation per process. The read is bracketed and registered
with the active source generation, so replacement during transfer cannot enter
the cache or leave constructor geometry tied to another epoch. The cache uses
the source census's path/device/inode/size/mtime/ctime identity because the
read is expensive (11.79 s
measured on a 35 MB vendor assembly) and a project may hold many leaves
over one file (ADR-078).
The other half of a STEP document — where each product sits — is a
reader, not a node: StepAssembly(path), in the same adapter module,
walks every occurrence through nested sub-assemblies over the identical
cached document, reporting each one's placement matrix, its world
matrix composed outward through its parents, and, for a proper rigid
placement, the exact (angle, axis)/translation pair that reproduces
it through the framework's own rotate then translate — decomposed
through OCCT's own quaternion (gp_Trsf.GetRotation(),
GetVectorAndAngle) rather than the trace and an acos, which is not
accurate enough at a 180° turn. A placement that is not proper — a
mirror or a scale, neither of which rotate/translate can state — is
reported with its determinant and scale factor and left undecomposed;
the rest of the document is unaffected. The CLI command solid import-step turns this reader into project-owned source in one shot:
parts.py (one StepNode subclass per part) and assembly.py (one
AssemblyNode per assembly product, its children placed at the
document's own transforms, machine at rest — no driver, no
simulate()), never overwriting existing source and never touching
pyproject.toml (ADR-079).
One leaf kind names a manufacturing method rather than a backend.
SheetLeafNode — internal base, Build123dSheetNode its v1 adapter — is a
part cut from sheet stock, authored as a 2D profile() plus a declared
thickness. The base owns render(), which validates the profile and
extrudes it from the XY plane along +Z, so the solid in the tree and the file
a cutter consumes derive from one authored thing and cannot drift apart;
profile() is the only extension point (ADR-053). The profile contract is
the base's: exactly one planar face, one outer boundary with holes strictly
inside, on the XY plane, rejected naming the node and the offending type
before anything is written. thickness is required and positive at
construction, and as a constructor argument it keys artifacts like any other
parameter. The extrusion is an ordinary backend solid, so a sheet part is an
exact leaf in every respect above — exact.py needed no change, and a fusion
may mix a sheet part with any other exact child.
One leaf kind carries no solid at all. FlexibleNode — internal base,
MolejoNode its v1 adapter — is a part whose geometry, not merely whose
placement, is a function of machine state: a valve spring, a belt, a loom
(ADR-057). Its per-instant parameters arrive through declared ports,
connected by the parent assembly's connect() — ADR-056's guardrail
extended from pose is a pure function of the driver snapshot to shape
is. Constructor arguments stay structural, because every one of them
enters uniq_id (ADR-026) and a value that follows a cam would otherwise
mint an artifact identity per frame; uniq_id therefore stays structural
and two instances of one flexible class share it. The port name is the
parameter name, and the two name sets are checked in both directions
immediately after render(), an unbound port failing loudly rather than
defaulting. MolejoNode.render() returns a molejo Shape, validated by
the ordinary namespace mechanism: the render contract is the backend's
object, as for every other adapter. It is exact by type, shape()
evaluating the bound instant through molejo's B-rep evaluator and
recasting it into ADR-047's one currency, with the backend's declared
approximation surfaced as shape_tolerance (1e-6 for a helix or spline
sweep, 0.0 where every surface is analytic) rather than hidden. Nothing
of that geometry is persisted: shape() computes on demand behind an
in-memory memo keyed on the binding, because (path, mtime) keying is
currency for a source and never for a binding.
Exact nodes expose unplaced BREP geometry through
shape(); placement remains the caller's responsibility through the same
composed matrices as the mesh path. An exact FusionNode fuses its placed
children in OCCT and represents that fuse in both BREP and STL (ADR-045).
Each adapter still emits SCAD, but artifact production follows its backend:
Solid2 and raw OpenSCAD leaves use OpenSCAD, CadQuery and build123d — sheet
parts included — use OCCT, JSCAD uses jscad, a flexible leaf uses molejo's
Python evaluator, and an imported mesh uses no
tool whatsoever (ADR-046). Emitting SCAD
does not itself require the OpenSCAD binary. A sheet leaf writes one artifact
the others do not: a nominal DXF of its profile, in millimeters with arcs
preserved, beside its .stl and .brep and under the same freshness rules,
which its skip guard also requires. A flexible leaf writes another: a
per-binding snapshot STL, so the assembled SCAD document stays complete for
the OpenSCAD GUI — a snapshot camera, never animation, the treatment
drivers already receive. The camera declines where there is no instant to
photograph: a port fed by animation time, which nothing binds on this path
(ADR-008), yields no artifact and no geometry rather than failing the build
or inventing a moment. An unbound port, or one still carrying a raw driver
token the loader should have bound, is a wiring mistake and still fails
loudly (ADR-057).
Identity is split three ways. uniq_id (class qualname + canonicalized
params, 12-hex sha256, readable prefix) keys build artifacts —
parameters change, artifacts change; name (explicit or derived from
the parent attribute holding the child) addresses the tree for tests
and the viewer, and never touches geometry (ADR-026). On a declarative
class the params are the resolved, coerced declared values sorted by
name, computed by the framework through the same serialization, so a
class that forwarded everything keeps its key and no keyword can be
forgotten; repeated identical units share one key and one artifact
(ADR-063). A piece id
(12-hex sha256 of the built STL's bytes) identifies one thing to print,
so solids factored into different classes but building identical geometry
are one piece, while handed variants are two (ADR-043). Each answers a
different question — rebuild needed, addressed how, same thing to print —
and conflating any two produces silently wrong answers. A flexible leaf's
snapshot artifact adds a fourth key beside — never inside — uniq_id: a
binding_hash of its resolved parameter values, which is what makes a
changed state a different file rather than a rewrite of one whose mtime
already claims it is current (ADR-057).
Transforms are first-class operation objects (ADR-023):
Rotation/Translation render for four consumers — .scad(),
.mesh(), .serialized, .matrix() (ADR-028) — plus .reversed.
AssemblyNode is the only animatable node, and it binds a
multi-driver state snapshot (ADR-056 stage 1): set_state(**states)
merges named plain-number driver values and propagates down the
rendered tree. render() reads a driver it declares as an
attribute — x = Driver(...) is read self.x (ADR-056, amended
2026-08-27) — which is
the only read there is; the snapshot has no mapping view. An unbound
read fails loudly naming set_state, assigning to a driver fails the
same way, and a driver declared over a name the node class already
carries fails at class-definition time. Every bound name must name a
declared driver, bare or qualified, because nothing could read an
entry with no declaration behind it; time is the exception, one
snapshot entry with the ADR-008 fallback to symbolic OpenSCAD $t
(0..1) when unbound, read through the time property. What that
entry means is the root's to declare (ADR-072): a root class may
carry time = Time(loop=<seconds>) — a frozen data descriptor bound to
the name time, refused under any other name or on a non-assembly,
readable off the class as Root.time.loop — and then self.time reads
seconds on every path: unbound, the symbolic product $t * loop, so
$t stays the 0..1 slider and published expressions carry the
multiplication; bound, whatever the binder stated in seconds
(set_keyframe, the testing decorators, Sim's k*dt). One reader in
solid_node/node/assembly.py (read_time) serves the base property and
the descriptor: bound entry first, else walk _parent to the root of the
linked tree and scale by its declaration — a descendant reads what its
root reads with no state propagated, and a declaration on a node strictly
below the root is refused at the read naming both nodes. The walk relies
on the link every walker makes before recursing; a bare render() links
nothing, by contract, so a child rendered by hand before any walker
reached it is its own root for that read. An undeclared root is
unchanged. set_keyframe(t)/clear_keyframe() are the preserved
time-only surface — exactly set_state(time=t)/clear_state('time').
Clearing is reversible by re-render: an operation records whatever
value render() computed, so a bound tree has no symbolic form left
to recover until it re-renders (ADR-051).
An entry is addressed to the whole tree or to one instance in it
(ADR-056 stage 3a). A qualified driver id is the dotted path of
linked child names from the addressing root plus the class-local
driver name — x_axis.motor; a root-declared driver keeps its bare
name. set_state(**{'x_axis.motor': 8000}) reaches only that
instance's subtree, stripping the consumed segment as it descends, so
two instances of one class hold independent values for their
same-named driver. time remains the one global entry and propagates
flat. A bare project-driver name stays valid while exactly one
declared driver in the tree bears it; when two do, binding fails
naming both qualified ids rather than silently giving them one value.
The propagation walk links each child before recursing, exactly as the
scad and serializer passes do, because a name is derived by the parent
and an unlinked node has none. Qualification never falls back and never
sanitizes: a driver reachable only through an unlinked node, or through
a list-held child's <attr>-<index> name (a legal node name, an
illegal expression identifier), raises. solid_node/node/qualified.py
owns the id, the linked walk, and DriverToken — an OpenSCADConstant
subclass whose string is the qualified id, so ordinary solid2
arithmetic and solid_node.math's degree trig build the wire
expression with no new operators. It also carries DriverDeclaration,
which is both the marker the node layer needs to recognize a
declaration and the data descriptor that hands its bound value back
(ADR-056 amendment) — the same responsibility over the same _states dict, and
the same shape Port already had. What a driver means stays in the
simulation layer, which subclasses it.
An assembly's lifecycle is render() at rest, simulate() per
instant (ADR-066). render() declares structure and places what does
not move, reads no driver, time or port, and runs once per instance;
simulate() — a no-op in the base — is run by the framework after it
on every enumeration of the children, under the current binding, and
is where drivers, self.time and ports are read. Both run inside the
wrapper __init_subclass__ installs (_lifecycle_render), so every
walker still calls render() and gets a tree posed for the binding.
Operations applied in simulate() are motion: inserted at the head
of the node's single operations list, before every rest placement, so
composition in list order puts motion inside placement; they are tagged
with the simulating assembly (animator-tagged idempotency, ADR-023;
tag renamed from "driver" so that word can mean a simulation input) and
each run sweeps only its own tags before re-expressing pose absolutely.
Rest placement (untagged) survives; independent animators of one node
don't disturb each other. The phase stack lives in
solid_node/node/phase.py; DriverDeclaration.__get__,
AssemblyNode.time and BoundPort.value report a read to the innermost
render phase, and a render() whose first run read one keeps the
previous behaviour — re-run, tagged, swept — and warns once per class
with a FutureWarning. omit() in simulate() raises. Leaves never
simulate; a flexible leaf renders from its bound ports when the tree is
walked.
Ports (spec ports) are domain-typed connection points declared
as class attributes (RotationalPort, TranslationalPort,
SignalPort): stateless declarations carrying domain, unit, direction,
and an optional design-units-per-native-unit scale, discoverable off
the class via declared_ports(), with per-instance value slots
materialized by descriptor. connect(source, sink) on internal nodes
is causal, immediate, per-render rebinding — value flows one way,
scaled by the sink; no flow variable exists yet (the bond-graph
extension ADR-056 reserves).
World pose is one composed 4×4 matrix — own operations then ancestors, premultiplied (ADR-028) — recomputed on every access because operation values can be animated expressions and the operations list is mutated by design. The base mesh under it is cached per strong artifact observation, and each caller still receives a mutable copy (ADR-085, amending ADR-028).
solid_node/simulation/ is the layer that produces driver
snapshots (ADR-056 stage 2); solid_node/node/ never imports it, so
a node without drivers pulls none of it in. Driver is a frozen
class-attribute declaration on an assembly (default, range, unit,
optional dtype=int for discrete devices, optional scale in design
units per native unit), discovered off the class MRO exactly like
declared_ports and read off an instance exactly like a port
(ADR-056 amendment); all mutable state — current value, active program —
lives in the per-simulation bank a Sim builds, so two simulations
share nothing and there is nothing to reset. RampProgram advances
state as a pure function of the tick (start + delta*k//n for
integer drivers — integer-exact, exact landing), which is what makes
two runs of one scenario comparable with ==. Instruction records
design-unit targets plus a duration; conversion to native state
happens once, at trigger time, through the driver's declared scale.
simulation/enumeration.py is the one authority on what drivers a
machine has (ADR-056 stage 3a). declared_drivers(cls) reads a single
class, which stops being enough the moment a machine is built out of
mechanisms — a printer's drivers live on its axes and its root may
declare none. qualified_drivers(root) walks the linked tree and
returns {qualified_id: Driver}, binding each declaration's own
default as it descends because finding children means rendering.
Everything that names a driver reads it: the Sim bank,
instruction-target resolution, the loader's opening snapshot, and the
serialized document's driver table — so the id in the document and the
key in the bank are the same string by construction rather than by two
implementations agreeing. qualified_instructions(root) does the same
for instructions, which are declared with class-local target names and
qualify by their declaring node's path.
Sim is the fixed-dt loop: instants become integer tick counts the
moment they are stated (rejected if not whole — the ADR-050 reasoning
applied to simulated time). Public time values are finite real seconds,
excluding booleans; dt is strictly positive, while run and instruction
durations are nonnegative (ADR-083). Those boundaries are validated before
state changes. Absolute at(t) scheduling rejects a tick already passed but
accepts the current tick, whose deferred actions can fire through run(0).
A zero-duration instruction settles and binds its complete target snapshot at
the current tick without adding a trajectory entry. Construction enumerates
the whole linked tree and binds every declared default through set_state by
qualified id before the first render, so a driverless root with driver-declaring
children simulates. The bank, trajectory, programs, and instruction
targets all key by qualified id, and trigger('x_axis.Home') ramps
only that instance. Each tick advances programs and binds the full
snapshot together with the global time entry set to the exact
instant k*dt in seconds, computed from the integer tick count
and never accumulated — so under a simulation self.time reads the
stepped clock, while the normalized 0..1 $t animation path outside
simulations is untouched. Then it records the trajectory and runs
deferred at(t) actions (.trigger(name), .run(fn)) and cadence
every() slots, each accounting its own cost — ticks are free,
cadence budgets assertion cost. ScenarioTest composes over the CAD
TestCase: one class runs unchanged under pytest and the solid test runner, building STLs only when meshes = True.
Known stage boundaries (ADR-056 stage 3c+ territory): range is
declarative metadata, not a clamp; Driver.scale and Port.scale
remain two declarations; a driver on a list-held child is forbidden
rather than sanitized; the viewer's trigger runs one instruction's
ramps and nothing sequences them — programs and G-code are a later
layer, and determinism belongs to Sim, not to the client animation.
Nodes are addressed by reference — a declared model name, a qualifier
(package.module:Class), a filesystem path, or a path plus class —
dynamically imported and resolved against a project root discovered
from the nearest ancestor pyproject.toml carrying [tool.solid-node]
(ADR-005, superseded by project-manifest-node-references). A bare path
to a file defining several node classes must name the one meant in the
reference; implicit discovery remains limited to classes defined in the
loaded file. Artifacts remain keyed to the selected class's real
implementation source. The source set tracks that implementation/import
closure, so an edit to it invalidates and reloads the active node.
A project has one model, model = "package.module:Class", or several
declared by name in [tool.solid-node.models], with model then naming
the default by its key (ADR-073). A name is one word, so it is never
mistaken for a qualifier or a path, and it may not equal a directory at
the project root. The build root is $SOLID_BUILD_DIR (default
_build, resolved against the discovered project root rather than the
working directory). A single model builds in the root itself; a declared
model owns <build root>/<name>/, with its own published document,
errors file, lock and sweep, so publishing one model never disturbs
another. A sub-node reference builds in the root, whose sweep does not
descend into a model's directory. Every command selects before it loads:
the selection turns a name, or the default, into the concrete reference
and the directory it owns, anchored for the process and inherited by the
fresh interpreters it starts. Within a build directory artifacts mirror
the source layout, basename <script>-<uniq_id>.
Loading a node also binds its declared driver defaults across the
tree by qualified id, before the first render (ADR-056 stage 3a), so a
driver-declaring project builds, tests, and serves through the CLI
without restating its declarations in __init__. A tree that declares
no driver is left strictly alone — not bound, not even walked, since
the walk renders — so a driverless project loads exactly as it always
did. The binding lives in the loader, which may import
solid_node/simulation/; the node layer never does, and a hook there
for the simulation layer to register into would hide that dependency
rather than place it.
Project-local source execution belongs to one request-local source
generation (ADR-084). The loader observes and coherently reads the selected
entry/facade and every project module before executing its bytes, then requires
an uncached post-load identity match; it does not trust timestamp-and-size
.pyc validity. Late imports join the same handshake while external packages
keep Python's ordinary import behavior. Artifact-producing phases share one
distinct-path source census internally and compare fresh uncached observations
at their boundaries. Assembly seals the recursively discovered Python and
foreign SCAD/JS/STL/STEP contributors; later retained passes, asynchronous
renderer waits and publication must still match that generation. A missing,
replaced, retargeted, newly selected or changed contributor returns
SOURCE_CHANGED before a stale document can be published.
STL generation is normally asynchronous: StlRenderStart carries a spawned
openscad process, PID lock files guard concurrency, and
build_stls() loops until nothing is stale. The metadata-only currency path is
artifact mtime equality plus source-set fingerprint equality — generated
files are back-dated with os.utime to the max source mtime (ADR-006), and a
sidecar records path, filesystem identity, size, mtime, and change time for
every member of node.files (ADR-081): the node's own source
plus its project-local import closure, unioned upward from children
(ADR-033) — and, for an imported mesh, the closure of the wrapper module
that declares it as well, since the mesh file has no imports of its own to
walk and the wrapper is where its geometry-affecting code lives (ADR-055).
Both sides of that equality are integer nanoseconds
(st_mtime_ns, os.utime(ns=…)), so the back-date is a fixed point at
whatever resolution the filesystem stores — a float stamp is not, and on a
millisecond-resolution filesystem it left every artifact permanently stale
and this loop non-terminating (ADR-050).
A timestamp or fingerprint mismatch invokes the node-scoped content digest (ADR-060/071). Equal content restamps the artifact and refreshes the sidecar without rendering, which preserves cheap clone, checkout, relocation, and sibling-only changes. A legacy digest-only sidecar follows this path once and upgrades in place. Settled checks stat tracked files and read the sidecar, but never read source contents or parse Python.
OpenSCAD availability is resolved once per process, at the first operation that actually requires it (ADR-046). Mesh-backend STL rendering, faceted fusion, Solid2 symbolic-value evaluation, the OpenSCAD GUI viewer, and the OpenSCAD snapshot renderer are the complete requiring set. A missing binary raises one actionable error naming the operation and remedy before subprocess launch; an all-exact build never performs the check.
A rigid, optimizing leaf whose artifacts are current assembles by
importing its STL — render() and as_scad() never run (ADR-033), so
the check happens before the expensive work rather than after it.
Internal nodes always render: their file set is the union of their
children's and is only known by walking them. The adapters that write
their artifact inside as_scad() — CadQuery, build123d, sheet, JSCAD — carry
the same guard, for nodes that opt out of optimization. A flexible leaf
carries it per binding: mtime equality decides source currency within one
binding exactly as elsewhere, and a different binding is a different file
rather than a question mtime is asked and cannot answer (ADR-057).
solid build and the watchdog-driven development loop start one builder per
sealed source generation (ADR-084, amending ADR-067). Every child still starts
from a fresh interpreter, never a fork of the command process: parent model
resolution may initialize OCCT's OpenMP team, whose bookkeeping a fork would
inherit without its threads and then deadlock on parallel tessellation. A fresh
child receives a module-level target and plain reconstructable values. Within
one stable generation it retains the same loaded root and complete assembly
across all sequential artifact passes; source change or failure ends reuse of
that tree, so the next build attempt again has fresh native and module state.
A failed development reload writes errors.json, releases the build lock and
performs no more geometry while that same child remains solely as a recovery
watch over known source locations and the broad Python area; repair ends it
SOURCE_CHANGED. Candidate
builds publish viewer.json with the versioned solid-node-export tree
schema, linked node names, per-node mtime, and build-root-relative model
paths, so private NodeAPI consumers can serve a completed build without
loading project Python (ADR-031/034). Sharing that schema marker with export
does not make a build publication portable: it copies no meshes and retains
its private viewer.json document boundary.
SCAD publication distinguishes time-invariant and state-dependent producers (ADR-086). Rigid base SCAD may be reused immediately only when the canonical path's currently published full timestamp/digest/fingerprint identity matches. Every non-rigid, non-flexible instance still renders and composes its own model, but its assembly phase keeps only the immutable final desired text/stamp/digest/fingerprint per canonical path, ordered by each path's last occurrence. After a fresh pre-flush check it compares and atomically publishes those final values under the active census and project lock, then checks the source generation again. Body/pre-flush failure discards them; flush error uses the ordinary assembly failure path. Flexible bindings and direct calls outside the assembly phase remain immediate.
Artifacts write directly into one ordinary build directory. Each artifact is
written to a temporary sibling and replaced with os.replace; OpenSCAD renders
to a temporary STL and publishes it only on completion. viewer.json is the
manifest and is written last, so it never names a partial artifact; a later
sweep removes files it no longer names. A build whose artifacts are all current
still republishes that manifest when it no longer matches the model, since the
pass that renders an artifact exits before writing the document. The project
lock serializes builders, while readers remain lock-free. This intentionally
permits a mixed model during a build and a failed build can leave partial new
work, but no reader sees a torn file (ADR-038, reversing ADR-030 and
superseding ADR-032). Errors go to an atomically written errors.json in the
build dir — file-based IPC, no broker
(ADR-018). A broken initial build kills develop; a broken reload waits outside
the lock for a relevant repair, then exits SOURCE_CHANGED so the supervisor
starts a fresh child and source generation.
An exact rigid node has a private .brep beside its .stl (ADR-044). Both
must match the node mtime for the build to be current; the BREP is spared by
the artifact sweep but is never named in a viewer or export document. That
freshness rule is why a flexible leaf persists no exact geometry at all: the
.brep requirement is scoped to nodes that are both rigid and exact, the
sweep spares .brep files unconditionally by extension so per-binding ones
could never be collected, and nothing reads them anyway — the exact
composition path fuses the shapes children return, and a fusion refuses a
flexible child (ADR-057). An
all-exact fusion is the exception to the subprocess protocol: it writes its
BREP and tessellates its fused shape synchronously in process (ADR-045).
Every exact artifact's STL, a leaf's or a fusion's, is written without the
zero-area triangles OCCT's mesher emits, so the mesh engine's edge pairing
sees only the surface (ADR-074). The tessellation itself is at whatever
linear_deflection (mm) and angular_deflection (rad) an ExactLeafNode
or FusionNode subclass declares as a class attribute — 0.1 and 0.1,
the framework's historical values, when it declares neither — read and
validated once, immediately before each write (ADR-077). Precision is
not artifact identity and rides the node's own source set instead
(ADR-071), so redeclaring it rewrites the same .stl in place; the
.brep and shape() are unaffected, and a fusion's declaration shapes
only its own fused solid, never a child's.
The artifact sweep learns one thing from the tree rather than from the
document. A flexible leaf's snapshot is addressed by its binding, and the
published document is symbolic — it describes the machine, not the pose —
so it cannot name the file the assembled SCAD imported. The assembled tree
can, and it is the same tree that publication describes, so each flexible
node's snapshot_file joins the referenced set and every other binding's
snapshot is swept. A build whose only change is the binding writes no new
document and therefore runs no sweep, so one superseded snapshot survives
until the next document-changing build (ADR-057).
Publication enforces build mechanics and model validity, not project-selected geometry contracts. It therefore does not count STL components or invoke whole-solid connectivity assertions. The incomplete-render guard remains: a manifest may not name a rigid artifact that has not been written, independently of any geometric test (ADR-039, amended 2026-08-10).
solid <command> <path> — command-first grammar since 0.4, with an
exit-2 migration guard for the old order (ADR-024). Commands are a
duck-typed registry naming where each lives: build, develop, test,
snapshot, new (offline scaffold), export, viewer, models.
models lists a project's models from the manifest and their build
directories alone — unbuilt, published or failed — as text or
--json, importing no project code; build --all and test --all walk
every declared model in order and never stop at a failing one (ADR-073).
Every command
that loads a node takes --set name=value, registered once beside the
shared reference positional: the loader parses each value by the root's
declared kind and constructs the root with the overrides, the develop
loop carries them into every builder it starts, and an unknown or derived
name fails listing what is settable (ADR-062). Only the invoked
command's module is imported, and the node and simulation packages resolve
their exports on first access, so a command pays for the backends it uses and
not for the rest (ADR-059) — solid viewer answers from the viewer's entry
point alone. Top-level -h is the exception: it renders every command's
docstring, so it loads them all. The test framework defers the same way by a
different mechanism (ADR-069): a module's own call sites are global reads,
which PEP 562 never sees, so solid_node.test binds its seven
solid_node.exact names to deferred callables that import on first call and
replace themselves unless patched. A faceted-only project runs its whole suite
without importing cadquery. Snapshot has an explicit renderer choice
(ADR-021/041/046/068): OpenSCAD remains the external-tool default with xvfb
fallback, whether or not the browser viewer is installed, while the
optional web renderer stages the node and has the installed viewer package
photograph it in sandboxed headless Chromium to produce a true-alpha PNG.
Unsupported renderer-specific options are rejected rather than ignored or
substituted. If the default OpenSCAD renderer is unavailable, the command
names --renderer web but does not select it silently; if the viewer is
not installed, --renderer web names the viewer extra. ./.env is read with
setdefault semantics (real environment wins), carrying
SOLID_NODE_PORT / SOLID_NODE_FRONTEND_PORT / SOLID_BUILD_DIR.
Test-driven CAD is the framework's reason to exist: contracts about
geometry, checked on the real meshes. Tests live in companion files or
on the node via TestCaseMixin (ADR-010), run by solid test — which
builds first, then runs test_ methods per declared animation instant
(@testing_instant / @testing_steps, ADR-011) with operation
checkpoints restored between instants.
Collision assertions (ADR-009/044) select the strongest shared representation
the run allows: intersection-volume and connectivity questions use placed
OCCT shapes when both operands are exact and retain trimesh/Manifold for
mixed or faceted pairs. Which kernel a run compares on is the run's property,
not the model's (ADR-073): solid test resolves one comparison policy —
--exact/--faceted, else SOLID_TEST_KERNEL from the project's ignored
.env, else exact — and a faceted run answers every one of those questions
on meshes, reads no shape(), applies one run-wide volume epsilon to every
engine verdict after the memo is read, and labels itself before the first
build and on its summary line. The exact run is unchanged, node.exact
still reports the geometry's capability, and the build does not depend on
the kernel. That selection reaches the placement step too (ADR-052): a solid is
placed into the spatial index from its cached bounds alone, and its Manifold
is built only when a comparison really reads it, so an all-exact assembly
builds none and needs no mesh engine. Distance and containment assertions
remain mesh-sampled. This includes the
paired kinematic fit contract (ADR-025): assertBlockedBeyond +
assertFreeWithin perturb a part along its working degree of freedom
(rotational axis= or translational along=, injected before every
operation of the node so the direction is the node's own and all of its
rotations carry it, ADR-075; always restored) — fit is certified only by the
pair. volume_epsilon separates real interference from boolean noise,
with a deliberately strict default: a flush contact that is non-empty
at exactly 0.0 mm³ is a foul until the test opts into an epsilon.
The shared intersection path (ADR-029/044) caches one Manifold per strong
artifact observation — canonical path, device, inode, size, nanosecond mtime
and change time — built at the first faceted read and judged there by
the engine's own status() (ADR-074): a mesh Manifold refuses raises by
file name with the engine's reason, a mesh trimesh doubts and the engine
accepts is compared, and selection, the broad phase and the exact path
never judge a mesh at all. It culls provably disjoint pairs with a
conservative world-AABB broad-phase, and reads is_empty()/volume()
straight off lazy-transformed Manifolds — verdict-identical to the naive
faceted path. Exact pairs share the same AABB
broad phase, then use OCCT common and interpret “contains no solid” as empty;
kernel failure raises and never falls back. volume_epsilon is ignored with a
warning when every comparison in a call was exact.
Verdicts are memoized within a run (ADR-070). The identity of an intersection
question is (both geometry identities, evaluation path, exact bytes of inv(M1) @ M2) — relative rigid placement, so a pair carried together by a
shared parent is the same question while a pair that moved relative to each
other is not. The key carries no tolerance and no rounding: deciding that two
near-identical placements are one question is the judgement volume_epsilon
exists to leave with the project. Exact and faceted entries never serve one
another, a node with no file identity is never cached, and entries are evicted
when a geometry identity changes, on the same discipline as the Manifold cache.
Bounding boxes share those stable geometry identities. Exact placements use a
512-entry LRU keyed by stable shape identity and the exact placement-matrix
bytes, with no rounding; eviction merely recomputes the same placement and a
new managed solid test run starts empty. A stock FlexibleNode keeps a
separate 64-entry LRU of evaluated mesh, bounds and Manifold results keyed by
its full source identity, canonical structural identity, exact binding and
serialized shape specification. A subclass that overrides the stock flexible
evaluation seam is conservatively uncached, and flexible intersection verdicts
remain uncached: the per-instance final binding can still change between
comparisons.
The root-level integrity boundary is the first rigid node on every branch
(ADR-039/040). Connectivity is deliberately solid-local.
assertNoDisconnectedSolids(node) explicitly checks that every printed solid
in a selected subtree is one connected body; it reads each topmost rigid
node's local STL. assertNoSolidInterference(node) is its world-space
assembly complement: zero or one selected solid passes without geometry work;
otherwise a sweep-and-prune index estimates interval pressure on X, Y and Z,
chooses the least-pressure axis (X, then Y, then Z on ties), and emits the
potentially interacting pairs in the historical X-order before each is settled
by an exact same-kernel
intersection — the sole verification path, with no whole-assembly measurement.
Exact zero-volume boundary contact passes, every positive candidate volume
fails, and no public volume epsilon or private numerical tolerance is exposed.
Correctness rests on the broad phase being complete, which is proved by
framework tests rather than re-checked at runtime (ADR-040). Up to 8,192
accepted candidates are buffered to restore that order; at the cap the buffer
is discarded and the original streaming X sweep is used, preserving bounded
memory and the same candidate set. The old all-leaf
assertNoPairwiseIntersections sweep remains deprecated and
behavior-compatible.
assertAssemblySupported(node, gravity=(0, 0, -1), max_drop=1.0, ground=None, supports=None, stability_margin=0.0) asks the physical inverse over the same
selection and the same
placement (ADR-048): not whether two parts share material, but whether any part
is floating. A solid is directly supported by another when, displaced by
max_drop along the normalized gravity vector — one world-frame translation
folded into the placement matrix — it intersects that solid with positive
volume; zero-volume contact after the drop is not a hold. Those edges form a
support graph, seeded by the solids within max_drop of the assembly's
furthest extent along gravity (or by an explicit ground, resolved up from a
feature or down through an assembly), and groundedness propagates from
supporter to supported, so a mutual-lean cycle is grounded exactly when a
member reaches ground. supports=[(supported, supporter), ...] declares holds
the drop cannot prove — press fits, glue, friction — without grounding
anything by itself. The broad phase is the same sweep-and-prune, run over the
displaced and placed boxes at once so an emitted cross-half pair is exactly a
directed overlap; exact pairs still route to the kernel. Zero or one selected
solid passes without geometry work; a zero gravity, a non-positive
max_drop, a negative stability_margin, and an unresolvable
ground/supports entry are loud errors.
When reachability holds, a second phase proves frictionless static equilibrium
(ADR-049): that push-only normal forces over the detected interfaces balance
every non-anchored solid's weight and its torque about its own centre of mass,
decided by one deterministic scipy.optimize.linprog HiGHS solve of the
L1-relaxed feasibility program. Its constraint matrix is assembled sparsely:
each contact or declared wrench contributes only to its affected solid rows,
per-cell contributions are accumulated in the historical deterministic order,
and CSR identity and horizontal blocks preserve the same equations and
diagnostics without allocating the dense mostly-zero matrix. Interfaces are extracted by meshing each
displaced intersection and classifying its faces to the supporter's boundary by
nearest surface, so contact points and normals sit on the supporter's real,
undisplaced surface; gravity-perpendicular faces are discarded as walls the
displacement drove into. Detection runs the drop sweep and a symmetric lift
sweep through the same broad phase, the lift contributing contacts only so a
snug hole's upper wall can complete a couple, never support-graph edges.
Contact extraction and mass properties (placed facets, uniform unit density, so
weight is volume) are faceted even for exact pairs, whose edge existence still
routes to the kernel. With ground=None a virtual floor slab in the gravity
frame is the sole anchored body, so a default seed must balance on its real
footprint; an explicit ground anchors exactly the resolved solids and no
floor exists. A declared supports edge carries a free six-component wrench.
stability_margin shrinks each patch toward its centroid first. The failure
names each unbalanced solid and whether force or torque does not close, and
points at supports=. The assertion now claims support reachability, force
balance, torque balance and toppling over the detected contacts; friction,
adhesion, purely lateral wall reactions, single-solid floor toppling and
dynamics remain outside it.
All three integrity assertions run only when ordinary project test source
calls them. solid new declares the connectivity and interference pair as two
counted companion tests; non-test commands do not load that companion. assertJoined(a, b, min_weld_volume=...) checks the separate pairwise claim that two named
features meet directly. It composes operations only below their enclosing
topmost rigid node, excluding whole-solid placement and every animated
ancestor. That frame is meaningful only within one part, so the assertion
refuses a pair drawn from two different solids instead of comparing them at
their own origins. Collision remains world-framed and time-dependent.
The browser viewer is not in this repository. It is solid-node-viewer, an
independent AGPL-3.0-only package installed through the viewer extra
(ADR-068); the framework is Apache-2.0 and complete without it, with the
OpenSCAD GUI as its viewer and the OpenSCAD CLI as its snapshot renderer.
The framework touches the viewer in exactly two ways. solid_node/viewers/ bundle.py loads the viewer's solid_node.viewer entry point — a
standard-library-only function returning the bundle path, the export page
and the declared API version — and nothing else of it; solid viewer,
solid export, the Sphinx directive and the web snapshot all resolve the
bundle there and name one remedy when it is absent. Everything else runs the
viewer as a separate process through sys.executable -m solid_node_viewer.
solid develop opens the browser viewer when its package is installed and
OpenSCAD otherwise; --web and --openscad are explicit and never
substituted. The browser viewer is the viewer's serve --build-dir process
on the project's atomically published build directory, restarted after each
completed build as the in-process server was, so the reload channel's
"greet a reconnecting browser with reload" contract is unchanged. The
server, the React development shell, the bundle routes and the reload and
build-error surfaces are specified in the viewer's development-server
capability; how the widget renders — tree traversal, world-matrix
composition, expression evaluation, animation, targeted in-place updates —
in its viewer-package capability. Those decisions (ADR-012/013/014/027/
035/036/037/042) are relocated to the viewer repository under their
original numbers.
The browser snapshot renderer keeps the half that knows what a node is: it
renders any stale artifact of the photographed node, serializes that node's
tree into a temporary sibling and copies each artifact from the same pinned,
strongly observed snapshot used for its piece facts, all while holding the
project build lock. It validates the observation after each copy, then releases
the lock and runs the viewer's
capture on that pinned staging directory with the image size, the animation
instant and the camera it resolved from OpenSCAD's syntax. It never
republishes or sweeps the build itself: the published document belongs to the
producer serving it, so a snapshot of one part leaves the rest of the project
intact. The viewer adds the bundle and a mount page, serves the directory on
an ephemeral loopback port and captures only the transparent canvas under
Chromium/SwiftShader; staging is removed after either success or failure
(ADR-041/068).
solid export (ADR-020/034/035/042) emits a self-contained static artifact:
manifest.json (format: solid-node-export, at the versioned tree-document
schema shared with viewer.json — version: 2, 3 when the tree holds a
flexible node, or 4 when its expressions share a subexpression (ADR-080);
not a portability claim),
deduplicated models/*.stl, and — copied from the installed viewer
package — a React-free three.js widget whose side-effect-free imperative core mounts a
published tree into a host and returns a lifecycle handle; its published entry
auto-mounts data-solid-widget containers, animates $t client-side (play/
pause + timeline when animated), and honors ?t=/?autoplay=0. The browser
global exposes API version 5 so a host can check compatibility before mounting.
The handle exposes immutable assembly metadata and host-controlled subtree
focus and visibility by root-relative name path. Those inspection controls are
session state: they neither mutate nor unload the published tree, and valid
paths survive targeted updates while removed paths are discarded.
Hosts may supply camera position/target, an up direction, and field of view;
the latter two retain Z-up/50° defaults when absent. OpenSCAD camera conversion
is isolated as pure math and supplies the browser renderer with eye, target,
up, and OpenSCAD's 22.5° perspective field of view (ADR-041).
The handle also drives the document (ADR-056 stage 3b). A document
whose drivers table is non-empty loads and renders at the pose its
expressions evaluate to under the declared defaults; what is refused is
a document naming an id its own table does not declare, which has no
value to bind. The handle exposes drivers() and instructions()
verbatim, driver(id)/setDriver(id, value) in native driver
units (a range never clamps, and an unknown id fails loudly listing
the declared ones), onDriverChange(fn) — once per changed driver per
frame, synchronously on setDriver, returning an unsubscribe — and
trigger(name), which converts the instruction's design-unit targets
through the driver table and ramps each target driver linearly from its
current value over the declared duration, returning {done, cancel()}.
Ramps advance on wall-clock elapsed time in the existing animation
loop: endpoints and duration are contract (integer dtypes are whole at
every frame and land exactly on target), intermediate values are
sampling, and a new trigger replaces an active ramp from where it
stands, as Sim's programs do. Determinism stays with the Python
simulation; the client is an animation. Which operations recompute is
decided by the free variables of their parsed expressions, not by a
substring test: a driver change re-evaluates exactly the operations
naming it, $t operations keep animating from the time transport, and
a driver named total is never found inside a function name.
The widget evaluates shape per frame the way it evaluates pose
(ADR-057). It bundles molejo's JavaScript evaluator through the ADR-035
delivery path, and per flexible node parses its params once through the
existing expression cache, evaluates them in the existing scope ($t plus
the nested driver map), and hands the values to molejo. Buffers are
allocated by molejo's first evaluation — only that call can know the
counts, which the spec fixes — and every later binding refills the same
Float32Array in place, leaving the index untouched: no reallocation and
no topology change ever happens at frame rate. Gating is one rule over
two dependency sets: touchedBy(free, changed) decides both whether a
node's matrix recomputes, from its operations' free variables, and whether
its geometry does, from its params' — so a driver named by no params
expression costs a spring nothing and the two sets cannot drift into two
rules. The surface is shaded flat, which is a decision rather than a
default: molejo emits no normals, its mesh is indexed and shares rim
vertices between wall and caps so computed vertex normals would round the
rim, an STL arrives non-indexed and therefore already looks flat, and
skipping the pass spares O(V) work per driven frame. Two refusals stand in
the prepare phase, before the live tree is touched, so a rejected document
leaves the standing scene intact: a document version outside the accepted
set — [1, 2, 3] at this writing, and, once the paired viewer change
(ADR-080) that evaluates a bindings table lands, [1, 2, 3, 4] — a check
the loader previously did not perform at all — and a flexible node whose
tech this build cannot evaluate, each named in the error. Until that
viewer change lands, a version-4 document — which this framework can
already publish — is refused here by design (ADR-080): silently ignoring
bindings would resolve a reference to nothing and render a wrong pose, so
a version bump forces the loud refusal instead.
The tree
walk is the same rigid-stops/non-rigid-recurses rule as the NodeAPI;
operations ship as raw expression strings. Both producers use the same core
serializer, which links rendered children before recursion and includes
mtime; export alone maps and copies rigid models beneath models/.
The serializer is a pure walk: which time a document is written in is a
producer decision (ADR-051). export_node clears any keyframe before
serializing, so manifest.json carries $t whatever the caller did to the
node, and leaves it in symbolic time; the builder never keyframes; the browser
snapshot keyframes deliberately and bakes one instant through math.py, whose
degree semantics are the ADR-022 source of truth. A new producer states its own
time contract.
Schema version 2 extends that guarantee from $t to named drivers
(ADR-056 stage 3a). symbolic_drivers(node) is a serialization mode,
not a relaxed validator: it binds every declared driver of the tree to
its qualified DriverToken through an internal path — set_state's
numbers-only contract is untouched, which is what keeps a bound pose a
pure function of numbers — serializes, then restores exactly the
snapshot each node held and re-renders under it. So a document
serialized from a numerically stepped node still publishes
(x_axis.motor * 0.1125) rather than the constant that instant
computed. Beside root it publishes a drivers table: qualified
id → default, range, unit, dtype, scale, straight from the
declaration, presentation metadata only (range is never a clamp), and
every id any serialized expression references appears in it. The export
manifest and the normal-build viewer.json use the mode; the browser
snapshot does not, because it photographs one instant on purpose — its
document names no driver, so its table is empty. A tree declaring no
drivers is not walked at all and serializes the version 1 document with
an empty table added, which is why consumers gate on the table rather
than the number: an empty one renders exactly as version 1, a non-empty
one is refused by a consumer that cannot evaluate driver expressions
rather than rendered at a wrong pose. The .scad path is unchanged —
bound drivers collapse to numerals because Python evaluates eagerly,
and $t stays live.
Beside it, still within version: 2, the document publishes an
instructions table (ADR-056 stage 3b): qualified instruction name
→ design-unit targets keyed by qualified driver id, plus a duration,
verbatim from the declarations the same tree walk found. The key is
additive rather than a version bump because an instruction targets a
driver, so a document carrying instructions necessarily carries a
non-empty drivers table, which a consumer without driver evaluation
already refuses loudly — no consumer can misread it. Targets stay in
design units: the conversion to native state belongs to the driver
declaration, and the client performs it exactly once, exactly as
Driver.native does.
animation.loop is the third additive key (ADR-072). Every producer
builds its animation object through one helper (animation_block) that
reads declared_time(type(root)) and adds loop — the declared seconds
one turn of $t covers — beside fps and frames when the root declares
a time base, and omits it otherwise, so an undeclared root's document is
byte-identical to before. The key needs no version: the tree shape and the
operation serialization do not change, the expressions already carry
$t * loop, and a consumer ignoring it plays frames / fps as it always
did. solid snapshot --time keeps its 0..1 meaning and keyframes
fraction * loop in Python when a base is declared; the renderers' $t
is untouched.
Schema version 3 adds a third node shape (ADR-057). Beside a model
reference and a list of children, a node may carry flexible: the
evaluating technology (tech), the shape spec verbatim as the adapter
serialized it (spec, inlined — a molejo document is a few kilobytes and
there is no mesh to deduplicate), and a params table of one raw
expression string per shape parameter. The producer reads that shape off
node.flexible exactly as it reads node.rigid, so the document's shape
follows the node's kind rather than a type test; type keeps publishing
the framework node kind (LeafNode), unchanged. params expressions are
produced by the same symbolic mode as operations and carry the same
verbatim guarantee structurally — drive_tree binds every declared driver
before serialization — so a snapshot-bound tree still publishes
(46.8 - valvetrain.lift) rather than the constant that instant computed,
and every id they reference appears in the drivers table. A flexible leaf
contributes no models/ entry and no piece: its geometry is the spec.
The version is a property of the content, not of the producer:
document_version() reads it off the finished tree, so a document with no
flexible node stays byte-identical to the version 2 it always was and an
old consumer refuses only what it genuinely cannot render. Consumers accept
1, 2 and 3.
Schema version 4 publishes each subexpression more than one operation or
params entry uses, once, as a named bindings table (ADR-080). Every
symbolic value the framework builds is a solid2 OpenSCADConstant, and
OpenSCADConstant is string-eager — a value used twice is written twice,
and a value reused at each of several nested levels is written exponentially
often, which is what let one clock's kinematics publish a 31.6 MB document
that was 99.9% seven million copies of 263 distinct subexpressions. The cure
does not touch how a project writes kinematics or how solid_node.math and
solid2's operator overloads build text: solid_node/core/expressions.py
parses the strings the walk already collected from operations and flexible
params, interns them structurally across the whole document, and rewrites
every occurrence of a subexpression that repeats — except a bare number or a
bare driver id, shorter written out than referenced — into a reference to a
named entry. bindings is a top-level array beside drivers and
instructions, each entry {name, expression}, ordered so an entry names
only $t, a declared driver id, or an earlier entry: a consumer
evaluates it in one forward pass, before any operation or params
expression, into the same scope it already builds for $t and drivers.
Names are _b0, _b1, … in table order, under a prefix lengthened by a
leading underscore whenever a declared driver id would otherwise collide
with one — deterministically, so a legal model is never refused for a
driver's name. A reference is the binding's name written where an
expression would be, resolved exactly as $t and a driver id are: a name
resolves as a binding before it is judged an undeclared driver, and
dependence flows through it, so an operation whose whole expression is a
binding name over $t is still time-dependent and one over a driver still
depends on that driver. An expression the parser cannot read — outside the
grammar two producers emit, reachable only through a hand-written
scad_inline string — is published verbatim and unshared, with a warning,
never failing the build; the producer refuses to publish only when the
table it would write is wrong (an entry naming a later entry, a name
colliding with a driver id, a rewrite that does not reproduce what was
built), which is a framework defect, never a project's. The version, like
the drivers table before it, is read off the finished document rather than
declared while building it: non-empty bindings → 4; empty → the
version the tree's shape already needed (2, or 3 with a flexible leaf),
with the key omitted entirely, so a document with nothing to share is
byte-identical to the one published before this existed. Unlike the
additive keys above, this bump is not additive — a consumer ignoring
bindings would resolve a reference to nothing and render a wrong pose —
so it is a genuine refusal for a consumer that cannot read version 4, by
design, in the phase that already refuses an unknown version. The .scad
path never sees a binding: operation.scad(...) and port.value read
self.angle / self.translation / the port's value directly, never
operation.serialized or flexible_document()'s table-rewritten strings,
so generated SCAD and Solid2Node.as_number's echo(...) round-trip are
unaffected. solid snapshot --renderer web keyframes and bakes constants
before serializing, so its staged document shares nothing, carries no
table, and stays at version 2 or 3 with any viewer. Consumers accept 1, 2,
3 and 4 once the paired viewer change (solid-node-viewer) widens its
accepted set to admit it; until then a version-4 document is refused by the
phase described just below, which is the correct failure for a machine no
installed viewer can yet resolve bindings for.
Every producer — export, build snapshot, browser snapshot — also publishes a
printed-piece inventory (ADR-043): a top-level pieces list beside root,
one entry per distinct built artifact content, carrying id, name,
contributing sources and models, count, bounding size, volume, and
watertight, with every rigid node carrying the piece id that resolves into
it. Expensive content-derived facts live in a private versioned sidecar keyed
by the artifact's strong observation and current currency record (ADR-085).
The internal identity is the full content digest; a collision in its public
short prefix is refused rather than merged. Current tree metadata is always
serialized afresh. Facts and any export or snapshot staging bytes are read or
copied from one pinned artifact snapshot with a fresh post-read identity check,
so metadata and bytes cannot describe different file generations. Build and
browser-snapshot producers hold the project lock for that operation; direct
export relies on the pinned validation and retries without acquiring it. No
pose or $t leaks into the facts. The section is additive; a consumer reading
only the tree is unaffected.
The Sphinx extension (.. solid-node:: <export-dir>) embeds exports
as iframes, copies them at html-collect-pages, and completes missing
widget files from the installed package — docs build without the CAD
stack.
There is exactly one expression semantics: OpenSCAD's degree
conventions, with ^ as power (ADR-022, revised). solid_node/math.py
is the source of truth, and every function in it wears three faces:
numeric on plain numbers (keyframes, tests), a deferred OpenSCAD
expression when any argument is symbolic (the build and viewer path),
and a dimension-checked Formula when any argument is a declared
parameter (ADR-062). Three runtimes must agree — math.py, OpenSCAD,
and the one TypeScript evaluator in the viewer package — and since
ADR-056 stage 3b the same semantics govern driver expressions too: a
qualified id resolves through a nested driver map, needing no grammar
extension.
The module is split in two, and the split is load-bearing.
Primitives are what it emits as an OpenSCAD call: the degree
trigonometry, sqrt, and the direct builtins abs, floor, ceil,
sign, min and max. Each carries a dimension rule in
function_formula. Compositions — clamp, clamp01, ramp,
lerp, wrap, piecewise, bump, and the vector helpers polar,
turn, rotate_x, rotate_y, rotate_z — are built from those and
carry no rule, so each has exactly one definition and its declared face
is the same function as the other two; what they do to dimensions is a
consequence of the primitives' rules and the algebra's. The consequence
a caller meets is that a bound, period or centre stated as a bare number
against a dimensioned quantity is refused, exactly as length + 1 is.
A name enters the vocabulary only if all three runtimes compute it
identically, which is why there is no round (OpenSCAD rounds halves
away from zero, JavaScript toward +infinity, Python to even) and no
mod (OpenSCAD spells it as the % operator, whose sign rule differs
from Python's). SYMBOLIC_BUILTINS in math.py is the single inventory
of every name that may be emitted; _symbolic_call refuses any other.
That agreement is enforced, not documented: parity-fixture.test.ts
runs the shipped evaluator against parity-fixture.json, whose expected
values are producer values — one numeric render of a tree paired by
structure with one symbolic serialization of it, so nothing recomputes
an expression a second way. tools/generate_parity_fixture.py
regenerates it from two corpora — the ADR-056 expression spike's
two-axis machine (linear driver terms, port scales, degree-trig chains,
^ terms, mixed $t-and-driver formulas) and
tests/expression_project/vocabulary.py, which puts every remaining
emitted builtin on the wire under a driver bound negative as well as
positive — for 421 cases in all. The generator reads SYMBOLIC_BUILTINS
and refuses to write while any emitted name is uncovered, so the
enforcement cannot silently narrow; tests/test_expression_corpus.py
says the same in the framework's own suite. The fixture also pins
Driver.native's design-to-native conversion, integer
round-half-to-even included, for the same reason. The fixture itself is
committed in the viewer repository (ADR-068), so widening the vocabulary
is one change here and one there.
solid_node/mechanisms/ carries the textbook mechanism laws that a
project's own kinematics.py kept rewriting (ADR-076): the external
spur-gear mesh and its inverse, the lead screw, the planar slider-crank,
linear delta kinematics, and the circle geometry a linkage asks for. One
module per family — gears, screws, cranks, deltas, linkages —
with the family's frame, zero and sign stated once at its top, and an
eager flat re-export whose names are unique across families.
Three properties make it a subsystem rather than a utility drawer. Every
law is a composition over solid_node.math and arithmetic, so it has
that module's numeric and symbolic faces and emits no OpenSCAD builtin
the parity corpus above does not already pin — the mechanisms spec
requires that, so a law wanting a new builtin must go through math.py
first. A gear library's convention enters as reference angles
(driver_gap, driven_tooth), not as a fork of the law or a read of a
gear object. And there is no declared face: the laws carry degree
literals the ADR-062 algebra cannot type as angles, so a declared token
reaching one raises DimensionError at class definition, with .value
as the documented way through.
The short list that changes must not silently break:
- An artifact is fresh iff its mtime equals the node's max source mtime, compared as integer nanoseconds and never as a float, and the recorded metadata fingerprint of every tracked source equals its current path identity, size, mtime and change time (ADR-050/081); an exact node requires both STL and BREP current, and every cache keys on that signal (ADR-006/028/029/044). Equality, not tolerance: a window wide enough to absorb a filesystem's timestamp quantum is a window in which a real edit is invisible. When and only when either equality fails, a content-verified fallback compares a digest of the node's tracked sources against the digest recorded beside the artifact when it was written; identical sources restamp rather than re-derive, so a clone, a branch switch or a stash pop costs a settled rebuild instead of a full one (ADR-060). The fresh path reads source metadata and the small sidecar but no source contents; the fallback is strictly stricter than the rule it stands behind — byte equality rather than timestamp equality — so it cannot report a changed source current. The digest is scoped to the node (ADR-071): a file that defines several node classes contributes to each node's digest the file minus the other node classes' bodies, except any the retained text names as an identifier or a string, and an internal node's digest covers the union of its children's scopes. Two nodes sharing a file therefore rebuild independently, an edit to code they share rebuilds both, and a single-class file digests byte for byte as before. One node per file is not a premise of the cache; what remains true of a multi-node file is a property of node references (a bare path to it is ambiguous), not of currency.
- A loaded tree belongs to one sealed source generation. Project Python is executed from coherently observed bytes, all consumed Python and foreign contributors retain their canonical targets and strong identities, and every producing phase and asynchronous wait rechecks them before stale work can publish (ADR-084). The F04 project lock spans assembly, artifact work and publication, not callbacks, project tests or development recovery waits.
- A node's source set is its own file plus the project-local modules it
imports, transitively — never the
__init__.pyof a package the walk merely traverses, which would make every node depend on every file (ADR-033). The set over-approximates on purpose: a spurious rebuild is cheap, a stale model is not — which is why a leaf whose source is a foreign file adds the closure of the python module wrapping it when that module carries geometry-affecting code (ADR-055). Resolving the package a file was imported as, which the closure needs for relative imports, reads a one-shot index of the loaded module table keyed on the exact set of module names, so building a closure does not scale with what the interpreter has imported and cannot answer from a superseded module set (ADR-058). name=never influences geometry oruniq_id; any parameter change changes the artifact key (ADR-026), and on a declarative class the framework computes the key from every declared value, so none can be left out (ADR-063). A declaration is realized per parent instance; a class body never holds a node instance (ADR-061). Piece identity is the converse: it derives from built content only, never from a class, its parameters, or its artifact path. Piece facts and staged bytes come from one strongly observed pinned artifact snapshot; an artifact that changes or cannot be read gets no piece id rather than borrowing one (ADR-043/085).- Re-rendering an instant is absolute, never cumulative; only driver-tagged operations are swept (ADR-023).
- All pose consumers compose own-ops-first, ancestors after, later operations outermost — Python and both browsers alike (ADR-027/028).
- A non-empty, zero-volume faceted flush contact fouls at
volume_epsilon=0; exact boundary contact contains no solid and is empty. Kinematic fit still needs the Blocked and Free pair (ADR-025/029/044). - The
solid-node-exportformat/version identifies a shared tree-document schema; breaking its tree shape or operation serialization means bumping the version and updating every producer and consumer together. A producer emits the lowest version its content needs, so a consumer refuses exactly the documents it genuinely cannot render and no others (ADR-057). Portability stays producer-specific:manifest.jsonis copied and portable,viewer.jsonis build-root-relative and private (ADR-020/031/034). - Every expression evaluator — of
$tor of a driver id — uses degree trig and treats^as power, and the client's agreement with the producer's numerics is held by the parity fixture (ADR-022). - A
rangeis presentation metadata. Nothing in the framework, the simulation, or the viewer clamps a driver to it (ADR-056). - Users never override
assemble(); rigid geometry is time-invariant (ADR-002/003). Structure varies with parameters, never with time: a render that omits a different set of declared children than the instance's first render raises (ADR-064). A part whose shape follows machine state is therefore not rigid — the one non-rigid leaf kind — and it is fused by nothing, persisted as nothing, and printed as nothing. Its stock evaluated geometry may be memoized only by the complete binding and shape identity; its verdicts are never reused (ADR-057). - A topmost rigid node is the boundary of one printed solid, not a guarantee that its geometry is connected. Whole-solid integrity is an explicit project assertion; connectivity uses the solid-local frame and collision uses the world frame (ADR-039, amended 2026-08-10).
- OpenSCAD is outside the parity fixture (ADR-022): the corpus pins
the TypeScript evaluator to Python's numerics, and the
.scadboundary is exercised by rendered spike snapshots rather than by a test. Closing it needs OpenSCAD in the loop, which nothing yet requires. - Create React App is deprecated (ADR-013, now in solid-node-viewer): the development shell's toolchain carries migration debt (Vite or similar), owed by the viewer repository.
- The viewer is installed from Git in CI and on Read the Docs until solid-node-viewer is published on PyPI.
- Sequential STL rendering:
build_stlsrenders one STL at a time; cold builds could parallelizeopenscadjobs (docs/performance-improvement.md§4–5, unscheduled).
| Subsystem | Code | Spec capability | ADRs |
|---|---|---|---|
| Node model | solid_node/node/, solid_node/exact.py |
node-model, exact-geometry, flexible-parts, step-assembly |
001–004, 006, 026, 044–045, 047, 053–055, 057, 077, 078, 079, 082 |
| Build parameters | solid_node/parameters.py, node/declarative.py |
declarative-nodes |
061–065, 082 |
| Kinematics | node/operations.py, node/assembly.py, math.py |
kinematics |
008, 022, 023, 028 |
| Mechanisms | solid_node/mechanisms/ |
mechanisms |
022, 076 |
| Build pipeline | solid_node/core/ |
build-pipeline |
005–007, 018, 026, 038, 067, 080, 081, 084, 086 |
| CLI | cli.py, solid_node/manager/ |
cli |
021, 024, 068, 079 |
| Test framework | solid_node/test.py, manager/test.py |
test-framework |
009–011, 025, 029, 040, 048, 052, 070, 073 |
| Viewer lookup & snapshot staging | solid_node/viewers/bundle.py, viewers/browser.py, viewers/openscad.py |
viewer-distribution, web-snapshot |
015, 018, 041, 068 (the viewer itself: solid-node-viewer) |
| Export | core/export.py, core/serializer.py, core/expressions.py |
export |
020, 034, 043, 051, 057, 068, 080, 085 |
| Sphinx embedding | solid_node/sphinx.py |
sphinx-embedding |
020 |