Skip to content

Commit e28cd3a

Browse files
lfagundesclaude
andcommitted
feat(mechanisms): carry the laws every kinematics.py kept rewriting
Thirteen projects wrote the same textbook geometry out by hand, each in its own frame and sign convention, and the numeric-only copies broke the moment a symbolic driver reached them. solid_node.mechanisms carries them once: 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. Every law is a composition over solid_node.math and arithmetic, so it computes on numbers and builds the viewer's deferred expression on symbolic time or a driver, and emits no OpenSCAD builtin the ADR-022 parity corpus does not already pin. A gear library's convention enters as two reference angles rather than a fork of the law: cq_gears' gap centre at 180 / teeth, MrBunsy's at gap_angle / 2. There is no declared face -- the laws carry degree literals the ADR-062 algebra cannot type as angles, so a declared token raises at class definition and .value is the documented way through. delta_rod returns two rotations about constant axes rather than one about the perpendicular the rod leans about, because a Rotation's axis cannot carry a driver symbol; that finding cost kossel a rendered mesh and is now a docstring and a spec scenario. Checked against the function each law was lifted from, in that project's own repository: gearbox at 2.27e-13 over 900 cases, wall_clock_01's real train at 8.53e-14, and v8-engine, kossel, the grasshopper, openflexure and Inmoov at exactly 0.0. OpenSpec change mechanisms, archived; ADR-076. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147ScXEqYBQkKGv9ruLSmxR
1 parent ebe874b commit e28cd3a

19 files changed

