Repository navigation
Releases
v4.0.0 — the escapement as its own exhibit
Three modes where there were two, three escapement variants where there were two, and the escapement's parts built once instead of three or four times.
The exhibit
A third mode, and an exhibit rather than a third view of the movement. Inside the movement the escapement runs at movement scale and movement speed, where lock, unlock, impulse and drop blur into one flip. Here a single lever escapement stands alone and large, with play/pause, a step to the middle of the next stage, a phase readout, and two camera homes.
It runs on solved contact, not on a schedule. The balance is the input, its pin drives the lever, and the wheel follows the pallets — which are not shapes with chosen angles but the conjugate profiles of the action: for every lever angle in a phase, the acting tooth's toe is put where that phase says the wheel stands, and the run of those places is the face. Contact is then true by construction, and a polygon intersection that knows nothing of the construction checks it.
What comes out of that, measured rather than set:
| a beat advances the wheel | exactly half a pitch (12.0000°) |
| draw | 12°, with the recoil solved from it per stone |
| lift | 50.25° |
| contact gap | 1e-5 |
| penetration | 0.0000 |
The safety action is there too: the guard pin catches the lever if it is knocked off the banking, the crescent is the one place it may pass, and both its half-angle and its depth are measurements of that passage rather than shapes drawn to look right. You can push the lever and watch the guard hold it.
The exhibit is isolated by a test, not by agreement: nothing under src/showcase/ may import from the movement, the socket or the lesson layer.
The double-axis tourbillon
The third escapement module, and the one the socket was built for. Its three drive ratios must multiply to exactly 1 or the beat drifts — a constraint on the tooth counts, not a number anyone picked — and inside it the inner cage turns twice for every turn of the outer, which is what a second axis is for.
Its behaviour table reports a dash where no honest number exists rather than a plausible one.
One part, one drawing
The socket implied it and nobody enforced it, so the escapement's common parts were drawn three and four times over, and the copies drifted in ways nobody decided. Each is now built once, with a guard that compares construction rather than one number about it:
- the balance (
escapement/balance.js) — the drift surfaced sideways, in a cost table that called the newest module cheaper than the one it is meant to exceed; - the pallet fork (
escapement/fork.js) — the harder case, since it is not the same part in three places but the same parts in three arrangements: sections quoted at a reference wheel and scaled by each module's own escape wheel; - the balance wheel (
parts/balanceWheel.js) — this one crossed between the two models, so it lives on neutral ground that neither owns.
Also
- The lever is cut as one piece — one closed outline, not plates bolted together.
- A watch that has run down says so, instead of looking like a program that has stopped.
- The power-reserve station admits that real time tops the barrel up by exactly what each beat spends, so the watch never stops in that mode.
- Leaving the escapement model brings the movement back in frame rather than keeping the exhibit's zoom.
- English is the repository's one language, enforced by a guard rather than asserted in prose.
init-version/— the early snapshot — is gone.
181 tests.
v3.0.0 — the escapement becomes a socket
The escapement stops being one module and becomes a socket holding one of
several. The app now opens with a plain Swiss lever escapement; a tourbillon
can be installed in its place, and a double-axis tourbillon is shown as the
next step without pretending to exist.
The point is not configurability. It is that one function can be filled by
units of wildly different complexity — and that in this model the extra
complexity buys nothing. Swapping the escapement in front of the viewer moves
no number on the station card, because the card now shows only what a swap
cannot move. That is the strongest statement of the station's own claim: the
rate is set by the balance, not by the construction.
What the model can show honestly is the cost — and it measures it rather
than claiming it.
| parts | of them moving | nested rotations | footprint | |
|---|---|---|---|---|
| Lever escapement | 18 | 16 | 1 | 4.73 × 2.95 |
| Tourbillon | 50 | 48 | 2 | 4.32 × 5.20 |
| Double-axis tourbillon | — | — | — | — |
Every figure comes from walking that module's own scene graph. The planned
module has none, because there is nothing to walk yet and inventing them would
break the rule the rest of the app lives by.
One result worth stating plainly: the lever's footprint is wider. Its
balance is carried out to one side while the cage keeps everything inside its
rim. The cost of complexity shows in part count, in how much of it moves, in
nesting and in height — not in width. A test pins that, so it does not get
tidied into a neater story later.
What is in it
- The socket. All modules are built at startup, exactly one is installed,
and only the installed one is updated. A module hands the socket a rotating
group, a fixed group andupdate() → β; how many nested cages it has, and
about which axes, is its own business — which is what lets a double-axis
module arrive later without touching the socket. - The lever escapement, restored from v0.5.0 (the tourbillon had deleted
it) and adapted to the current contract. The parts swap places: in a
tourbillon the balance is central and the escape wheel orbits; in a lever the
escape wheel is central — the train drives it directly — and the balance
moves out to that same 2.6. - The Варіанти modal on station 4: three modules with silhouettes, the cost
table, one honesty line, Лишити як є / Змінити. The mechanism keeps running
while it is open — pausing would break the very thing being shown — and the
camera flies to the escapement after a change. - The beat maths lives once. It had already been copy-pasted verbatim
between v0.5.0's escapement and the tourbillon — same constants, same
smoothstep. Identical timing across modules is now structural rather than a
rule someone has to remember.
Fixed along the way
- Units were never translated. "12 с" stayed Cyrillic in English: the unit
was written intostations.jsrather than the dictionary, and key parity
cannot see a string that never reaches one. - "seconds wheel" contradicted "Fourth wheel" — the same part named two
ways, which the project's own notes warn about specifically.
Both predate this release.
How it was verified
Stage one of the branch was a pure refactor, and every commit in it was checked
by a scene-graph diff against main — the world transform of every mesh
across idle, demo, manual wind and auto-wind. 704 meshes, zero differences.
That is how a crown-spin regression was caught once before, when the test suite
alone had missed it.
89 tests, up from 68. Six of them carry the premise directly: exactly one
module installed, visible and updated; identical β across modules; the
station's figures unchanged whichever is installed; the cost metrics derived
rather than typed — checked by rebuilding the socket with a different cage
radius and watching the measurement follow.
v2.1.2 — housekeeping
Housekeeping. Nothing in the running app changes — same scene, same
kinematics, same numbers. Three things that would have cost more later than
they cost now.
Changed
Both workflows run on Node 24 actions
Every action in both workflows targeted Node 20, which the runner already
forced onto Node 24 with a deprecation warning on every run; when support is
dropped, the tests and the Pages deploy would both have stopped.
| before | after | |
|---|---|---|
actions/checkout |
v4 | v7 |
actions/setup-node |
v4 | v7 |
actions/configure-pages |
v5 | v6 |
actions/upload-pages-artifact |
v3 | v5 |
actions/deploy-pages |
v4 | v5 |
The fourth name in the warning, upload-artifact@v4, was transitive:
upload-pages-artifact@v5 now calls v7 internally. Nothing in the majors
between affects this repo — setup-node's new automatic caching is overridden
by the explicit cache: npm (and package.json has no packageManager
field), and checkout's fork-PR blocking applies to pull_request_target and
workflow_run, which neither workflow uses.
Removed
springMat, which drew nothing
The LineBasicMaterial outlived the last Line in the scene. The mainspring
and the hairspring are both solid (springSteel) and the click spring clones
steel, so springMat was threaded from main.js through buildMovement
into tourbillon.js without ever rendering; its only remaining use was the
unreachable half of springSteel || springMat.
The hairspring material is now simply a springSteel clone, and the
isLineBasicMaterial guard around it goes too. Verified identical —
MeshStandardMaterial, #bfd4ff, roughness 0.22, metalness 0.95 — and the
scene holds 176 meshes and zero lines.
Fixed
Three stale details in the README: the balance radius was still documented as
≈1.5 where the mesh has 1.95 (and is now derived from the cage radius rather
than set on its own), the cage was still described as two cut-out plates, and
the free-mode control list did not mention cage opacity or the top plate.
68 tests.
v2.1.1 — the hairspring stops churning
Two things the visual overhaul in v2.1.0 left behind. No new features — the
app looks and behaves as before, it just costs a lot less per frame and
speaks English when asked to.
Fixed
The hairspring rebuilt its whole mesh every frame
updateHair() created a fresh CatmullRom curve and a whole TubeGeometry
on every frame, discarding the previous one — ~5.7 KB of garbage and ~285
µs per frame, more than the rest of the movement put together, plus a full
GPU buffer re-upload each frame.
The tube grid (121×9 vertices, indices, UVs) is now built once, and only
position and normal are rewritten in place in preallocated
Float32Arrays. The CatmullRom curve is gone entirely: the spiral's shape is
analytic, so it can be evaluated directly at the points needed.
| before | after | |
|---|---|---|
setTime per frame |
394.7 µs | 140.5 µs |
| garbage per frame | 7429 B | 1533 B |
| the hairspring's own share | ~285 µs, ~5.7 KB | ~32 µs, 0 B |
Two supporting decisions: cross-sections are framed from the Z axis
(N = T × Z, B = T × N) rather than by Frenet — the spiral's tangent never
turns vertical, so this is stable, cheaper, and does not twist the section
along the coil; and the bounding sphere is computed once, since the spiral
breathes within fixed bounds.
Verified against the analytic curve at four balance angles: axis, tube
thickness and normal length all match to Float32 precision (~3·10⁻⁸). Two
tests keep it that way — one asserts the geometry object and its buffer
survive a frame while the vertices move, the other that the tube keeps a
constant thickness at any balance angle.
The cage controls named themselves in Ukrainian
The two controls added in v2.1.0 wrote their labels as literals, so they
stayed Ukrainian in English. They now go through i18n. Key parity cannot
catch this — a string written straight into .name() never reaches a
dictionary at all — so a test scans main.js for Cyrillic in .name()
literals, allowing only the language button's own "УКР".
Cage opacity and top plate reset on a language change
Switching language rebuilds the GUI, and that state lived inside the builder
while the cage kept whatever had been applied to it.
68 tests.
v2.1.0 — finish and detail
Finish and detail. The movement was already correct; this release is about
what the eye gets — and about the render loop the lesson layer had quietly
made expensive.
Added
- The tourbillon cage in detail — plates extruded with a bevel, pillars
with caps and screws, timing screws on the balance rim, and pallet stones
as a physical material with transmission, so the rubies read as stones. - The hairspring is a body, not a line — a swept tube with a Breguet
overcoil, studded to the cage at one end and the balance at the other. - Winding you can read — a thickened ratchet, a 36-ridge knurled crown
with bevels, cap and gasket, and an extruded click lever with its ruby and
spring. Bevel-gear teeth taper toward the apex while preserving the cone
invariant. - Two cage controls in free mode — cage opacity and top-plate
visibility, so the escapement inside can actually be seen. - A spot light on the cage: blued steel and rubies stand out without blowing
out the brass.
Fixed
- The render loop allocates again — as in, no longer.
readouts()ran
every frame and rebuilt a dozen objects: 807 B per frame, 2.8 MB a minute,
against a zero-allocation property this codebase had been measured to
hold. Mechanism constants are now computed once, rounding no longer goes
throughtoFixed, and the panel passes a reusable buffer. 807 B → 23 B
per frame, 3.6 µs → 0.06 µs. Free mode skips the lesson update entirely:
it is the pre-lesson app and should not pay for the lesson. - The side view kept its own copies of the mechanism's dimensions. The
cage radius, the central-seconds module, hand lengths and the differential
radii were typed intosection.js, contradicting the promise in that
file's own header. Each is now derived from the module that owns it —
which surfaced a real error: the balance rim was drawn at 1.5 where the
mesh has 1.95. - Dimmed material clones ignored a later wireframe toggle. Turning the
wireframe on in free mode and returning to a station left the dimmed parts
solid while the highlighted ones were wireframe. - Two Ukrainian strings in the test suite closed their quotes early, so Vite
could not parse the file and half the suite silently never ran.
Removed
createHighlighter().dispose()— dead code implying a managed lifecycle
the app does not have.
65 tests. Kinematics verified with a scene-graph diff against the previous
build: 704 meshes across four states, zero differences.
v2.0.0 — guided exploration
v2.0.0 — 2026-09-06
A guided layer over the simulator. On open the app is no longer a bare
scene with a panel of engineering knobs: it is a route through the
movement, and the old app is one click away.
Added
- Explore mode — six stops in the order energy flows: winding, barrel,
going train, escapement, time display, power reserve. Each stop dims the
rest of the movement, flies the camera to its node, and shows a card:
the key idea, the formula, one control to turn. - Live figures. No number in a card is typed.
readouts.jsderives
every figure from the same constants that build the geometry, so a card
cannot drift from the mechanism. This caught a false claim during
development: a card said the seconds wheel "still" takes 32 s at any
rate — both periods scale with the rate; only their ratio does not. - Two honesty markers. Green names the test that backs the claim, and a
meta-test asserts every referenced test exists, so the badge cannot
quietly become a lie. Amber marks where the model departs from horology. - Side view — a developed section along the chain, not a projection: an
orthographic side view collapses Y and would drop the tourbillon cage on
the differential. Heights are true, arbors sit at their real centre
distances, so every pinion touches its neighbour exactly at the pitch
circles. Vertical exaggeration is measured, not asserted. - Summary. Each stop leaves a sentence; the end assembles all six into
a description of the going, and hands over to free mode. - Ukrainian and English throughout, including the 3D labels and the
free-mode panel. Wheel names are horological, not literal: the third and
fourth wheels are not "intermediate" and "seconds". - Interface fonts ship with the repo (OFL 1.1), so typography does not
depend on the network. - The version is shown in the header, taken from
package.jsonat build.
Changed
- Free mode is the v1.1.0 app unchanged — verified by a scene-graph
diff across four states, zero differences — reachable from the header
and from the end of the route, with its own way back. - Modules tag what they create (
userData.mod), which is what lets a
station light the barrel without the rest of the train: scene groups are
mixed by purpose, so dimming by group could not express a station. - 59 tests (was 34).
v1.1.0
Changed
movement.jssplit into per-subsystem modules. It was 798 lines with a single
~750-linebuildMovement; it is now a ~230-line composer overtrain.js,
barrel.js,motionWorks.js,winding.jsandpowerReserve.js(plus
common.jsfor shared constants and helpers), mirroring the documented modules.
Each module is two-phase:layout…()returns positions and its own extents,
so bounds and the plate radius are known before any mesh exists;build…()then
creates meshes and returns its ownupdate(). Behaviour is unchanged — a
scene-graph diff against the previous build (660 meshes across six states) shows
zero differences.- The escape arbor is labelled "Анкерний вузол" rather than "Анкерне колесо":
it carries no escape wheel any more, it is the tourbillon cage.
Fixed
- Removed a duplicate label: the escape arbor and the tourbillon cage share one
axis, so their two 3D labels overlapped. The axis is now labelled only
"Турбійон". - Merged the duplicate entry in the node-visibility list for the same reason —
one "Турбійон" toggle instead of "Анкерне колесо" + "Турбійон (кліть)".
Docs
- Live demo link and GitHub Pages deployment;
npm run servefor a static build. - Corrected the run time: a full wind is ≈ 214 s of demo time, not 160 s — that
figure is the run from the default starting charge$c_0 = 0.75$ . - Documented that the fixed wheel's post and the cage arbor are coaxial and
interpenetrate (a real calibre would use a hollow cage arbor).
v1.0.0 — first complete release
First complete release — a browser 3D model of a simple mechanical watch movement (no dial), built procedurally with Three.js + Vite, with a headless Vitest suite locking in every kinematic invariant.
Mechanism
- Going train — barrel → center → third → seconds → escape arbor, exact gear ratios.
- Swiss lever escapement in a tourbillon — the whole escapement (escape wheel, pallet fork with ruby pallets, balance, hairspring) rides in a rotating cage; the escape pinion rolls around a fixed wheel. Timing is unchanged (
θ_cage = β), as a real tourbillon only averages error. - Motion works & hands — hour, minute and central seconds (12:1), raised to the top of the central staff.
- Open barrel — the drum is open so the coiled mainspring is visible; it tightens while winding and unwinds while running.
- Winding works — ratchet, crown wheel, a 90° bevel take-off to the winding stem, and an animated click.
- Power reserve differential — a real bevel differential (two suns, two planets, hand on the carrier) computes the remaining wind as the difference between winding and running; real-time mode acts as an auto-winder. Fan-shaped
sector sub-dial above the barrel. - Time modes — demo time (driven by the escapement) and real time (hands follow the system clock).
- Compact layered layout — the train is coiled into a tight loop; balance, motion works and central seconds sit on higher Z-layers.
Presentation
- Dark backdrop plate + blued-steel tourbillon cage for contrast.
- Node labels, camera presets (Overview, Hands, Power reserve, Tourbillon), visibility toggles, wind button and a live wind-% readout in a
lil-guipanel.
Engineering
- 34 headless tests (mesh invariants, train/motion-works/central-seconds ratios, escapement stepping, real-time hand angles, winding charge, bevel-pair tangency, differential condition, tourbillon cage, layout collision scan).
- GitHub Actions CI on every push.
- MIT licensed.