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() → validate() → as_scad()
│
┌───────────────┼──────────────────┐
▼ ▼ ▼
.scad files world meshes serialized ops
→ openscad (trimesh / ($t expressions)
→ .stl cache manifold3d) │
(BUILD) (TEST-FRAMEWORK) ▼
│ web viewer / widget
▼ evaluate $t per frame
dev loop, OpenSCAD snapshot, (VIEWER-WEB, EXPORT,
export models MATH)
Three architectural commitments shape almost every subsystem:
- Geometry follows the strongest backend path available
(ADR-004/044/045/046). Every backend still produces SCAD and an STL.
Solid2 and raw
.scadleaves, and faceted fusions containing them, render through OpenSCAD. CadQuery preserves BREP geometry and all-exact fusions compose and tessellate in OCCT without OpenSCAD. JSCAD produces its STL through its ownjscadtool. OpenSCAD is therefore conditional on the paths that invoke it, not a universal framework prerequisite. - The build artifact is the currency, mtime is its clock
(ADR-006/026/033). STLs are cached per parameter-hashed identity and
validated by mtime equality against the max source mtime. Caches
at every layer — meshes, Manifolds, HTTP responses — key on the same
(artifact, mtime)signal, so artifact freshness is 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. - 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).
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). Only rigid nodes produce STLs, which
is why rigid 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.
Leaf adapters (ADR-004) wrap the backends: Solid2Node,
CadQueryNode (exports to STL, re-imports), OpenScadNode
(scad_source + module call), JScadNode (shells out to the jscad
CLI). Every node exposes derived read-only exactness (ADR-044): CadQuery is
exact, the other leaf adapters are faceted, and an internal node is exact only
when every child is. 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 uses OCCT, and JSCAD uses
jscad (ADR-046). Emitting SCAD does not itself require the OpenSCAD binary.
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). 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.
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: its time is OpenSCAD's
$t (0..1) symbolically, or a float under set_keyframe() (ADR-008).
Assembly render()s are wrapped for driver-tagged idempotency
(ADR-023): operations applied during a render are tagged with the
driving assembly, and each re-render sweeps only its own tags before
re-expressing pose absolutely. Static placements (untagged) survive;
independent drivers of one node don't disturb each other.
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
(stl_file, mtime).
Nodes are addressed by reference — 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.
Artifacts land under $SOLID_BUILD_DIR (default _build, resolved
against the discovered project root rather than the working directory),
mirroring the source layout, basename <script>-<uniq_id>.
STL generation is normally asynchronous: StlRenderStart carries a spawned
openscad process, PID lock files guard concurrency, and
build_stls() loops until nothing is stale. Staleness is mtime
equality — generated files are back-dated with os.utime to the max
source mtime (ADR-006), taken over node.files: the node's own source
plus its project-local import closure, unioned upward from children
(ADR-033).
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, JSCAD — carry the same
guard, for nodes that opt out of optimization.
The dev loop (ADR-007) is a single-shot builder under watchdog:
build, watch node.files per-file, exit on change, get respawned by
solid develop (which also restarts the viewer process). solid build
uses the same builder passes without a viewer or watch loop. 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.
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 falls back to
a broad recursive watch and keeps the loop alive.
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. 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).
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: build, develop, test, snapshot, new (offline
scaffold), export. Snapshot has an explicit renderer choice
(ADR-021/041/046): OpenSCAD remains the external-tool default with xvfb
fallback, while the
optional web renderer captures the packaged viewer 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. ./.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:
intersection-volume and connectivity questions use placed OCCT shapes when
both operands are exact and retain trimesh/Manifold for mixed or faceted
pairs. 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 in the local
pre-placement frame, 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
(stl_file, mtime) (watertightness checked once, at fill), 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.
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 over conservative world AABBs emits the
potentially interacting pairs, and 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). The old all-leaf
assertNoPairwiseIntersections sweep remains deprecated and
behavior-compatible.
Both integrity assertions run only when ordinary project test source calls
them. solid new declares them 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.
solid develop serves a FastAPI + Uvicorn app (ADR-015, post-018 the
only HTTP service): static React build by default, npm-proxy under
--web-dev. It serves the current atomically published build directory
under /build/ and the installed shared viewer bundle under /_viewer.
The server does not import project source; an absent build or bundle leaves
the reload socket and build-error endpoint available, with a bundle remedy
for the browser shell to display.
The browser app is a small React shell (ADR-013, amended by ADR-036). It
loads the shared viewer bundle, mounts it against /build/viewer.json with
inline autoplay controls, names the tab from the snapshot, and uses the
mount handle's manifestChanged() after /ws/reload reports a successful
build. The shared viewer reconciles the document in place and refetches only
geometry whose (model path, mtime) identity changed (ADR-037); the canvas,
viewpoint, animation clock, and unchanged meshes survive. reload() remains
available for a host that explicitly needs a complete replacement.
Tree traversal, world-matrix composition, expression evaluation, animation,
stale-load disposal, and targeted-update failure containment live once in the
reusable viewer package (ADR-035/037), not in the development app.
A sibling OpenSCAD GUI viewer (--openscad) and the headless
snapshot renderers cover non-interactive cases. The browser snapshot renderer
renders any stale artifact of the photographed node, serializes that node's
tree into a temporary sibling and hardlinks its artifacts there, all while
holding the project build lock, then releases the lock and serves that pinned
staging tree on an ephemeral loopback port. 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. Playwright
captures only the transparent canvas under Chromium/SwiftShader; staging is
removed after either success or failure (ADR-041).
solid export (ADR-020/034/035/042) emits a self-contained static artifact:
manifest.json (format: solid-node-export, version: 1 — a versioned
tree-document schema shared with viewer.json, not a portability claim),
deduplicated models/*.stl, and 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 4 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 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/.
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. Facts are read from the artifact's own base mesh, so no pose or $t leaks
into them. The section is additive at version: 1; 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 $t semantics: OpenSCAD's degree
conventions, with ^ as power (ADR-022). solid_node/math.py is
the dual-mode source of truth (numeric under keyframes, deferred
OpenSCAD expressions when symbolic); the dev viewer's evaluator
reproduces it. Four runtimes must agree: math.py, OpenSCAD, dev
viewer, export widget.
The short list that changes must not silently break:
- An artifact is fresh iff its mtime equals the node's max source mtime; an exact node requires both STL and BREP current, and every cache keys on that signal (ADR-006/028/029/044).
- 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. name=never influences geometry oruniq_id; any parameter change changes the artifact key (ADR-026). Piece identity is the converse: it derives from built content only, never from a class, its parameters, or its artifact path — an artifact that cannot be read gets no piece id rather than borrowing one (ADR-043).- 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. Portability stays producer-specific:manifest.jsonis copied and portable,viewer.jsonis build-root-relative and private (ADR-020/031/034). - Every
$tevaluator uses degree trig and treats^as power (ADR-022). - Users never override
assemble(); rigid geometry is time-invariant (ADR-002/003). - 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).
- Export-widget
$tparity defect (ADR-022): the widget evaluator uses radian trig and lacks the^rewrite — non-linear animated exports render wrong. Open; first in line for an OpenSpec change. - No automated cross-runtime parity enforcement (ADR-022): the four-runtime agreement holds by discipline; a golden parity corpus or shared evaluator is the recorded way out.
- Create React App is deprecated (ADR-013): the dev viewer's toolchain carries migration debt (Vite or similar).
- 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 |
001–004, 006, 026, 044–045 |
| Kinematics | node/operations.py, node/assembly.py, math.py |
kinematics |
008, 022, 023, 028 |
| Build pipeline | solid_node/core/ |
build-pipeline |
005–007, 018, 026 |
| CLI | cli.py, solid_node/manager/ |
cli |
021, 024 |
| Test framework | solid_node/test.py, manager/test.py |
test-framework |
009–011, 025, 029, 040 |
| Web viewer | solid_node/viewers/web/ |
web-viewer |
012–015, 018, 036 |
| Export & widget | core/export.py, core/serializer.py, viewers/widget/ |
export |
020, 034 |
| Sphinx embedding | solid_node/sphinx.py |
sphinx-embedding |
020 |