Lines changed: 1420 additions & 31 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# ADR-076: Mechanism Laws as Compositions Over Expression Math
2+
3+
**Status:** Accepted
4+
**Date:** 2026-09-06
5+
**Depends on:**
6+
- [ADR-022: Cross-Runtime Degree-Trig Parity for `$t` Expression Evaluation](ADR-022-cross-runtime-degree-trig-parity-for-t-expressions.md)
7+
8+
**Related to:**
9+
- [ADR-062: Typed Parameters and the Exponent Algebra](../NODE/ADR-062-typed-parameters-and-the-exponent-algebra.md)
10+
- [ADR-056: Signals, Drivers, Ports, and Stepped Simulation](../NODE/ADR-056-signals-drivers-ports-and-stepped-simulation.md)
11+
12+
## Context and Problem Statement
13+
14+
Thirteen mechanical projects in the development workspace carry a
15+
`kinematics.py`, and beneath each project's own arithmetic sit a handful
16+
of textbook mechanism laws, written again and again:
17+
18+
- the **external spur-gear mesh**, four times — `conjugate_angle` in
19+
`sandbox/gearbox` over cq_gears' conventions, and
20+
`pinion_angle_for_wheel` / `wheel_angle_for_pinion` in three
21+
`3DPrintedClocks` designs over the MrBunsy `Gear` object's;
22+
- the **lead screw**, three times, in `Inmoov-sim`,
23+
`openflexure-microscope` and `snappy-reprap`, each `lead * angle / 360`
24+
with its own sign;
25+
- **circle geometry**, three shapes — a circle-circle intersection with a
26+
side, the law of cosines, and the `sqrt(L² − d²)` rise of a rigid link;
27+
- the **slider-crank**, in `v8-engine`;
28+
- **linear delta kinematics**, in `kossel`.
29+
30+
Each is a law, not a design decision. Each copy nevertheless carried a
31+
convention its author had to rediscover against a rendered mesh — where a
32+
gear library says a tooth sits at zero, which way a screw's handedness
33+
falls, which of two circle intersections is meant — and getting one half a
34+
tooth wrong looks like nothing: the numbers still read plausibly, the
35+
leaves just land on the teeth.
36+
37+
Worse, several copies were **numeric-only**. A law written with Python's
38+
`math` raises `TypeError: must be real number, not OpenSCADConstant` the
39+
instant a symbolic driver or `$t` reaches it (ADR-022), so it works under a
40+
keyframe test and kills `solid develop` at the first non-linear mechanism.
41+
42+
The museum of printers the shop plans to simulate — deltas, corexy,
43+
i3-style — will write the delta and the screw again unless the framework
44+
carries them.
45+
46+
## Decision Drivers
47+
48+
- **A law belongs where it is written once.** Rediscovering a sign
49+
convention per project is the cost this is meant to remove.
50+
- **Both faces, for free.** Anything a project's `simulate()` calls must
51+
compute at a keyframe *and* build the viewer's deferred expression.
52+
- **No new cross-runtime surface.** ADR-022's parity is enforced by a
53+
producer-generated fixture over a fixed corpus; a mechanism package that
54+
emitted a new OpenSCAD builtin would silently widen the contract three
55+
runtimes must satisfy.
56+
- **A library's convention must not fork the law.** Two gear libraries
57+
already disagree about zero, and more will.
58+
- **The dimension algebra (ADR-062) cannot type a degree literal.** A
59+
plain number is dimensionless and `Angle` is its own axis.
60+
61+
## Considered Options
62+
63+
1. **Leave the laws in the projects.** Each project keeps its copy.
64+
2. **A framework package named `solid_node.kinematics`.**
65+
3. **A mesh law that reads the gear object**, branching per library.
66+
4. **Add an `Angle`-typed literal to the dimension algebra**, so a law's
67+
`+ 180` could be spelled in a class body and every law would have the
68+
third, declared face too.
69+
5. **A framework package `solid_node.mechanisms`**, one module per family,
70+
every law a composition over `solid_node.math`, gear conventions passed
71+
in as reference angles, and no declared face.
72+
73+
## Decision Outcome
74+
75+
**Option 5.** `solid_node.mechanisms` carries the laws, with three
76+
properties that are the actual decision:
77+
78+
**Compositions, not a new evaluator.** Every function is written over
79+
`solid_node.math` and ordinary arithmetic and has exactly one definition.
80+
It therefore inherits that module's numeric and symbolic faces, and the
81+
symbolic string it builds contains only calls ADR-022's corpus already
82+
pins. The `mechanisms` spec makes that a *requirement*: a future law that
83+
wanted a new builtin must go through `solid_node.math` first, where the
84+
corpus rule lives. The package needed no extension of the parity fixture.
85+
86+
**Reference angles as the seam.** `meshed_angle` takes `driver_gap` and
87+
`driven_tooth` — plain angles saying where a gap centre and a tooth tip
88+
point at angle zero — and reads no gear object. cq_gears' convention is
89+
then `180 / teeth` and MrBunsy's is `gap_angle / 2`, two lines in the
90+
caller rather than a branch in the law. The two references are asymmetric
91+
on purpose: what defines a mesh is a tooth of the driven pointing into a
92+
gap of the driver.
93+
94+
**Two faces, and the third refused loudly.** `meshed_angle` contains
95+
`+ 180` and `screw_travel` contains `/ 360`. Under ADR-062 a declared
96+
`Angle` plus a plain number raises, so a declared token reaching a law
97+
raises `DimensionError` at class definition — early and loud, rather than
98+
yielding a formula with a wrong dimension. The package states this and
99+
documents `.value` as the way through for a static mesh phase, which is
100+
the algebra's own escape hatch. One spec scenario pins both halves.
101+
102+
Option 1 was rejected as the status quo the evidence indicts. Option 2 was
103+
rejected on naming: `kinematics.py` is what every project calls its own
104+
module, and the `kinematics` OpenSpec capability already means operations,
105+
poses and the expression contract. Option 3 was rejected because it makes
106+
the law's shape a function of a third-party object's API. Option 4 is a
107+
real gap in ADR-062 and would give these laws and any other degree-literal
108+
arithmetic a proper third face — but it is an algebra change with its own
109+
consequences and belongs to its own cycle; it is recorded as an open
110+
question rather than folded in here.
111+
112+
## Consequences
113+
114+
- The framework now has an opinion about mechanism conventions, and each
115+
is stated in the family module a law lives in: the mesh's common frame
116+
and reference angles, the screw's right-hand rule (handedness and
117+
nut-versus-screw stay the caller's minus sign), the crank's
118+
`(across, along)` plane with zero at top dead centre, the delta's
119+
`radius` and its two-rotation posing recipe, and the linkage side rule.
120+
- Names are unique across families by rule — `crank_rod_angle`, not
121+
`rod_angle`; `delta_rod`, not `rod_tilt` — so the flat re-export can
122+
stay flat as families grow.
123+
- `delta_rod` returns two rotations about *constant* axes rather than one
124+
about the perpendicular the rod actually leans about, because a
125+
`Rotation`'s axis cannot carry a driver symbol (ADR-056). That framework
126+
finding, which cost `kossel` a rendered mesh to discover, is now a
127+
docstring and a spec scenario.
128+
- Nothing guards an unreachable configuration. A `sqrt` or `acos` out of
129+
range raises numerically and is NaN symbolically, exactly as
130+
`solid_node.math`, OpenSCAD and the viewer behave. A guard would have to
131+
invent a pose that does not exist.
132+
- No existing behaviour changes: the package is new, imported by nothing at
133+
startup, and depends only on `sin`, `cos`, `asin`, `acos`, `atan2` and
134+
`sqrt`.
135+
- Each law was checked against the function it was lifted from, in that
136+
project's own repository, as uncommitted evidence: gearbox 900 cases at
137+
2.27e-13, wall_clock_01's real train at 8.53e-14, and v8-engine, kossel,
138+
the grasshopper, openflexure and Inmoov at exactly 0.0.
139+
- Open questions carried forward: an `Angle`-typed literal in the algebra
140+
(Option 4), and whether openflexure's four-bar `leg_lean` / `lever_rise`
141+
becomes a law once a second flexure stage appears.
142+
143+
Change record: `openspec/changes/archive/2026-09-06-mechanisms/`.

