Skip to content

Releases

v4.0.0 — the escapement as its own exhibit

Choose a tag to compare

@OlegG90 OlegG90 released this 10 Sep 21:55

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

Choose a tag to compare

@OlegG90 OlegG90 released this 07 Sep 14:50

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 and update() → β; 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 into stations.js rather 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.

Live: https://olegg90.github.io/watch-simulator/

v2.1.2 — housekeeping

Choose a tag to compare

@OlegG90 OlegG90 released this 06 Sep 21:32

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.

Live: https://olegg90.github.io/watch-simulator/

v2.1.1 — the hairspring stops churning

Choose a tag to compare

@OlegG90 OlegG90 released this 06 Sep 21:09

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.

Live: https://olegg90.github.io/watch-simulator/

v2.1.0 — finish and detail

Choose a tag to compare

@OlegG90 OlegG90 released this 06 Sep 15:14

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
    through toFixed, 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 into section.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.

Live: https://olegg90.github.io/watch-simulator/

v2.0.0 — guided exploration

Choose a tag to compare

@OlegG90 OlegG90 released this 06 Sep 06:55

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.js derives
    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.json at 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

Choose a tag to compare

@OlegG90 OlegG90 released this 06 Sep 05:38

Changed

  • movement.js split into per-subsystem modules. It was 798 lines with a single
    ~750-line buildMovement; it is now a ~230-line composer over train.js,
    barrel.js, motionWorks.js, winding.js and powerReserve.js (plus
    common.js for 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 own update(). 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 serve for 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

Choose a tag to compare

@OlegG90 OlegG90 released this 23 Jul 20:21

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-gui panel.

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.