docs/adrs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ that way.
8585

8686
### MATH — expression evaluation parity
8787
- [ADR-022](MATH/ADR-022-cross-runtime-degree-trig-parity-for-t-expressions.md) — Cross-runtime degree-trig parity for `$t` and driver expressions — **Accepted**, revised 2026-09-06 (defect fixed and parity enforced 2026-08-26; vocabulary widened beyond trigonometry, and the corpus must cover every emitted name)
88+
- [ADR-076](MATH/ADR-076-mechanism-laws-as-compositions-over-expression-math.md) — Mechanism laws as compositions over expression math — **Accepted**, depends on 022
8889

8990
### TEST-FRAMEWORK — CAD testing
9091
- [ADR-009](TEST-FRAMEWORK/ADR-009-trimesh-based-mesh-assertions-for-cad-testing.md) — Trimesh mesh assertions — **Accepted**, extended by 025

docs/animation.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,26 @@ by a driver-derived angle survives the viewer::
184184
about=...)`` turns a 2D point about a centre, and ``rotate_x``,
185185
``rotate_y`` and ``rotate_z`` turn a 3D point about an axis. All
186186
degrees, all right-handed, all returning plain tuples.
187+
Before writing a mechanism out longhand over those functions, look in
188+
`solid_node.mechanisms`: it carries the laws projects kept rewriting —
189+
the external spur-gear mesh, the lead screw, the slider-crank, linear
190+
delta kinematics, and the circle geometry a linkage keeps asking for.
191+
Each is a composition over `solid_node.math`, so it has that module's
192+
numeric and symbolic faces — and only those two: a mechanism law carries
193+
degree literals the dimension algebra cannot type, so unlike the
194+
functions of `solid_node.math` it has no third, declared face, and
195+
reaching one from a class body raises there. Each states its frame, its
196+
zero and its sign in the family module it lives in. See :doc:`the API
197+
reference <api-reference>`.
198+
199+
The delta is the worked reason the package exists. A rod leaning toward
200+
a moving effector leans about a direction that the effector's position
201+
decides — and a `Rotation`'s axis is a *constant*: it cannot carry a
202+
driver symbol, so ``rotate(angle, computed_axis)`` works in a test and
203+
fails in the viewer. `delta_rod` therefore returns two rotations about
204+
constant axes, ``-tilt`` about Y then ``azimuth`` about Z, which is the
205+
same pose and survives symbolically. That finding cost one project a
206+
rendered mesh to discover; it is now a docstring.
187207

188208
.. _time-base:
189209

docs/api-reference.rst

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ scenario tests.
249249
.. autofunction:: solid_node.simulation.qualified_instructions
250250

251251
Expression math
252-
===============
252+
========
253253

254254
``solid_node.math`` is the one expression semantics: OpenSCAD's degree
255255
conventions, computed on plain numbers, deferred as an OpenSCAD
@@ -315,6 +315,111 @@ counter-clockwise, as everywhere else.
315315
.. autofunction:: solid_node.math.rotate_x
316316
.. autofunction:: solid_node.math.rotate_y
317317
.. autofunction:: solid_node.math.rotate_z
318+
=======
319+
Mechanisms
320+
==========
321+
322+
The textbook mechanism laws, importable from
323+
``solid_node.mechanisms``. Each is a composition over
324+
``solid_node.math`` and so has two of that module's three faces: it
325+
computes a number when the node is posed at a keyframe, and builds the
326+
equivalent deferred OpenSCAD expression when its driving argument is
327+
animation time or a driver. The third — a formula over declared
328+
parameters — it deliberately does not have; see below. Every angle is
329+
in degrees, positive by the right-hand rule about the axis each family
330+
states. Read the family module's docstring for its frame, its zero and
331+
its sign before calling into it.
332+
333+
There is **no declared (class-body) face**. The laws carry degree
334+
literals — the mesh adds ``180``, the screw divides by ``360`` — and the
335+
dimension algebra has no way to type a plain number as an angle, so a
336+
declared token reaching a law raises ``DimensionError`` at class
337+
definition. A class body that needs a static mesh phase computes it over
338+
``.value`` operands, the algebra's stated escape hatch::
339+
340+
class Train(AssemblyNode):
341+
wheel = Count(60)
342+
pinion = Count(8)
343+
phase = Angle(0.0)
344+
pinion_phase = meshed_angle(phase.value, wheel.value, pinion.value)
345+
346+
Gears
347+
-----
348+
349+
The external spur-gear mesh. A pair is meshed when a tooth of the driven
350+
points into a gap of the driver along the line of centres; where a tooth
351+
sits at zero is the gear library's business, so the law takes it as two
352+
plain reference angles and reads no gear object. ``driver_gap`` is the
353+
direction of a gap centre in the driver's own frame at angle zero,
354+
``driven_tooth`` the direction of a tooth tip in the driven's. cq_gears
355+
centres a tooth on +X, so its gap centre is ``180 / teeth``; MrBunsy's
356+
``Gear`` starts at a gap, so its gap centre is ``gap_angle / 2`` and its
357+
tooth tip ``gap_angle + tooth_angle / 2``, both negated for a flipped
358+
part and zero for a lantern pinion.
359+
360+
.. autofunction:: solid_node.mechanisms.meshed_angle
361+
362+
.. autofunction:: solid_node.mechanisms.driving_angle
363+
364+
Screws
365+
------
366+
367+
The lead screw, right-hand and sign-neutral: a positive turn by the
368+
right-hand rule about the screw's own axis advances it along that axis
369+
relative to its nut. A left-hand thread, a nut moving instead of a
370+
screw, or a lever that inverts is the caller's minus sign, where it can
371+
be read beside the reason for it. ``lead`` is the advance per turn —
372+
pitch times starts, never bare pitch.
373+
374+
.. autofunction:: solid_node.mechanisms.screw_travel
375+
376+
.. autofunction:: solid_node.mechanisms.screw_angle
377+
378+
Cranks
379+
------
380+
381+
The planar slider-crank, in the crank's own plane: the crank axis is the
382+
plane normal, the cylinder axis is *along*, the other coordinate is
383+
*across*, and the crank angle is measured from *along*, zero at top dead
384+
centre. A caller whose crank axis is elsewhere maps these with its own
385+
frame rotation.
386+
387+
.. autofunction:: solid_node.mechanisms.crank_pin
388+
389+
.. autofunction:: solid_node.mechanisms.crank_rod_angle
390+
391+
.. autofunction:: solid_node.mechanisms.piston_height
392+
393+
Deltas
394+
------
395+
396+
Linear delta kinematics: towers on a circle about Z, carriages riding
397+
vertically, a diagonal rod to the effector. ``radius`` is the horizontal
398+
distance from an effector joint to its carriage joint with the effector
399+
at the origin. ``delta_rod`` returns two rotations about *constant* axes
400+
rather than one about the perpendicular the lean happens about, because
401+
a ``Rotation``'s axis cannot carry a driver symbol; pose a rod authored
402+
along Z by ``-tilt`` about Y, then ``azimuth`` about Z.
403+
404+
.. autofunction:: solid_node.mechanisms.delta_carriage
405+
406+
.. autofunction:: solid_node.mechanisms.delta_rod
407+
408+
Linkages
409+
--------
410+
411+
Circle geometry. ``side = 1`` picks the intersection to the left of the
412+
direction from the first centre to the second. None of the three guards
413+
an unreachable configuration: a ``sqrt`` or ``acos`` out of range raises
414+
numerically, exactly as ``solid_node.math`` raises, and is NaN
415+
symbolically, exactly as OpenSCAD and the viewer are — a guard would
416+
have to invent a pose that does not exist.
417+
418+
.. autofunction:: solid_node.mechanisms.circle_intersection
419+
420+
.. autofunction:: solid_node.mechanisms.triangle_angle
421+
422+
.. autofunction:: solid_node.mechanisms.link_rise
318423

319424
Testing
320425
=======

docs/architecture.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,28 @@ round-half-to-even included, for the same reason. The fixture itself is
10181018
committed in the viewer repository (ADR-068), so widening the vocabulary
10191019
is one change here and one there.
10201020

1021+
### Mechanisms (MATH · in spec `mechanisms`)
1022+
1023+
`solid_node/mechanisms/` carries the textbook mechanism laws that a
1024+
project's own `kinematics.py` kept rewriting (ADR-076): the external
1025+
spur-gear mesh and its inverse, the lead screw, the planar slider-crank,
1026+
linear delta kinematics, and the circle geometry a linkage asks for. One
1027+
module per family — `gears`, `screws`, `cranks`, `deltas`, `linkages`
1028+
with the family's frame, zero and sign stated once at its top, and an
1029+
eager flat re-export whose names are unique across families.
1030+
1031+
Three properties make it a subsystem rather than a utility drawer. Every
1032+
law is a **composition over `solid_node.math`** and arithmetic, so it has
1033+
that module's numeric and symbolic faces and emits **no OpenSCAD builtin
1034+
the parity corpus above does not already pin** — the `mechanisms` spec
1035+
requires that, so a law wanting a new builtin must go through `math.py`
1036+
first. A gear library's convention enters as **reference angles**
1037+
(`driver_gap`, `driven_tooth`), not as a fork of the law or a read of a
1038+
gear object. And there is **no declared face**: the laws carry degree
1039+
literals the ADR-062 algebra cannot type as angles, so a declared token
1040+
reaching one raises `DimensionError` at class definition, with `.value`
1041+
as the documented way through.
1042+
10211043
## Load-bearing invariants
10221044

10231045
The short list that changes must not silently break:
@@ -1117,6 +1139,7 @@ The short list that changes must not silently break:
11171139
| Node model | `solid_node/node/`, `solid_node/exact.py` | `node-model`, `exact-geometry`, `flexible-parts` | 001–004, 006, 026, 044–045, 047, 053–055, 057 |
11181140
| Build parameters | `solid_node/parameters.py`, `node/declarative.py` | `declarative-nodes` | 061–065 |
11191141
| Kinematics | `node/operations.py`, `node/assembly.py`, `math.py` | `kinematics` | 008, 022, 023, 028 |
1142+
| Mechanisms | `solid_node/mechanisms/` | `mechanisms` | 022, 076 |
11201143
| Build pipeline | `solid_node/core/` | `build-pipeline` | 005–007, 018, 026 |
11211144
| CLI | `cli.py`, `solid_node/manager/` | `cli` | 021, 024, 068 |
11221145
| Test framework | `solid_node/test.py`, `manager/test.py` | `test-framework` | 009–011, 025, 029, 040, 048, 052, 070, 073 |

docs/changelog.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ with a declared parameter, which used to render the declaration's
3636
``repr()`` into the published expression, now raises. See
3737
:ref:`Non-linear kinematics <non-linear-kinematics>`. (OpenSpec change
3838
``expression-math``; ADR-022, revised.)
39+
**The mechanism laws, carried once.** ``solid_node.mechanisms`` holds
40+
the textbook geometry thirteen projects' ``kinematics.py`` kept
41+
rewriting, each in its own frame and sign convention: the external
42+
spur-gear mesh and its inverse (``meshed_angle``, ``driving_angle``),
43+
the lead screw (``screw_travel``, ``screw_angle``), the planar
44+
slider-crank (``crank_pin``, ``crank_rod_angle``, ``piston_height``),
45+
linear delta kinematics (``delta_carriage``, ``delta_rod``) and the
46+
circle geometry a linkage asks for (``circle_intersection``,
47+
``triangle_angle``, ``link_rise``). Every one is a composition over
48+
``solid_node.math``, so it computes on numbers and builds the viewer's
49+
expression on symbolic time or a driver, and emits no OpenSCAD builtin
50+
that module does not already emit. A gear library's convention is two
51+
argument values rather than a fork of the law: cq_gears' gap centre at
52+
``180 / teeth``, MrBunsy's at ``gap_angle / 2``. There is no declared
53+
(class-body) face — the laws carry degree literals the dimension
54+
algebra cannot type as angles, so a declared token raises there and
55+
``.value`` is the way through. Lifted from ``sandbox/gearbox``,
56+
``3DPrintedClocks`` (``wall_clock_01`` and the grasshopper),
57+
``v8-engine``, ``kossel``, ``openflexure-microscope``, ``Inmoov-sim``
58+
and ``snappy-reprap``, and checked against each of their own functions.
59+
(OpenSpec change ``mechanisms``.)
3960

4061
**Several models in one project.** A manifest may declare its models by
4162
name in ``[tool.solid-node.models]``, with ``model`` naming the default
File renamed without changes.
File renamed without changes.
File renamed without changes.

openspec/changes/mechanisms/specs/mechanisms/spec.md renamed to openspec/changes/archive/2026-09-06-mechanisms/specs/mechanisms/spec.md

File renamed without changes.

0 commit comments

Comments
 (0)