diff --git a/README.md b/README.md
index b69c711..af402d7 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ the same model that moves the parts. Left alone, each page plays a guided tour;
| | The machine | What drives it | Checked by |
|---|---|---|---|
+| [**Pepper**](machines/pepper) | The Curta Type I, the mechanical calculator that fits in one hand (8 sliders, a 6-digit counter, an 11-digit result): one stepped drum for every column, subtraction by the ten's complement, carries that run column after column, the carriage shifted to multiply, the clearing ring. A model after the public sources; not affiliated with any maker or rights holder. | The cumulative crank angle θ, and the discrete state between turns (sliders, crank up or down, carriage position). A plan of operations (1 234 × 56, one turn back) is laid out once. | `machines/pepper/check.mjs` |
| [**Quantum**](machines/quantum) | A photonic quantum computer: a quantum dot emits one photon per laser pulse, fibre delay lines bring them onto a 24-mode chip together, 276 Mach-Zehnder interferometers mix them, superconducting detectors click. A Hong-Ou-Mandel test on one coupler, the Perceval program behind the heaters. Modelled on the public figures of Quandela's Belenos; not affiliated with Quandela. | The physical time τ of the shot, in nanoseconds. The output distribution is computed from the permanents of the chip's unitary. | `machines/quantum/check.mjs` |
| [**Spindl**](machines/spindl) | A 3.5-inch 7,200 rpm desktop hard drive (2 platters, 4 heads): spin-up, the heads loading off the ramp, a seek drawn live, the rotational wait, the 3 nm flying height, the emergency park. A model of a typical drive, not a specific product. | The drive time τ. A request queue (LBA → zone, cylinder, head, sector) is planned once. | `machines/spindl/check.mjs` |
diff --git a/machines/pepper/FILM.md b/machines/pepper/FILM.md
new file mode 100644
index 0000000..038f215
--- /dev/null
+++ b/machines/pepper/FILM.md
@@ -0,0 +1,29 @@
+# The film
+
+The tour (38 s) is recorded frame by frame from `machines/pepper/curta-film`, the same code as the page, with the
+capture tools of the teardown skill ([whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion)). They need
+the skill installed (`SKILL_DIR`), headless Chrome with a real GPU, `ffmpeg` and Python 3.
+
+```sh
+cd machines/pepper
+node curta-shared/make-timeline.mjs # curta-film/timeline.json (beats), sounds.json (every tick)
+node "$SKILL_DIR/sims/build.mjs" ./curta-film
+echo '{"lang":"en"}' > /tmp/en.json # or fr / es
+node "$SKILL_DIR/sims/capture.mjs" curta-film --timeline curta-film/timeline.json --config /tmp/en.json --size 1600x900 --dpr 1.2 --stills beats --cols 4 --out teardown-out/beats.png
+node "$SKILL_DIR/sims/capture.mjs" curta-film --timeline curta-film/timeline.json --config /tmp/en.json --size 1600x900 --dpr 1.2 --out teardown-out/silent.mp4
+python3 "$SKILL_DIR/run_sfx.py" curta-shared/score.py # teardown-out/pepper-score.wav, mastered
+ffmpeg -y -i teardown-out/silent.mp4 -i teardown-out/pepper-score.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart teardown-out/pepper-en.mp4
+```
+
+The share card is frame 0 of the film, scaled and cropped to 1200 × 630, without metadata:
+
+```sh
+node "$SKILL_DIR/sims/capture.mjs" curta-film --config /tmp/en.json --size 1600x900 --dpr 1.2 --still 0 --out teardown-out/frame0.png
+ffmpeg -y -i teardown-out/frame0.png -vf "scale=1200:675,crop=1200:630:0:22" -map_metadata -1 og.png
+```
+
+On macOS, Playwright's headless shell may get no WebGL context; point the capture at Chrome:
+`CHROMIUM="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"`.
+
+`{"lang":"en","clean":true}` records the machine without the sheet. The kit's film build inlines Inter and
+JetBrains Mono only; `curta-film/main.js` brings in Instrument Serif itself.
diff --git a/machines/pepper/REFERENCES.md b/machines/pepper/REFERENCES.md
new file mode 100644
index 0000000..c1fb11d
--- /dev/null
+++ b/machines/pepper/REFERENCES.md
@@ -0,0 +1,67 @@
+# Pepper: references
+
+Pepper is a model of the Curta Type I mechanical calculator (Curt Herzstark, Contina AG, Mauren, Liechtenstein, from
+1948), built from public sources: encyclopedias, a period article, a teaching page and drawings of the internals. It
+is not affiliated with any maker or rights holder, and it is not a measured reproduction: where a figure is not
+public, the model uses a plausible value, listed below as an estimate.
+
+The letters match the comments in [`curta-shared/machine.js`](curta-shared/machine.js) (the figures and the
+arithmetic) and [`curta-shared/curta.js`](curta-shared/curta.js) (the geometry).
+
+## Sources
+
+- **[W]** Wikipedia, [Curta](https://en.wikipedia.org/wiki/Curta). Type I: 8 setting sliders, a 6-digit revolution
+ counter, an 11-digit result; Type II: 11 / 8 / 15. 230 g (Type I), 360 g (Type II). About 80,000 Type I and
+ 60,000 Type II made; introduced 1948 (Type II 1954), the last one in 1972 according to Herzstark. German patent
+ DE 747073 (filed 19 August 1938). Pulling the crank up before turning subtracts; subtraction by nines' complement.
+- **[F]** Wikipédia, [Curta](https://fr.wikipedia.org/wiki/Curta). Type 1: 8.5 cm high, 5.3 cm across, 230 g,
+ ~600 parts; made 1948–1970, ~80,000 Type 1. The drum carries a second set of teeth, interleaved, each row with the
+ nine's complement of its digit, engaged by moving the drum (and the crank, which is fixed to it) a few millimetres
+ in height; the first column takes the ten's complement. Carries: a pin on each result wheel presses a carry lever
+ as the wheel passes 9 → 0, the lever shifts a pinion on the next column's shaft into the path of the single tooth
+ of the carry bell, fixed to the drum; a cascade propagates during the turn; a reset cam lifts the levers at the
+ start of the next turn; a ratchet stops the crank turning backwards. Clearing: a top disc with a fold-out lever,
+ two racks, the carriage lifted to disengage the wheels. The carriage lifts, turns, and drops into a position.
+- **[V]** "The Curta", a period article reprinted at [vcalc.net](https://vcalc.net/cu-pe-mag.htm): 19 rows of teeth
+ on the drum (9 for addition, 9 complementary, 1 row of ten teeth for the first digit's true complement, met by an
+ extra cog on that column's axle), the handle extended to raise the drum for subtraction, the carry by transfer
+ pin, slide and pinion against a single tooth above the drum; capacity 8 × 6 × 11; 6 carriage positions.
+- **[H]** E. Hamann, [Curta](https://public.beuth-hochschule.de/hamann/curta/index.html), Beuth Hochschule Berlin:
+ 53 mm across, 105 mm high (with the crank), 230 g; the carriage turns only when lifted; the crank turns clockwise
+ only; a negative result shows as the complement of the next power of ten (−12 as 99…9988).
+- **[M]** Wikimedia Commons, [Category: Curta Internal Details](https://commons.wikimedia.org/wiki/Category:Curta_Internal_Details)
+ (drawings by Manuco, CC0): the carry lever's actuation and reset cam, carry propagation, the reset racks, the
+ doubly stepped drum. The geometry of the carry levers, the digit drums lying on their sides and the racks follows
+ these drawings.
+- Photographs used to build the outside (not redistributed here): Wikimedia Commons
+ [Curta01.JPG](https://commons.wikimedia.org/wiki/File:Curta01.JPG) (a Type I in the hand) and
+ [Curta Mechanical computer img 1651.jpg](https://commons.wikimedia.org/wiki/File:Curta_Mechanical_computer_img_1651.jpg)
+ (front view: knurled base and carriage, slotted body with its sliders and rods, the CURTA band, the crank and the
+ clearing lever).
+
+Not consulted: the service manuals collected at curta.org (Rick Furr) and Herzstark's patent text, which would
+settle the estimates below.
+
+## Checked in `check.mjs`
+
+The registers' capacity (99,999,999,999 / 999,999 / 99,999,999), the film's multiplication (1,234 × 56 = 69,104 in
+6 + 5 turns, the counter reading 56), the subtraction by the ten's complement (one turn back in position 2 adds
+10¹¹ − 12,340, 10 − 4 teeth on the first column, 9 − d on the others, and leaves 56,764 = 1,234 × 46), the carry
+cascade of that turn (columns 5 to 11 in order, and one off the top), …999 + 1 = 0 with 10 carries, the timing of the
+carries over a turn, and 3,000 random turns against plain arithmetic modulo 10¹¹ and 10⁶.
+
+## Estimates **[E]**
+
+| Figure | Value | Why |
+|---|---|---|
+| Stations around the drum | 16, 22.5° apart: 8 slider shafts, 3 carry-only shafts, 5 parked places | The model needs every result wheel to sit over a shaft or a parked place in all 6 carriage positions; the real machine spreads its 8 sliders over a wider arc than the model's 157°. |
+| Columns above the 8th slider in subtraction | take 9 teeth, as a slider set to 0 would | The sources say only that a negative result reads as the complement of 10¹¹ (99…9988); how the mechanism feeds those 9s is not described in what was read. The arithmetic is exact either way. |
+| Timing over a turn | teeth from 15°, 10° apart; the carry tooth meets station s at 105° + 22.5°·(s − 1); the counter steps at 338° | Chosen so each station's teeth end before its carry and the carries run in order; the sources say only that carries are sequential, during the turn. |
+| Row pitch, drum shift | 2.4 mm, 1.2 mm (half a row) | "A few millimetres" [F]; the shift is half the pitch by construction [V]. |
+| Carriage lift | 3.2 mm | Not given. |
+| Ratchet teeth | 12 per turn | The ratchet is described [F], not its count; it sets the ratchet ticks of the sound. |
+| Counter carries | in the same step as its digit | The counter's own carry train is not modelled. |
+| Interior proportions | drum Ø 12 mm, shafts on a 9.7 mm radius, digit drums Ø 8.4 mm | Scaled to fit the 53 mm body; not measured. |
+
+The brief asked for the drum to move down when the crank is pulled; the sources say it rises with the crank [V][F],
+so it rises here.
diff --git a/machines/pepper/check.mjs b/machines/pepper/check.mjs
new file mode 100644
index 0000000..8c5c9ca
--- /dev/null
+++ b/machines/pepper/check.mjs
@@ -0,0 +1,125 @@
+// Recomputes, from the same functions the page draws with, every figure Pepper shows, and fails if one is wrong:
+// the registers' capacity, the multiplication of the film, the subtraction by the ten's complement, a carry cascade
+// from …999 + 1, the order and timing of the carries over one turn, and 3,000 random turns against plain arithmetic.
+// node machines/pepper/check.mjs (CI runs it on every push).
+import {rng} from '../../kit/util.js';
+import {CURTA, G, addendOf, createPlan, fromDigits, stationOf, thetaCarry, thetaTooth, toDigits, turnEvents} from './curta-shared/machine.js';
+import {SETTING, filmFacts, filmPlan} from './curta-shared/schedule.js';
+
+let bad = 0;
+const eq = (name, v, want) => {
+ const ok = v === want;
+ if (!ok) bad++;
+ console.log(`${ok ? 'ok ' : 'BAD'} ${name.padEnd(54)} ${v}${ok ? '' : ` (want ${want})`}`);
+};
+const near = (name, v, lo, hi, unit = '') => {
+ const ok = v >= lo && v <= hi;
+ if (!ok) bad++;
+ console.log(`${ok ? 'ok ' : 'BAD'} ${name.padEnd(54)} ${v} ${unit} [${lo} … ${hi}]`);
+};
+
+// ---- the figures [W][F][H]
+eq('setting sliders (Type I)', CURTA.sliders, 8);
+eq('counter digits (Type I)', CURTA.counter, 6);
+eq('result digits (Type I)', CURTA.result, 11);
+eq('Type II: 11 · 8 · 15', `${CURTA.typeII.sliders} · ${CURTA.typeII.counter} · ${CURTA.typeII.result}`, '11 · 8 · 15');
+near('diameter', CURTA.diameter, 52, 54, 'mm');
+near('height', CURTA.height, 84, 86, 'mm');
+near('mass', CURTA.mass, 228, 232, 'g');
+near('Type I made', CURTA.made, 80000, 80000);
+// capacity: the largest number each register holds
+eq('result capacity', 10 ** CURTA.result - 1, 99999999999);
+eq('counter capacity', 10 ** CURTA.counter - 1, 999999);
+eq('setting capacity', 10 ** CURTA.sliders - 1, 99999999);
+// the carriage turns through as many positions as the counter has digits; the setting reaches the top column
+eq('carriage positions = counter digits', CURTA.counter, 6);
+
+// ---- the film: 1 234 × 56, then one turn back
+const plan = filmPlan();
+const f = filmFacts(plan);
+eq('setting', f.set, 1234);
+eq('6 turns in position 1 (counter)', f.m1, 6);
+eq('… then 5 in position 2 (counter)', f.m2, 56);
+eq('1 234 × 56', f.r1, 1234 * 56);
+eq('one turn back in position 2: 69 104 − 12 340', f.r2, 69104 - 12340);
+eq('counter after the subtraction', f.c2, 46);
+eq('1 234 × 46 = the result', f.r2, 1234 * 46);
+const sub = plan.turns[plan.turns.length - 1];
+// the ten's complement: 9 − d in each column, 10 − d in the first, over the 11 columns from the carriage position
+const add = addendOf(sub.set, sub.p, -1);
+const addV = add.reduce((a, x, i) => a + (x ?? 0) * 10 ** i, 0);
+eq('addend = 10^11 − 12 340', addV, 10 ** 11 - 12340);
+eq('first column takes 10 − 4 teeth', add[sub.p - 1], 6);
+eq('…the column above takes 9 − 3', add[sub.p], 6);
+// the carries of that turn: one per column from the 5th to the 11th, in order, and one off the top
+eq('carries in the subtraction turn', sub.carries.map((c) => c.col).join(','), '5,6,7,8,9,10,11');
+eq('overflow off the 11th column', !!sub.overflow, true);
+let order = true;
+for (let i = 1; i < sub.carries.length; i++) if (!(sub.carries[i].at > sub.carries[i - 1].at && sub.carries[i].from < sub.carries[i].at && sub.carries[i].from > sub.carries[i - 1].from)) order = false;
+eq('carries in order, each after the 9 → 0 that set it', order, true);
+
+// ---- …999 + 1: the longest cascade
+{
+ const reg = new Array(CURTA.result).fill(9);
+ const ev = turnEvents(reg, new Array(CURTA.counter).fill(0), [1, 0, 0, 0, 0, 0, 0, 0], 1, 1);
+ eq('99 999 999 999 + 1 = 0 (11 digits)', fromDigits(ev.after), 0);
+ eq('…with 10 carries', ev.carries.length, 10);
+ eq('…and the 1 off the top', !!ev.overflow, true);
+ near('…last carry, crank angle', thetaCarry(11), 330, 330, '°');
+}
+
+// ---- the timing over one turn: every station's teeth end before its carry, the carry tooth runs in order, the
+// counter steps last, everything inside 360°
+{
+ let ok = true;
+ for (let s = 1; s <= G.active; s++) {
+ if (s > 1 && thetaCarry(s) <= thetaCarry(s - 1) + G.carryDur) ok = false;
+ // a wrap in column s (teeth or carry) always comes before the carry tooth reaches s + 1
+ if (Math.max(thetaTooth(s, s === 1 ? 9 : 8) + G.toothDur, thetaCarry(s) + G.carryDur) >= thetaCarry(s + 1)) ok = false;
+ }
+ eq('teeth, then carries in order, per station', ok, true);
+ near('carries span (crank angle)', thetaCarry(G.active) - thetaCarry(2), 0, 240, '°');
+ near('counter step after the last carry', G.counterAt - (thetaCarry(G.active) + G.carryDur), 0, 30, '°');
+ near('the complement shift = half a row', G.shift / G.rowPitch, 0.5, 0.5);
+}
+
+// ---- 3,000 random turns against plain arithmetic, mod 10^11 and 10^6
+{
+ const r = rng(1948);
+ let ok = true;
+ let reg = new Array(CURTA.result).fill(0);
+ let ctr = new Array(CURTA.counter).fill(0);
+ let R = 0n;
+ let C = 0;
+ const M = 10n ** 11n;
+ for (let k = 0; k < 3000; k++) {
+ const set = Array.from({length: 8}, () => Math.floor(r() * 10));
+ const p = 1 + Math.floor(r() * 6);
+ const sign = r() < 0.4 ? -1 : 1;
+ const ev = turnEvents(reg, ctr, set, p, sign);
+ reg = ev.after;
+ ctr = ev.ctrAfter;
+ R = (((R + BigInt(sign) * BigInt(fromDigits(set)) * 10n ** BigInt(p - 1)) % M) + M) % M;
+ C = (((C + sign * 10 ** (p - 1)) % 1e6) + 1e6) % 1e6;
+ const got = BigInt(reg.reduce((a, x, i) => a + BigInt(x) * 10n ** BigInt(i), 0n));
+ if (got !== R || fromDigits(ctr) !== C) ok = false;
+ }
+ eq('3,000 random turns = arithmetic mod 10^11 / 10^6', ok, true);
+}
+// the live page's plan gives the same as the film's for the same steps
+{
+ const p2 = createPlan();
+ p2.add({kind: 'slide', t0: 0, t1: 1, to: SETTING});
+ p2.add({kind: 'turns', t0: 1, t1: 2, n: 6});
+ p2.add({kind: 'carriage', t0: 2, t1: 3, to: 2});
+ p2.add({kind: 'turns', t0: 3, t1: 4, n: 5});
+ eq('a plan built step by step', fromDigits(p2.end.reg), 69104);
+ eq('stations: column 1 in position 2 is parked', stationOf(1, 2), 0);
+}
+void toDigits;
+
+if (bad) {
+ console.error(`\n${bad} figure(s) wrong`);
+ process.exit(1);
+}
+console.log('\nevery figure checks');
diff --git a/machines/pepper/curta-film/main.js b/machines/pepper/curta-film/main.js
new file mode 100644
index 0000000..ba74c3c
--- /dev/null
+++ b/machines/pepper/curta-film/main.js
@@ -0,0 +1,9 @@
+// Pepper, the explorer film. Shared code in ../curta-shared. The kit's film build inlines Inter and JetBrains Mono
+// only; Instrument Serif (the sheet's titles) comes in here, as the site build gives it to the live page.
+import serif from '../../../node_modules/@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2';
+import {run} from '../curta-shared/film.js';
+
+const style = document.createElement('style');
+style.textContent = `@font-face{font-family:'Instrument Serif';font-weight:400;font-style:normal;font-display:block;src:url(data:font/woff2;base64,${serif}) format('woff2');}`;
+document.head.appendChild(style);
+document.fonts.load("400 20px 'Instrument Serif'").finally(run);
diff --git a/machines/pepper/curta-film/sounds.json b/machines/pepper/curta-film/sounds.json
new file mode 100644
index 0000000..14e0d6f
--- /dev/null
+++ b/machines/pepper/curta-film/sounds.json
@@ -0,0 +1 @@
+{"ratchet":[6.39,6.854,7.218,7.543,7.85,8.151,8.458,8.781,9.143,9.6,10.125,11.999,12.128,12.219,12.292,12.353,12.406,12.454,12.498,12.538,12.575,12.609,12.673,12.702,12.73,12.757,12.783,12.807,12.831,12.854,12.876,12.898,12.919,12.959,12.978,12.997,13.015,13.033,13.05,13.067,13.085,13.103,13.122,13.141,13.181,13.202,13.224,13.246,13.269,13.293,13.317,13.343,13.37,13.398,13.427,13.491,13.525,13.562,13.602,13.646,13.694,13.747,13.808,13.881,13.972,14.101,18.299,18.428,18.519,18.592,18.653,18.706,18.754,18.798,18.838,18.875,18.909,18.973,19.002,19.03,19.057,19.083,19.107,19.131,19.154,19.176,19.198,19.219,19.259,19.278,19.297,19.315,19.333,19.35,19.367,19.385,19.403,19.422,19.441,19.481,19.502,19.524,19.546,19.569,19.593,19.617,19.643,19.67,19.698,19.727,19.791,19.825,19.862,19.902,19.946,19.994,20.047,20.108,20.181,20.272,20.401,23.539,23.813,24.066,24.344,24.777,25.354,26.002,26.71,27.438,28.137,28.712],"home":[10.902,12.642,12.939,13.161,13.457,14.473,18.942,19.239,19.461,19.757,20.773,29.327],"carry":[{"t":12.764,"col":2},{"t":12.783,"col":3},{"t":13.252,"col":2},{"t":13.287,"col":4},{"t":13.646,"col":3},{"t":19.083,"col":4},{"t":19.101,"col":5},{"t":19.319,"col":3},{"t":19.913,"col":3},{"t":19.946,"col":4},{"t":25.203,"col":5},{"t":25.669,"col":6},{"t":26.175,"col":7},{"t":26.71,"col":8},{"t":27.256,"col":9},{"t":27.794,"col":10},{"t":28.303,"col":11}],"overflow":[28.838],"counter":[10.447,12.627,12.93,13.152,13.444,14.217,18.927,19.23,19.452,19.744,20.517,28.979],"slide":[2.698,2.829,2.916,3.047,3.016,3.161,3.306,3.353,3.547,3.739,34.198,34.286,34.344,34.432,34.411,34.507,34.604,34.635,34.765,34.892],"carriageLift":[15.336,31.096],"carriageNotch":[16.769,33.315],"carriageDrop":[17.304,33.616],"crank":[22.25,30.225],"ring":[[31.348,32.916]]}
\ No newline at end of file
diff --git a/machines/pepper/curta-film/timeline.json b/machines/pepper/curta-film/timeline.json
new file mode 100644
index 0000000..f3a4310
--- /dev/null
+++ b/machines/pepper/curta-film/timeline.json
@@ -0,0 +1 @@
+{"duration":38,"beats":{"views":[2,4.6,11.2,21.1,23.9,30.4,34.2],"firstTooth":6.2,"turnsEnd":14.6,"carriage":16.2,"subStart":23.8,"cascade":26.175,"overflow":28.838,"clear":32.3,"end":36.5}}
\ No newline at end of file
diff --git a/machines/pepper/curta-live/main.js b/machines/pepper/curta-live/main.js
new file mode 100644
index 0000000..8e1ddf3
--- /dev/null
+++ b/machines/pepper/curta-live/main.js
@@ -0,0 +1,5 @@
+import {runLive} from '../curta-shared/live.js';
+
+// Pepper as an interactive page: your hand on the crank, the carriage and the setting drums; the subsystems opened
+// one by one; the body solid, glass, cut or exploded; orbit and zoom; and an auto tour when left alone.
+runLive();
diff --git a/machines/pepper/curta-shared/console.js b/machines/pepper/curta-shared/console.js
new file mode 100644
index 0000000..ed9ec59
--- /dev/null
+++ b/machines/pepper/curta-shared/console.js
@@ -0,0 +1,371 @@
+// Pepper's overlay, laid out like a watchmaker's catalogue sheet: titles in Instrument Serif, fine rules, a lot of
+// air. Left: the name, the technical sheet, the crank's turn as a timing chart (each column's teeth and its carry on
+// the crank angle), the hand controls. Right: the three registers drawn as the real numbered drums (rolling), the
+// operation laid out by hand in columns, the subsystem, cut and view buttons. Callouts with thin leaders into the
+// machine. Fixed 1600 x 900 CSS layout; update(s) redraws every live part from the frame's state.
+import {CURTA, G, addendOf, fromDigits, stationOf, thetaCarry} from './machine.js';
+import {ACCENT} from './curta.js';
+import {nf, T as tr} from './i18n.js';
+
+export const C = {ink: '#1b1a17', mute: '#8a877f', faint: '#c9c5bb', line: '#d9d5cc', paper: 'rgba(246,244,239,.92)', acc: ACCENT, accSoft: 'rgba(176,122,28,.16)'};
+export const SUBS = ['crank', 'sliders', 'drum', 'gears', 'carry', 'carriage', 'reset'];
+export const CUTS = ['solid', 'glass', 'section'];
+export const VIEWS = ['hero', 'top', 'macro', 'axial'];
+export const ACTS = ['turn', 'pull', 'carL', 'carR', 'clear', 'pause'];
+const SERIF = `'Instrument Serif',Georgia,serif`;
+const CSS = `
+#app{font-family:Inter,system-ui,sans-serif;color:${C.ink};-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
+.ui{position:absolute;inset:0;pointer-events:none}
+.sf{font-family:${SERIF};font-weight:400;letter-spacing:-.005em}
+.cp{font-size:10px;font-weight:600;letter-spacing:1.6px;text-transform:uppercase;white-space:nowrap}
+.mu{color:${C.mute}}
+.rule{position:absolute;height:1px;background:${C.line}}
+.tb{position:absolute;height:24px;padding:0 8px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;letter-spacing:.5px;border:1px solid ${C.line};border-radius:12px;background:rgba(255,255,255,.55);color:${C.ink};white-space:nowrap}
+.call{position:absolute;white-space:nowrap}
+.call b{display:block;font-family:${SERIF};font-weight:400;font-size:30px;line-height:1;letter-spacing:-.01em}
+.call span{display:block;font-size:10.5px;font-weight:600;letter-spacing:1.5px;color:${C.mute};text-transform:uppercase;margin-top:5px}
+.cur{position:absolute;left:0;top:0;z-index:50;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))}
+.ring{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:1.5px solid ${C.acc};opacity:0;z-index:49}
+.reg{position:absolute;display:flex;gap:2px}
+.dg{position:relative;width:24px;height:36px;overflow:hidden;background:#141414;border-radius:2px;box-shadow:inset 0 6px 6px -4px rgba(0,0,0,.9),inset 0 -6px 6px -4px rgba(0,0,0,.9)}
+.dg.cream{background:#ece5d2;box-shadow:inset 0 6px 6px -4px rgba(0,0,0,.35),inset 0 -6px 6px -4px rgba(0,0,0,.35)}
+.dg i{position:absolute;left:0;right:0;top:0;display:block;font:600 21px/36px 'JetBrains Mono',monospace;text-align:center;color:#f4f1e8;font-style:normal;white-space:pre}
+.dg.cream i{color:#151515}
+.dg u{position:absolute;left:3px;right:3px;bottom:-1px;height:3px;background:${C.acc};opacity:0}
+.dgx{position:absolute;font-size:9px;font-weight:600;letter-spacing:1px;color:${C.mute};text-align:center;width:24px}
+.op{position:absolute;font-family:${SERIF};font-size:27px;line-height:30px;white-space:nowrap}
+.op span{display:inline-block;width:19px;text-align:center}
+.op .o{width:22px;text-align:left;color:${C.mute}}
+.op .lo{color:${C.faint}}
+.op .ac{color:${C.acc}}
+.op .cy{font-family:Inter,sans-serif;font-size:11px;font-weight:700;color:${C.acc};line-height:12px;height:12px}
+.spec{position:absolute;font-size:12.5px;line-height:21px}
+.spec div{display:flex;justify-content:space-between;border-bottom:1px solid rgba(217,213,204,.6)}
+.spec b{font-weight:600}
+.cap{position:absolute;left:376px;right:376px;bottom:30px;text-align:center;font-family:${SERIF};font-size:29px;line-height:1.15;color:${C.ink};text-shadow:0 0 14px #ece9e3,0 0 26px #ece9e3,0 0 4px #ece9e3}
+.cap .pill{background:rgba(239,237,232,.88);padding:1px 14px 3px;border-radius:3px;-webkit-box-decoration-break:clone;box-decoration-break:clone;line-height:1.3}
+.pane{position:absolute;background:linear-gradient(rgba(240,238,233,.93),rgba(236,234,228,.9));box-shadow:0 0 0 1px rgba(217,213,204,.55)}
+`;
+const el = (h) => {
+ const d = document.createElement('div');
+ d.innerHTML = h.trim();
+ return d.firstElementChild;
+};
+const STRIP = '0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n0';
+
+export function createConsole(app) {
+ const style = document.createElement('style');
+ style.textContent = CSS;
+ document.head.appendChild(style);
+ const ui = el(`
`);
+ app.appendChild(ui);
+ const add = (h) => {
+ const n = el(h);
+ ui.appendChild(n);
+ return n;
+ };
+ const btn = {};
+ // the sheet's paper, so the columns stay readable over the black body in the close views
+ add(`
`);
+ add(`
`);
+
+ // ------------------------------------------------------------ left: name, sheet
+ const X = 46;
+ const W = 300;
+ add(`${tr('title')}
`);
+ add(`${tr('subtitle')}
`);
+ add(`${tr('model')}
`);
+ add(`
`);
+ add(`${tr('sheet')}
`);
+ const row = (k, v) => `${k} ${v}
`;
+ add(`
+ ${row(tr('specReg'), `${CURTA.sliders} · ${CURTA.counter} · ${CURTA.result}`)}
+ ${row(tr('specDia'), `${nf(CURTA.diameter)} mm`)}
+ ${row(tr('specH'), tr('specHv', nf(CURTA.height), nf(CURTA.heightCrank)))}
+ ${row(tr('specMass'), `${nf(CURTA.mass)} g`)}
+ ${row(tr('specMade'), tr('specMadev', nf(CURTA.made), CURTA.years[0], CURTA.years[1]))}
+ ${row(tr('specII'), `${CURTA.typeII.sliders} · ${CURTA.typeII.counter} · ${CURTA.typeII.result}`)}
+
`);
+ add(`
`);
+
+ // ------------------------------------------------------------ left: the turn as a timing chart
+ add(`${tr('turnTitle')}
`);
+ const thBig = add(`
`);
+ const thSub = add(`
`);
+ const chart = add(` `);
+ add(`${tr('chartLegend', C.acc)}
`);
+
+ // ------------------------------------------------------------ left: the hand (live controls)
+ add(`
`);
+ add(`${tr('grpHand')}
`);
+ {
+ let x = X;
+ let y = 740;
+ for (const k of ACTS) {
+ const w = Math.round(tr('act:' + k).length * 6.6 + 22);
+ if (x + w > X + W) {
+ x = X;
+ y += 30;
+ }
+ btn[k] = add(`${tr('act:' + k)}
`);
+ x += w + 6;
+ }
+ }
+ const handNote = add(`
`);
+
+ // ------------------------------------------------------------ right: registers as drums
+ const RX = 1258;
+ const RW = 300;
+ const regs = {};
+ const reg = (key, n, y, cream, title) => {
+ add(`${title}
`);
+ const side = add(`
`);
+ const box = add(`
`);
+ const cells = [];
+ for (let i = n - 1; i >= 0; i--) {
+ const d = el(`${STRIP}
`);
+ box.appendChild(d);
+ cells[i] = d;
+ }
+ regs[key] = {cells, side, box, n};
+ };
+ reg('result', CURTA.result, 22, false, tr('regResult'));
+ reg('ctr', CURTA.counter, 104, true, tr('regCtr'));
+ reg('set', CURTA.sliders, 186, false, tr('regSet'));
+ // column numbers under the result
+ for (let i = 0; i < CURTA.result; i++) add(`${i + 1}
`);
+ add(`
`);
+
+ // ------------------------------------------------------------ right: the operation by hand
+ add(`${tr('opTitle')}
`);
+ const opSide = add(`
`);
+ const op = add(`
`);
+ const opNote = add(`
`);
+ add(`
`);
+ const hist = add(`
`);
+
+ // ------------------------------------------------------------ right: buttons
+ // laid out from the bottom up, so a longer language takes more rows and still ends above the footer
+ const groups = [[tr('grpSub'), SUBS, 'sub:'], [tr('grpCut'), [...CUTS, 'explode'], 'cut:'], [tr('grpView'), VIEWS, 'view:']];
+ const BX = RX - 30;
+ const laid = groups.map(([title, keys, pre]) => {
+ let x = BX;
+ let row = 0;
+ const items = keys.map((k) => {
+ const w = Math.round(tr(pre + k).length * 6.1 + 18);
+ if (x + w > RX + RW) {
+ x = BX;
+ row++;
+ }
+ const it = {k, x, row, w, label: tr(pre + k)};
+ x += w + 5;
+ return it;
+ });
+ return {title, items, rows: row + 1};
+ });
+ const height = laid.reduce((a, g) => a + 15 + g.rows * 27 + 6, 0);
+ let gy = 870 - height;
+ add(`
`);
+ for (const g of laid) {
+ add(`${g.title}
`);
+ for (const it of g.items) btn[it.k] = add(`${it.label}
`);
+ gy += 15 + g.rows * 27 + 6;
+ }
+
+ // ------------------------------------------------------------ caption, callouts, pointer
+ const cap = add(`
`);
+ add(`${tr('hint')}
`);
+ const callSvg = add(` `);
+ const callG = add(`
`);
+ const ring = add(`
`);
+ const cur = add(``);
+ const centre = (n) => [parseFloat(n.style.left) + parseFloat(n.style.width) / 2, parseFloat(n.style.top) + 12];
+ const spots = Object.fromEntries(Object.entries(btn).map(([k, n]) => [k, centre(n)]));
+ const setSpots = Object.fromEntries(Array.from({length: CURTA.sliders}, (_, i) => [i, [RX + RW - (i + 1) * 26 + 14, 186 + 32 + 18]]));
+ const pointer = (c) => {
+ cur.style.display = c.x < 0 ? 'none' : 'block';
+ cur.style.transform = `translate(${(c.x - 3).toFixed(2)}px, ${(c.y - 2).toFixed(2)}px) scale(${c.press ? 0.88 : 1})`;
+ ring.style.left = `${c.ringX}px`;
+ ring.style.top = `${c.ringY}px`;
+ ring.style.opacity = String(c.ring * 0.85);
+ ring.style.transform = `scale(${(0.35 + 0.9 * (1 - c.ring)).toFixed(3)})`;
+ };
+ const roll = (r, vals, active = -1) => {
+ for (let i = 0; i < r.n; i++) {
+ const v = (((vals[i] % 10) + 10) % 10);
+ r.cells[i].firstChild.style.transform = `translateY(${(-v * 36).toFixed(2)}px)`;
+ r.cells[i].lastChild.style.opacity = i === active ? '1' : '0';
+ }
+ };
+ let lastEv = null;
+
+ // the turn's chart: θ across, one row per result column (1 at the bottom); teeth as brass ticks, carries as dots
+ const drawChart = (ev, th, p) => {
+ const Wc = W;
+ const x0 = 26;
+ const x1 = Wc - 6;
+ const xa = (a) => x0 + (a / 360) * (x1 - x0);
+ const rh = 19;
+ const yRow = (j) => 8 + (CURTA.result - j) * rh + rh / 2;
+ let h = '';
+ for (const a of [0, 90, 180, 270, 360]) h += `${a}° `;
+ for (let j = 1; j <= CURTA.result; j++) {
+ const y = yRow(j);
+ h += `${j} `;
+ h += ` `;
+ }
+ if (ev) {
+ for (const c of ev.cols) {
+ const y = yRow(c.col);
+ for (const a of c.teeth) {
+ const on = th !== null && th >= a;
+ h += ` `;
+ }
+ }
+ // carries: from the wrap of a column to the carry tooth reaching the next, then the dot
+ for (const cy of ev.carries) {
+ const y = yRow(cy.col);
+ const a = cy.at;
+ const fired = th !== null && th >= a;
+ const xw = xa(cy.from);
+ h += ` `;
+ h += ` `;
+ }
+ if (ev.overflow) {
+ const a = ev.overflow.at;
+ const fired = th !== null && th >= a + 4;
+ h += `${tr("overflow")} `;
+ }
+ // the counter's tooth
+ const yc = yRow(1) + 12;
+ h += ` `;
+ }
+ // columns below the carriage's position are parked
+ for (let j = 1; j < p; j++) h += ` `;
+ if (th !== null) h += ` `;
+ chart.innerHTML = h;
+ };
+
+ // the operation by hand: during a turn, the register, the addend (or its complement), the carries and the sum;
+ // between turns, the running total
+ const opLine = (sym, digits, cls = (i) => '', lead = true) => {
+ let h = `${sym} `;
+ let started = !lead;
+ for (let i = CURTA.result - 1; i >= 0; i--) {
+ const d = digits[i];
+ if (d === null || d === undefined) {
+ h += ` `;
+ continue;
+ }
+ if (d !== 0 || i === 0) started = true;
+ h += `${started || !lead ? d : '0'} `;
+ }
+ return h + '
';
+ };
+ const fmt = (v) => nf(v).replace(/^-/, "−");
+
+ return {
+ spots,
+ setSpots,
+ btn,
+ root: ui,
+ pointer,
+ update(s) {
+ const S = s.S;
+ const turn = S.turn;
+ if (turn) lastEv = turn.ev;
+ const ev = turn ? turn.ev : s.nextEv || lastEv;
+ // the turn
+ const th = turn ? turn.th : null;
+ thBig.textContent = turn ? `θ ${Math.round(th)}°` : '';
+ thSub.innerHTML = turn
+ ? `${tr(turn.sign < 0 ? 'modeSub' : 'modeAdd')} · ${tr('turnOf', turn.n + 1, turn.of)} · ${tr('posN', S.p)} `
+ : `${tr(S.up > 0.5 ? 'crankUp' : 'crankDown')} · ${tr('posN', S.p)} `;
+ drawChart(ev, th, turn ? turn.ev.p : S.p);
+
+ // registers
+ const actCol = turn && S.active?.carry ? S.active.carry.col : turn && S.active?.tooth ? S.active.tooth.col : -1;
+ roll(regs.result, S.regPos, actCol - 1);
+ roll(regs.ctr, S.ctrPos, S.p - 1);
+ roll(regs.set, S.setPos, -1);
+ regs.result.side.textContent = nf(fromDigits(S.reg));
+ regs.ctr.side.innerHTML = `${tr('posN', S.p)} · ${nf(fromDigits(S.ctr))}`;
+ regs.set.side.textContent = nf(fromDigits(S.set));
+ // the carriage's offset under the result, shown by sliding the counter and setting rows
+ const off = (S.carPos - 1) * 26;
+ regs.set.box.style.transform = `translateX(${(-off).toFixed(1)}px)`;
+
+ // the operation
+ if (turn) {
+ const e = turn.ev;
+ const add = addendOf(e.set, e.p, e.sign);
+ const carried = new Array(CURTA.result).fill(null);
+ for (const c of e.carries) if (th >= c.at) carried[c.col - 1] = 1;
+ const now = S.reg;
+ let h = ` ${Array.from({length: CURTA.result}, (_, q) => `${carried[CURTA.result - 1 - q] ? '1' : ''} `).join('')}
`;
+ h += opLine('', e.reg0, () => '');
+ h += opLine(e.sign < 0 ? '+' : '+', add.map((x) => (x === null ? null : x % 10)), (i) => (add[i] === 10 ? 'ac' : i + 1 === actCol ? 'ac' : ''), e.sign > 0);
+ h += `
`;
+ h += opLine('', now, (i) => (i + 1 === actCol ? 'ac' : ''));
+ op.innerHTML = h;
+ opSide.textContent = e.sign < 0 ? tr('opSubSide') : tr('opAddSide', e.p);
+ opNote.innerHTML = e.sign < 0 ? tr('compNote', fmt(fromDigits(e.set) * 10 ** (e.p - 1)), C.acc) : tr('addNote', fmt(fromDigits(e.set)), e.p, fmt(fromDigits(e.set) * 10 ** (e.p - 1)));
+ } else {
+ let h = `
`;
+ h += opLine('', S.reg, () => '');
+ op.innerHTML = h;
+ opSide.textContent = '';
+ opNote.innerHTML = S.up > 0.5 ? tr('upNote', C.acc) : '';
+ }
+ // the running operation, by hand: 1 234 × 6, + 1 234 × 50, − 1 234 × 10
+ {
+ const H = S.hist.length ? S.hist : s.lastHist || [];
+ let h = '';
+ let mul = 0;
+ H.slice(-4).forEach((x, i) => {
+ const m = x.n * 10 ** (x.p - 1);
+ h += `${i || x.sign < 0 ? (x.sign < 0 ? '−' : '+') : ''} ${fmt(x.set)} × ${fmt(m)}${tr('histTurns', x.n, x.p)}
`;
+ });
+ for (const x of H) mul += x.sign * x.n * 10 ** (x.p - 1);
+ const set = H.length ? H[H.length - 1].set : 0;
+ const total = H.reduce((a, x) => a + x.sign * x.n * x.set * 10 ** (x.p - 1), 0);
+ if (H.length) h += `= ${H.every((x) => x.set === set) ? `${fmt(set)} × ${fmt(mul)}` : ''} ${fmt(total)}
`;
+ hist.innerHTML = h;
+ }
+
+ // toggles
+ const tog = (k, on) => {
+ if (!btn[k]) return;
+ btn[k].style.background = on ? C.ink : 'rgba(255,255,255,.55)';
+ btn[k].style.color = on ? '#f6f4ef' : C.ink;
+ btn[k].style.borderColor = on ? C.ink : C.line;
+ };
+ for (const k of SUBS) tog(k, s.sub === k);
+ for (const k of CUTS) tog(k, s.cut === k);
+ tog('explode', s.explode > 0.5);
+ for (const k of VIEWS) tog(k, s.view === k);
+ tog('pull', S.up > 0.5);
+ tog('pause', !!s.paused);
+ if (btn.pause) btn.pause.textContent = tr(s.paused ? 'act:play' : 'act:pause');
+ handNote.innerHTML = s.handNote || '';
+ cap.innerHTML = s.caption ? `${s.caption} ` : '';
+
+ // callouts
+ let lh = '';
+ let ch = '';
+ for (const c of s.callouts) {
+ if (c.o < 0.02 || !c.at) continue;
+ const [ax, ay] = c.at;
+ const [lx, ly] = c.label;
+ const right = c.w < 0;
+ const w = Math.abs(c.w);
+ lh += ` `;
+ ch += `${c.name} ${c.sub}
`;
+ }
+ callSvg.innerHTML = lh;
+ callG.innerHTML = ch;
+ pointer(s.cursor);
+ },
+ };
+}
+export {stationOf, thetaCarry};
diff --git a/machines/pepper/curta-shared/curta.js b/machines/pepper/curta-shared/curta.js
new file mode 100644
index 0000000..6fa8a39
--- /dev/null
+++ b/machines/pepper/curta-shared/curta.js
@@ -0,0 +1,787 @@
+// Pepper's geometry, built from code, in millimetres (the root is scaled to metres). Against the photos in
+// ../REFERENCES.md: a knurled base, the slotted body with its 8 sliders, the CURTA band, the knurled carriage with the
+// result and counter windows on its top plate, the clearing ring, the crank. Inside, from the drawings of the
+// internals [M]: one stepped drum with its addition and complement rows and the carry bell on top, 11 square shafts
+// around it with their counting gears, the carry levers and pinions, the digit drums lying on their sides under the
+// windows. update(S, fx) poses every part from the model's state (machine.js) for one frame; it holds no time.
+import * as THREE from 'three';
+import {rng} from 'teardown-kit/util.js';
+import {CURTA, G, stationOf, thetaCarry} from './machine.js';
+
+const V3 = THREE.Vector3;
+const D2R = Math.PI / 180;
+export const ACCENT = '#b07a1c';
+
+// ------------------------------------------------------------ layout, mm; y up, azimuth a from +z toward +x
+export const L = {
+ baseR: 26.5,
+ baseH: 14,
+ shellR: 23.6,
+ shellY: [14, 52],
+ slotY: [19.2, 47.6],
+ slotW: 3.4,
+ bandR: 25.2,
+ bandY: [52, 63],
+ carR: 26.5,
+ carY: [63, 79.5],
+ plateY: [79.5, 81.5],
+ ringR: [20.6, 25.6],
+ drumR: 6,
+ toothR: 7.6,
+ drumY: [18.6, 45.4],
+ shaftR: 9.7,
+ gearR: 2.3,
+ wheelR: 4.2,
+ wheelW: 4.2,
+ wheelAt: 18.6, // radius of the result digit drums' centres
+ ctrR: 2.6,
+ ctrW: 3.2,
+ ctrAt: 7.2,
+ crankArm: 16,
+ A1: 3.5 * G.pitch, // azimuth of station 1: the 8 sliders centred on the front, slider 1 on the right
+};
+export const azOf = (s) => L.A1 - (s - 1) * G.pitch; // degrees
+export const yRow = (d) => 43 - G.rowPitch * d; // height of the counting gear for digit d (and of add row d)
+const P = (r, a, y) => new V3(r * Math.sin(a * D2R), y, r * Math.cos(a * D2R));
+
+// ------------------------------------------------------------ textures drawn on canvases
+function canvas(w, h, draw) {
+ const c = document.createElement('canvas');
+ c.width = w;
+ c.height = h;
+ draw(c.getContext('2d'), w, h);
+ return c;
+}
+function normalFromHeight(w, h, hf, strength) {
+ return canvas(w, h, (g) => {
+ const img = g.createImageData(w, h);
+ for (let y = 0; y < h; y++)
+ for (let x = 0; x < w; x++) {
+ const dx = (hf((x + 1) % w, y) - hf((x - 1 + w) % w, y)) * strength;
+ const dy = (hf(x, (y + 1) % h) - hf(x, (y - 1 + h) % h)) * strength;
+ const n = new V3(-dx, dy, 1).normalize();
+ const i = (y * w + x) * 4;
+ img.data[i] = (n.x * 0.5 + 0.5) * 255;
+ img.data[i + 1] = (n.y * 0.5 + 0.5) * 255;
+ img.data[i + 2] = (n.z * 0.5 + 0.5) * 255;
+ img.data[i + 3] = 255;
+ }
+ g.putImageData(img, 0, 0);
+ });
+}
+const texOf = (c, {srgb = false, rx = 1, ry = 1, aniso = 8} = {}) => {
+ const t = new THREE.CanvasTexture(c);
+ if (srgb) t.colorSpace = THREE.SRGBColorSpace;
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
+ t.repeat.set(rx, ry);
+ t.anisotropy = aniso;
+ return t;
+};
+// diamond knurl: a grid of four-sided pyramids
+const knurlCanvas = () => normalFromHeight(64, 64, (x, y) => 1 - Math.max(Math.abs((x % 32) - 16), Math.abs((y % 32) - 16)) / 16, 2.2);
+// the crackle paint: small cells of raised paint with grooves between them
+function crackleCanvas() {
+ const r = rng(48);
+ const N = 256;
+ const pts = Array.from({length: 150}, () => [r() * N, r() * N]);
+ const H = new Float32Array(N * N);
+ for (let y = 0; y < N; y++)
+ for (let x = 0; x < N; x++) {
+ let d1 = 1e9;
+ let d2 = 1e9;
+ for (const [px, py] of pts) {
+ let dx = Math.abs(x - px);
+ let dy = Math.abs(y - py);
+ dx = Math.min(dx, N - dx);
+ dy = Math.min(dy, N - dy);
+ const d = dx * dx + dy * dy;
+ if (d < d1) [d1, d2] = [d, d1];
+ else if (d < d2) d2 = d;
+ }
+ const e = Math.sqrt(d2) - Math.sqrt(d1);
+ H[y * N + x] = Math.min(1, e / 3) * (0.8 + 0.2 * Math.sin(x * 0.9 + y * 1.3));
+ }
+ return normalFromHeight(N, N, (x, y) => H[y * N + x], 1.6);
+}
+// a digit drum's face: ten digits around the circumference, each turned so its top points at the machine's axis
+function digitsCanvas(fg, bg, w = 640, h = 72, font = 'JetBrains Mono') {
+ return canvas(w, h, (g) => {
+ g.fillStyle = bg;
+ g.fillRect(0, 0, w, h);
+ const cw = w / 10;
+ g.fillStyle = fg;
+ g.font = `600 ${Math.round(cw * 0.78)}px '${font}', monospace`;
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ for (let d = 0; d < 10; d++) {
+ g.save();
+ g.translate((d + 0.5) * cw, h / 2);
+ g.rotate(Math.PI);
+ g.fillText(String(d), 0, 2);
+ g.restore();
+ }
+ });
+}
+// a slider's digit tape: 0 at the top, 9 at the bottom
+const tapeCanvas = () =>
+ canvas(64, 640, (g, w, h) => {
+ g.fillStyle = '#0d0d0e';
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#f2efe6';
+ g.font = `600 46px 'JetBrains Mono', monospace`;
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ for (let d = 0; d < 10; d++) g.fillText(String(d), w / 2, (d + 0.5) * (h / 10) + 2);
+ });
+// the band: CURTA at the front, the slider numbers under the slots
+const bandCanvas = () =>
+ canvas(2048, 128, (g, w, h) => {
+ g.fillStyle = '#000';
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#e9e6dd';
+ g.font = `300 70px Inter, sans-serif`;
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ const word = 'C U R T A';
+ g.save();
+ g.translate(w * 0.035, h * 0.52);
+ g.fillText(word, 0, 0);
+ g.restore();
+ g.save();
+ g.translate(w * 1.035, h * 0.52);
+ g.fillText(word, 0, 0);
+ g.restore();
+ });
+const numbersCanvas = (labels, size = 34) =>
+ canvas(2048, 64, (g, w, h) => {
+ g.fillStyle = '#000';
+ g.fillRect(0, 0, w, h);
+ g.fillStyle = '#d9d6cd';
+ g.font = `500 ${size}px Inter, sans-serif`;
+ g.textAlign = 'center';
+ g.textBaseline = 'middle';
+ for (const [a, txt] of labels) {
+ const u = (((a / 360) % 1) + 1) % 1;
+ g.fillText(txt, u * w, h / 2 + 1);
+ }
+ });
+
+// ------------------------------------------------------------ shapes
+function gearGeom(teeth, rTip, rRoot, thick, hole = 0.7) {
+ const s = new THREE.Shape();
+ const n = teeth * 4;
+ for (let i = 0; i <= n; i++) {
+ const a = (i / n) * Math.PI * 2;
+ const ph = i % 4;
+ const r = ph === 1 || ph === 2 ? rTip : rRoot;
+ const x = r * Math.cos(a);
+ const y = r * Math.sin(a);
+ if (i === 0) s.moveTo(x, y);
+ else s.lineTo(x, y);
+ }
+ const hp = new THREE.Path();
+ hp.moveTo(hole, hole);
+ hp.lineTo(-hole, hole);
+ hp.lineTo(-hole, -hole);
+ hp.lineTo(hole, -hole);
+ hp.lineTo(hole, hole);
+ s.holes.push(hp);
+ const g = new THREE.ExtrudeGeometry(s, {depth: thick, bevelEnabled: false, curveSegments: 2});
+ g.rotateX(-Math.PI / 2);
+ g.translate(0, -thick / 2, 0);
+ return g;
+}
+// the top plate: a disc with 11 + 6 windows
+function plateGeom() {
+ const s = new THREE.Shape();
+ s.absarc(0, 0, L.carR - 0.3, 0, Math.PI * 2, false);
+ const win = (r, a, w, h) => {
+ const p = new THREE.Path();
+ const c = [r * Math.sin(a * D2R), r * Math.cos(a * D2R)];
+ const ra = [Math.sin(a * D2R), Math.cos(a * D2R)];
+ const ta = [Math.cos(a * D2R), -Math.sin(a * D2R)];
+ const q = (u, v) => [c[0] + ra[0] * u + ta[0] * v, c[1] + ra[1] * u + ta[1] * v];
+ const pts = [q(-h / 2, -w / 2), q(-h / 2, w / 2), q(h / 2, w / 2), q(h / 2, -w / 2)];
+ p.moveTo(...pts[0]);
+ for (const x of pts.slice(1)) p.lineTo(...x);
+ p.lineTo(...pts[0]);
+ s.holes.push(p);
+ };
+ for (let j = 1; j <= CURTA.result; j++) win(L.wheelAt, L.A1 - (j - 1) * G.pitch, 4.0, 5.6);
+ for (let c = 1; c <= CURTA.counter; c++) win(L.ctrAt, L.A1 - (c - 0.5) * G.pitch, 2.4, 3.4);
+ const hole = new THREE.Path();
+ hole.absarc(0, 0, 3.0, 0, Math.PI * 2, true);
+ s.holes.push(hole);
+ const g = new THREE.ExtrudeGeometry(s, {depth: L.plateY[1] - L.plateY[0], bevelEnabled: true, bevelThickness: 0.25, bevelSize: 0.25, bevelSegments: 2, curveSegments: 64});
+ // shape x → world x, shape y → world z (a = 0 at +z): rotate so the extrusion goes up
+ g.rotateX(Math.PI / 2);
+ g.scale(1, -1, 1);
+ g.translate(0, L.plateY[0], 0);
+ return g;
+}
+
+export function buildCurta() {
+ const root = new THREE.Group();
+ root.scale.setScalar(0.001);
+ const groups = {};
+ const grp = (name, parent = root) => {
+ const g = new THREE.Group();
+ g.name = name;
+ parent.add(g);
+ groups[name] = g;
+ return g;
+ };
+ // ---- materials (each group gets its own copies so it can turn to glass or ghost on its own)
+ const knurlN = texOf(knurlCanvas());
+ const crackN = texOf(crackleCanvas(), {rx: 3, ry: 2});
+ const std = (o) => new THREE.MeshStandardMaterial({roughness: 0.5, metalness: 0, envMapIntensity: 0.6, ...o});
+ const M = {
+ crackle: std({color: '#121213', roughness: 0.78, normalMap: crackN, normalScale: new THREE.Vector2(0.45, 0.45), envMapIntensity: 0.5}),
+ knurl: std({color: '#18191b', roughness: 0.44, metalness: 0.55, normalMap: knurlN, normalScale: new THREE.Vector2(1.1, 1.1), envMapIntensity: 0.75}),
+ satinBlack: std({color: '#151618', roughness: 0.42, metalness: 0.5, envMapIntensity: 0.7}),
+ steel: std({color: '#9a9ea5', roughness: 0.4, metalness: 1, envMapIntensity: 0.6}),
+ polished: std({color: '#aeb2b8', roughness: 0.36, metalness: 1, envMapIntensity: 0.62}),
+ brass: std({color: '#9c8c6a', roughness: 0.42, metalness: 1, envMapIntensity: 0.6}),
+ dark: std({color: '#0a0a0b', roughness: 0.7}),
+ band: std({color: '#ffffff', roughness: 0.55, metalness: 0.2, map: texOf(bandCanvas(), {srgb: true}), envMapIntensity: 0.5}),
+ nums: std({color: '#ffffff', roughness: 0.7, map: texOf(numbersCanvas(Array.from({length: 8}, (_, i) => [azOf(i + 1), String(i + 1)])), {srgb: true})}),
+ };
+ const accentC = new THREE.Color(ACCENT);
+ const ACC = () => std({color: '#c9902a', roughness: 0.38, metalness: 0.9, emissive: accentC, emissiveIntensity: 0, envMapIntensity: 0.8});
+ const resultTex = texOf(digitsCanvas('#f4f1e8', '#0b0b0c'), {srgb: true});
+ const ctrTex = texOf(digitsCanvas('#141414', '#ece5d2'), {srgb: true});
+ const tapeTex = texOf(tapeCanvas(), {srgb: true});
+ const mats = new Map(); // group name -> materials
+ const own = (gname, m) => {
+ const c = m.clone();
+ c.userData.base = {opacity: 1, transparent: false};
+ if (!mats.has(gname)) mats.set(gname, []);
+ mats.get(gname).push(c);
+ return c;
+ };
+ const mesh = (gname, geo, m, parent = groups[gname]) => {
+ const x = new THREE.Mesh(geo, own(gname, m));
+ x.castShadow = true;
+ x.receiveShadow = true;
+ parent.add(x);
+ return x;
+ };
+ const cyl = (r0, r1, h, seg = 96, open = false, ts = 0, tl = Math.PI * 2) => new THREE.CylinderGeometry(r0, r1, h, seg, 1, open, ts, tl);
+ const at = (o, y) => (o.position.y = y, o);
+ const A = {}; // anchors for the callouts
+
+ // ================================================================ base
+ grp('base');
+ {
+ const h = L.baseH;
+ const ring = mesh('base', cyl(L.baseR, L.baseR, h - 2, 128, true), M.knurl);
+ ring.material.normalMap = knurlN.clone();
+ ring.material.normalMap.repeat.set(90, 7);
+ ring.material.normalMap.needsUpdate = true;
+ at(ring, 1 + (h - 2) / 2);
+ // chamfers and the bottom
+ const prof = [[0, 0], [L.baseR - 1.2, 0], [L.baseR, 1.0], [L.baseR, 1.2], [L.baseR - 0.3, 1.2]].map(([x, y]) => new THREE.Vector2(x, y));
+ mesh('base', new THREE.LatheGeometry(prof, 128), M.satinBlack);
+ const top = [[L.baseR - 0.3, h - 1.2], [L.baseR, h - 1.2], [L.baseR, h - 0.8], [L.baseR - 0.8, h], [L.shellR - 0.2, h]].map(([x, y]) => new THREE.Vector2(x, y));
+ mesh('base', new THREE.LatheGeometry(top, 128), M.satinBlack);
+ // three steel pins on the top edge
+ for (const a of [-42, 8, 58]) {
+ const pin = mesh('base', new THREE.CylinderGeometry(0.7, 0.8, 1.6, 16), M.steel);
+ pin.position.copy(P(L.baseR - 1.4, a, h + 0.6));
+ }
+ }
+
+ // ================================================================ shell: the body with its slots, sliders, band
+ grp('shell');
+ {
+ const [y0, y1] = L.shellY;
+ const [s0, s1] = L.slotY;
+ const halfA = ((L.slotW / 2) / L.shellR) / D2R;
+ // full rings above and below the slots
+ const below = mesh('shell', cyl(L.shellR, L.shellR, s0 - y0, 128, true), M.crackle);
+ at(below, (y0 + s0) / 2);
+ below.material.side = THREE.DoubleSide;
+ const above = mesh('shell', cyl(L.shellR, L.shellR, y1 - s1, 128, true), M.crackle);
+ at(above, (s1 + y1) / 2);
+ above.material.side = THREE.DoubleSide;
+ // panels between the slots (slots at stations 1..8, a solid back)
+ const slots = Array.from({length: CURTA.sliders}, (_, i) => azOf(i + 1)).sort((a, b) => a - b);
+ const edges = [];
+ for (let i = 0; i < slots.length; i++) {
+ const a0 = slots[i] + halfA;
+ const a1 = i + 1 < slots.length ? slots[i + 1] - halfA : slots[0] + 360 - halfA;
+ edges.push([a0, a1]);
+ }
+ for (const [a0, a1] of edges) {
+ const g = cyl(L.shellR, L.shellR, s1 - s0, Math.max(4, Math.round((a1 - a0) / 3)), true, a0 * D2R, (a1 - a0) * D2R);
+ const m = mesh('shell', g, M.crackle);
+ m.material.side = THREE.DoubleSide;
+ at(m, (s0 + s1) / 2);
+ }
+ // the slot's back: a dark guide with a polished rod, like the photos
+ for (let i = 1; i <= CURTA.sliders; i++) {
+ const a = azOf(i);
+ const back = mesh('shell', cyl(L.shellR - 1.6, L.shellR - 1.6, s1 - s0, 8, true, (a - halfA * 1.6) * D2R, halfA * 3.2 * D2R), M.dark);
+ back.material.side = THREE.DoubleSide;
+ at(back, (s0 + s1) / 2);
+ const rod = mesh('shell', new THREE.CylinderGeometry(0.55, 0.55, s1 - s0, 12), M.polished);
+ rod.position.copy(P(L.shellR - 1.0, a, (s0 + s1) / 2));
+ }
+ // sliders: knurled tabs riding in the slots
+ groups.sliders = [];
+ for (let i = 1; i <= CURTA.sliders; i++) {
+ const a = azOf(i);
+ const piv = new THREE.Group();
+ piv.rotation.y = a * D2R;
+ groups.shell.add(piv);
+ const tab = new THREE.Group();
+ piv.add(tab);
+ const knob = mesh('shell', new THREE.BoxGeometry(4.4, 4.6, 2.4), M.knurl, tab);
+ knob.material.normalMap = knurlN.clone();
+ knob.material.normalMap.repeat.set(3, 3);
+ knob.position.set(0, 0, L.shellR + 1.0);
+ const stem = mesh('shell', new THREE.BoxGeometry(2.4, 3.2, 2.2), M.satinBlack, tab);
+ stem.position.set(0, 0, L.shellR - 1.0);
+ groups.sliders.push(tab);
+ // digit tape window above the slot
+ const win = mesh('shell', new THREE.PlaneGeometry(3.0, 3.0), std({map: tapeTex.clone(), roughness: 0.6, color: '#ffffff'}), piv);
+ win.material.map.repeat.set(1, 0.1);
+ win.material.map.needsUpdate = true;
+ win.position.set(0, (s1 + y1) / 2 + 0.6, L.shellR + 0.05);
+ tab.userData.win = win;
+ }
+ // slider numbers under the slots
+ const nums = mesh('shell', cyl(L.shellR + 0.02, L.shellR + 0.02, 3.2, 128, true), M.nums);
+ at(nums, y0 + 2.6);
+ // the band
+ const band = mesh('shell', cyl(L.bandR, L.bandR, L.bandY[1] - L.bandY[0], 128, true), M.band);
+ at(band, (L.bandY[0] + L.bandY[1]) / 2);
+ const bandTop = mesh('shell', new THREE.RingGeometry(L.shellR - 0.5, L.bandR, 128), M.satinBlack);
+ bandTop.rotation.x = Math.PI / 2;
+ bandTop.position.y = L.bandY[0];
+ A.sliders = P(L.shellR + 2, azOf(3), 34);
+ A.band = P(L.bandR, 20, 58);
+ }
+
+ // ================================================================ drum: the stepped drum and the carry bell, turning with the crank
+ grp('drum');
+ const drumSpin = new THREE.Group();
+ groups.drum.add(drumSpin);
+ groups.drumSpin = drumSpin;
+ const teeth = [];
+ {
+ const [y0, y1] = L.drumY;
+ const core = mesh('drum', cyl(L.drumR, L.drumR, y1 - y0, 64), M.steel, drumSpin);
+ core.material.color.set('#7d8187');
+ at(core, (y0 + y1) / 2);
+ const shaft = mesh('drum', cyl(2.2, 2.2, 82 - 15, 24), M.steel, drumSpin);
+ at(shaft, (15 + 82) / 2);
+ // rows: add row d (d teeth) at yRow(d), complement row d (9 − d teeth) half a pitch lower, the ten row on top
+ const rows = [];
+ for (let d = 1; d <= 9; d++) rows.push({kind: 'add', d, y: yRow(d), n: d});
+ for (let d = 0; d <= 8; d++) rows.push({kind: 'comp', d, y: yRow(d) - G.shift, n: 9 - d});
+ rows.push({kind: 'ten', d: 0, y: yRow(0) + G.shift, n: 10});
+ let count = 0;
+ for (const r of rows) count += r.n;
+ const tg = new THREE.BoxGeometry(1.25, 0.95, L.toothR - L.drumR + 0.3);
+ const im = new THREE.InstancedMesh(tg, own('drum', M.brass), count);
+ im.castShadow = true;
+ im.receiveShadow = true;
+ const m4 = new THREE.Matrix4();
+ const q = new THREE.Quaternion();
+ let i = 0;
+ for (const r of rows)
+ for (let k = 0; k < r.n; k++) {
+ // tooth k sits at drum angle A1 + entry0 + 10k (it meets station s at θ = entry0 + (s − 1)·22.5 + 10k)
+ const phi = L.A1 + G.entry0 + k * G.tooth;
+ q.setFromAxisAngle(new V3(0, 1, 0), phi * D2R);
+ m4.compose(P((L.drumR + L.toothR) / 2 - 0.15, phi, r.y), q, new V3(1, 1, 1));
+ im.setMatrixAt(i, m4);
+ im.setColorAt(i, new THREE.Color(1, 1, 1));
+ teeth.push({...r, k, i});
+ i++;
+ }
+ im.instanceColor.needsUpdate = true;
+ drumSpin.add(im);
+ groups.teeth = im;
+ // collars between the rows, thin rings that read as the drum's stepped body
+ for (let d = 0; d <= 9; d++) {
+ const c = mesh('drum', cyl(L.drumR + 0.2, L.drumR + 0.2, 0.25, 64), M.satinBlack, drumSpin);
+ at(c, yRow(d) - G.shift / 2 - 0.6);
+ }
+ // the carry bell: a disc with one tooth
+ const bell = mesh('drum', cyl(7.0, 7.4, 1.4, 64), M.steel, drumSpin);
+ at(bell, 46.8);
+ const phiC = L.A1 + G.carry0;
+ const ct = mesh('drum', new THREE.BoxGeometry(1.6, 2.6, 1.8), ACC(), drumSpin);
+ ct.position.copy(P(7.9, phiC, 47.2));
+ ct.rotation.y = phiC * D2R;
+ groups.carryTooth = ct;
+ A.drum = new V3(0, 30, 0);
+ A.drumTop = new V3(0, 47, 0);
+ }
+
+ // ================================================================ shafts: 11 stations, counting gears, forks, carry levers
+ grp('shafts');
+ const st = [];
+ {
+ const gearG = gearGeom(10, L.gearR, L.gearR - 0.55, 1.0);
+ const pinG = gearGeom(10, 1.7, 1.25, 0.9);
+ for (let s = 1; s <= G.active; s++) {
+ const a = azOf(s);
+ const base = P(L.shaftR, a, 0);
+ const piv = new THREE.Group();
+ piv.position.copy(base);
+ groups.shafts.add(piv);
+ const spin = new THREE.Group();
+ piv.add(spin);
+ const sh = mesh('shafts', new THREE.BoxGeometry(1.3, 46, 1.3), M.steel, spin);
+ sh.position.y = 16.5 + 23;
+ const gear = mesh('shafts', gearG, ACC(), spin);
+ gear.material.color.set('#a89878');
+ gear.position.y = yRow(0);
+ const pin = mesh('shafts', pinG, M.brass, spin);
+ pin.position.y = 49.6;
+ // the coupling at the top, under the carriage
+ const cup = mesh('shafts', cyl(1.6, 1.6, 1.2, 16), M.brass, spin);
+ cup.position.y = 62.2;
+ // the extra cog of the first column, met by the ten row when the drum is up [V]
+ if (s === 1) {
+ const cog = mesh('shafts', gearG, M.brass, spin);
+ cog.position.y = yRow(0) + G.shift;
+ }
+ // fork from the slider to the gear (sliders only)
+ let fork = null;
+ if (s <= CURTA.sliders) {
+ fork = new THREE.Group();
+ groups.shafts.add(fork);
+ fork.rotation.y = a * D2R;
+ const bar = mesh('shafts', new THREE.BoxGeometry(0.45, 0.8, L.shellR - 1.6 - L.shaftR - 1.2), M.satinBlack, fork);
+ bar.position.set(0, 0, (L.shaftR + 1.2 + L.shellR - 1.6) / 2);
+ const yoke = mesh('shafts', new THREE.BoxGeometry(3.4, 0.4, 1.0), M.satinBlack, fork);
+ yoke.position.set(0, 0.85, L.shaftR + 0.9);
+ }
+ // carry lever (stations 2..11): pivots on the frame, presses the pinion down [F]
+ let lever = null;
+ if (s >= 2) {
+ lever = new THREE.Group();
+ groups.shafts.add(lever);
+ lever.position.copy(P(12.6, a, 53.2));
+ lever.rotation.y = a * D2R;
+ const arm = mesh('shafts', new THREE.BoxGeometry(0.8, 0.9, 3.6), ACC(), lever);
+ arm.material.color.set('#b9bdc3');
+ arm.material.metalness = 1;
+ arm.position.set(0, -0.4, -1.6);
+ const up = mesh('shafts', new THREE.BoxGeometry(0.8, 6.4, 0.8), M.steel, lever);
+ up.position.set(0, 3.0, 0.1);
+ const pv = mesh('shafts', new THREE.CylinderGeometry(0.6, 0.6, 1.8, 12), M.polished, lever);
+ pv.rotation.z = Math.PI / 2;
+ lever.userData.arm = arm;
+ }
+ const smats = [];
+ for (const o of [piv, fork, lever]) o?.traverse((m) => m.isMesh && smats.push(m.material));
+ st.push({s, a, piv, spin, gear, pin, fork, lever, smats});
+ }
+ // the frame ring that carries the lever pivots
+ const fr = mesh('shafts', new THREE.TorusGeometry(12.6, 0.5, 8, 96), M.steel);
+ fr.rotation.x = Math.PI / 2;
+ fr.position.y = 53.2;
+ const plate = mesh('shafts', new THREE.RingGeometry(3.0, 14.5, 96), M.satinBlack);
+ plate.material.side = THREE.DoubleSide;
+ plate.rotation.x = -Math.PI / 2;
+ plate.position.y = 16.4;
+ A.shaft = (s) => P(L.shaftR, azOf(s), 30);
+ A.lever = (s) => P(12.6, azOf(s), 52);
+ }
+ groups.st = st;
+
+ // ================================================================ carriage: knurled ring, top plate, digit drums
+ grp('carriage');
+ const car = groups.carriage;
+ const wheels = [];
+ const ctrs = [];
+ {
+ const [y0, y1] = L.carY;
+ const ring = mesh('carriage', cyl(L.carR, L.carR, y1 - y0 - 2, 128, true), M.knurl);
+ ring.material.normalMap = knurlN.clone();
+ ring.material.normalMap.repeat.set(90, 8);
+ ring.material.side = THREE.DoubleSide;
+ at(ring, (y0 + y1) / 2);
+ const lo = [[L.bandR - 0.4, y0], [L.carR - 0.8, y0], [L.carR, y0 + 0.8], [L.carR, y0 + 1.0]].map(([x, y]) => new THREE.Vector2(x, y));
+ mesh('carriage', new THREE.LatheGeometry(lo, 128), M.satinBlack);
+ const hi = [[L.carR, y1 - 1.0], [L.carR, y1 - 0.8], [L.carR - 0.8, y1], [L.carR - 1.0, y1]].map(([x, y]) => new THREE.Vector2(x, y));
+ mesh('carriage', new THREE.LatheGeometry(hi, 128), M.satinBlack);
+ const inner = mesh('carriage', cyl(L.carR - 3.5, L.carR - 3.5, y1 - y0, 96, true), M.dark);
+ inner.material.side = THREE.DoubleSide;
+ at(inner, (y0 + y1) / 2);
+ const plate = mesh('carriage', plateGeom(), M.satinBlack);
+ plate.material.color.set('#111113');
+ // result digit drums, axles lying radially
+ for (let j = 1; j <= CURTA.result; j++) {
+ const a = L.A1 - (j - 1) * G.pitch;
+ const piv = new THREE.Group();
+ piv.rotation.y = a * D2R - Math.PI / 2; // local +x radial outward
+ car.add(piv);
+ const roll = new THREE.Group();
+ roll.position.set(L.wheelAt, L.plateY[0] - L.wheelR + 0.35, 0);
+ piv.add(roll);
+ const g = new THREE.CylinderGeometry(L.wheelR, L.wheelR, L.wheelW, 40, 1, false);
+ g.rotateZ(-Math.PI / 2);
+ const w = mesh('carriage', g, std({map: resultTex, roughness: 0.5, color: '#ffffff'}), roll);
+ w.castShadow = false;
+ // its bevel and stub down to the coupling
+ const bev = mesh('carriage', new THREE.CylinderGeometry(1.6, 0.9, 1.4, 16), M.brass, roll);
+ bev.rotation.z = Math.PI / 2;
+ bev.position.x = -L.wheelW / 2 - 4.4;
+ const ax = mesh('carriage', new THREE.CylinderGeometry(0.45, 0.45, L.wheelAt - 10, 8), M.steel, piv);
+ ax.rotation.z = Math.PI / 2;
+ ax.position.set((L.wheelAt + 10) / 2 - 0.5, roll.position.y, 0);
+ const stub = mesh('carriage', new THREE.CylinderGeometry(0.8, 0.8, roll.position.y - 63.2, 10), M.steel, piv);
+ stub.position.set(L.shaftR, (roll.position.y + 63.2) / 2, 0);
+ wheels.push({j, piv, roll, w});
+ }
+ // counter drums, inner ring, between the result columns
+ for (let c = 1; c <= CURTA.counter; c++) {
+ const a = L.A1 - (c - 0.5) * G.pitch;
+ const piv = new THREE.Group();
+ piv.rotation.y = a * D2R - Math.PI / 2;
+ car.add(piv);
+ const roll = new THREE.Group();
+ roll.position.set(L.ctrAt, L.plateY[0] - L.ctrR + 0.3, 0);
+ piv.add(roll);
+ const g = new THREE.CylinderGeometry(L.ctrR, L.ctrR, L.ctrW, 32, 1, false);
+ g.rotateZ(-Math.PI / 2);
+ const w = mesh('carriage', g, std({map: ctrTex, roughness: 0.5, color: '#ffffff'}), roll);
+ w.castShadow = false;
+ ctrs.push({c, piv, roll, w});
+ }
+ A.result = (j) => {
+ const v = new V3(L.wheelAt, L.plateY[1], 0);
+ return wheels[j - 1].piv.localToWorld(v);
+ };
+ A.counter = (c) => ctrs[c - 1].piv.localToWorld(new V3(L.ctrAt, L.plateY[1], 0));
+ A.carriage = (a) => P(L.carR, a, 71);
+ }
+
+ // ================================================================ clearing ring with its fold-out lever
+ grp('ring');
+ {
+ const y = L.plateY[1];
+ const pr = [[L.ringR[0], y], [L.ringR[1] - 0.6, y], [L.ringR[1], y + 0.6], [L.ringR[1], y + 1.0], [L.ringR[1] - 0.6, y + 1.4], [L.ringR[0] + 0.4, y + 1.4], [L.ringR[0], y + 1.0]].map(([x, yy]) => new THREE.Vector2(x, yy));
+ mesh('ring', new THREE.LatheGeometry(pr, 128), M.steel);
+ const lever = new THREE.Group();
+ lever.position.copy(P(L.ringR[1] - 0.2, -100, y + 0.8));
+ lever.rotation.y = -100 * D2R;
+ groups.ring.add(lever);
+ const loop = mesh('ring', new THREE.TorusGeometry(4.2, 0.7, 10, 32), M.steel, lever);
+ loop.rotation.x = Math.PI / 2;
+ loop.position.set(0, 0, 3.6);
+ groups.ringLever = lever;
+ A.ring = P(L.ringR[1], -100, y + 1);
+ }
+
+ // ================================================================ crank
+ grp('crank');
+ const crankSpin = new THREE.Group();
+ groups.crank.add(crankSpin);
+ {
+ const y = L.plateY[1] + 1.4;
+ const prof = [[0, y], [4.8, y], [5.2, y + 0.6], [5.2, y + 3.4]];
+ for (let i = 0; i <= 10; i++) {
+ const a = (i / 10) * (Math.PI / 2);
+ prof.push([5.2 * Math.cos(a), y + 3.4 + 4.6 * Math.sin(a)]);
+ }
+ mesh('crank', new THREE.LatheGeometry(prof.map(([x, yy]) => new THREE.Vector2(Math.max(0.001, x), yy)), 64), M.satinBlack, crankSpin);
+ const yA = y + 7.2;
+ const arm = mesh('crank', new THREE.BoxGeometry(4.4, 2.6, L.crankArm), M.steel, crankSpin);
+ arm.position.set(0, yA, L.crankArm / 2);
+ const elbow = mesh('crank', new THREE.CylinderGeometry(2.6, 2.6, 2.6, 24), M.steel, crankSpin);
+ elbow.position.set(0, yA, L.crankArm);
+ const knob = mesh('crank', new THREE.CylinderGeometry(3.2, 3.2, 12, 40), M.knurl, crankSpin);
+ knob.material.normalMap = knurlN.clone();
+ knob.material.normalMap.repeat.set(10, 7);
+ knob.position.set(0, yA + 1.3 + 6.2, L.crankArm);
+ const cap = mesh('crank', new THREE.SphereGeometry(3.2, 24, 10, 0, Math.PI * 2, 0, Math.PI / 2), M.satinBlack, crankSpin);
+ cap.scale.y = 0.35;
+ cap.position.set(0, yA + 1.3 + 12.2, L.crankArm);
+ const screw = mesh('crank', new THREE.CylinderGeometry(0.8, 0.8, 0.6, 12), M.polished, crankSpin);
+ screw.position.set(0, y + 8.05, 0);
+ A.crank = new V3(0, yA + 6, L.crankArm);
+ A.crankHub = new V3(0, y + 5, 0);
+ }
+ // the crank's home: the arm points back-left, clear of the windows in the 3/4 view
+ const HOME = 205;
+
+ // ---------------------------------------------------------------- contact shadow blob
+ const blob = new THREE.Mesh(
+ new THREE.PlaneGeometry(130, 130),
+ new THREE.MeshBasicMaterial({
+ map: new THREE.CanvasTexture(
+ canvas(256, 256, (g) => {
+ const gr = g.createRadialGradient(128, 128, 20, 128, 128, 128);
+ gr.addColorStop(0, 'rgba(30,26,20,0.62)');
+ gr.addColorStop(0.32, 'rgba(30,26,20,0.38)');
+ gr.addColorStop(0.62, 'rgba(30,26,20,0.08)');
+ gr.addColorStop(1, 'rgba(30,26,20,0)');
+ g.fillStyle = gr;
+ g.fillRect(0, 0, 256, 256);
+ }),
+ ),
+ transparent: true,
+ depthWrite: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -2,
+ polygonOffsetUnits: -4,
+ }),
+ );
+ blob.rotation.x = -Math.PI / 2;
+ blob.position.y = 0.05;
+ blob.renderOrder = -1;
+ root.add(blob);
+ groups.blob = blob;
+
+ // ---------------------------------------------------------------- cutaway, glass, ghosts
+ const HOUSING = ['base', 'shell', 'carriage', 'ring'];
+ const CLIP = new THREE.Plane(new V3(0, 0, -1), 0);
+ let cutMode = '';
+ const setCut = (mode) => {
+ if (mode === cutMode) return;
+ cutMode = mode;
+ for (const g of HOUSING)
+ for (const m of mats.get(g)) {
+ m.clippingPlanes = mode === 'section' ? [CLIP] : null;
+ m.clipShadows = true;
+ m.needsUpdate = true;
+ }
+ };
+ const fadeM = (m, o) => {
+ const t = o < 0.995;
+ if (m.transparent !== t) {
+ m.transparent = t;
+ m.needsUpdate = true;
+ }
+ m.opacity = o;
+ m.depthWrite = !t || o > 0.6;
+ };
+ const fade = (g, o) => {
+ for (const m of mats.get(g)) {
+ const t = o < 0.995;
+ if (m.transparent !== t) {
+ m.transparent = t;
+ m.needsUpdate = true;
+ }
+ m.opacity = o;
+ m.depthWrite = !t || o > 0.6;
+ }
+ };
+
+ // ---------------------------------------------------------------- update: one frame from the model's state
+ const white = new THREE.Color(1, 1, 1);
+ const accLit = new THREE.Color('#ffb53a');
+ const lastLit = new Set();
+ /**
+ * S: machine state (machine.js state()). fx: {explode 0..1, glass 0..1, cut, clipAz (deg), ghost: {group: o}}
+ */
+ const update = (S, fx) => {
+ const e = fx.explode ?? 0;
+ const ex = (v) => v * e;
+ groups.base.position.y = ex(-56);
+ groups.shell.position.y = ex(-56);
+ groups.drum.position.y = S.up * G.shift + ex(0);
+ groups.shafts.position.y = 0;
+ groups.carriage.position.y = S.lift * G.lift + ex(24);
+ groups.ring.position.y = S.lift * G.lift + ex(38);
+ groups.crank.position.y = S.up * G.shift + S.lift * G.lift + ex(54);
+ blob.position.y = ex(-56) + 0.05;
+ blob.material.opacity = 1 - 0.55 * e;
+ const th = S.theta;
+ drumSpin.rotation.y = -th * D2R;
+ crankSpin.rotation.y = (HOME - th) * D2R;
+ // carriage turned to its position
+ car.rotation.y = (S.carPos - 1) * G.pitch * D2R;
+ groups.ring.rotation.y = (S.carPos - 1) * G.pitch * D2R - S.ring * D2R;
+ groups.ringLever.rotation.x = 0;
+ groups.ringLever.children[0].position.y = S.phase === 'clear' ? 1.6 * Math.sin(Math.PI * Math.min(1, S.k * 1.2)) : 0;
+ // digit drums: value v at the top → α = 270° − 36°(v + ½)
+ for (const w of wheels) w.roll.rotation.x = (270 - 36 * (S.regPos[w.j - 1] + 0.5)) * D2R;
+ for (const c of ctrs) c.roll.rotation.x = (270 - 36 * (S.ctrPos[c.c - 1] + 0.5)) * D2R;
+ // sliders, their tapes, forks and gears
+ for (let i = 0; i < CURTA.sliders; i++) {
+ const y = yRow(S.setPos[i]);
+ groups.sliders[i].position.y = y;
+ const map = groups.sliders[i].userData.win.material.map;
+ map.offset.y = 0.9 - S.setPos[i] * 0.1;
+ }
+ // shafts: each turns with the column above it; its pose comes from the cumulative teeth it has taken
+ const turn = S.turn;
+ for (const x of st) {
+ const d = x.s <= CURTA.sliders ? S.setPos[x.s - 1] : 0;
+ x.gear.position.y = yRow(d);
+ if (x.fork) x.fork.position.y = yRow(d);
+ x.spin.rotation.y = (S.shaft?.[x.s] ?? 0) * 36 * D2R;
+ if (x.lever) {
+ const a = S.arms[x.s] ?? 0;
+ x.lever.rotation.x = -a * 0.42;
+ x.pin.position.y = 49.6 - 1.3 * a;
+ }
+ }
+ // accent: the teeth working now, the gear and the column they drive, the carry in flight
+ for (const i of lastLit) groups.teeth.setColorAt(i, white);
+ lastLit.clear();
+ for (const x of st) {
+ x.gear.material.emissiveIntensity = 0;
+ x.gear.material.color.set('#a89878');
+ if (x.lever) {
+ x.lever.userData.arm.material.emissiveIntensity = 0;
+ x.lever.userData.arm.material.color.set('#b9bdc3');
+ }
+ }
+ for (const w of wheels) w.w.material.emissive?.set(0);
+ groups.carryTooth.material.emissiveIntensity = 0;
+ const lit = (m, k = 1) => {
+ m.color.copy(accentC).lerp(accLit, 0.25);
+ m.emissive.copy(accentC);
+ m.emissiveIntensity = 0.45 * k;
+ };
+ const focus = fx.focusStation ?? (turn && S.active?.tooth?.station);
+ if (turn && focus) {
+ const s = focus;
+ const d = s <= CURTA.sliders ? S.set[s - 1] : 0;
+ const kind = turn.sign > 0 ? 'add' : 'comp';
+ for (const t of teeth) {
+ const row = (t.kind === kind && t.d === d && (kind === 'comp' || d > 0)) || (turn.sign < 0 && s === 1 && t.kind === 'ten');
+ if (row) {
+ groups.teeth.setColorAt(t.i, accLit);
+ lastLit.add(t.i);
+ }
+ }
+ const x = st[s - 1];
+ lit(x.gear.material);
+ }
+ for (const x of st) if (x.lever && (S.arms[x.s] ?? 0) > 0.05) lit(x.lever.userData.arm.material, S.arms[x.s]);
+ if (turn && S.active?.carry) lit(groups.carryTooth.material);
+ groups.teeth.instanceColor.needsUpdate = true;
+ // glass and ghosts
+ const glass = fx.glass ?? 0;
+ for (const g of HOUSING) fade(g, Math.min(fx.ghost?.[g] ?? 1, 1 - 0.86 * glass));
+ for (const g of ['drum', 'shafts', 'crank']) fade(g, fx.ghost?.[g] ?? 1);
+ // one subsystem isolated: the other stations fade, the working ones stay
+ const iso = fx.iso ?? 0;
+ if (iso > 0.001)
+ for (const x of st) {
+ const o = Math.min(fx.ghost?.shafts ?? 1, fx.isoStations?.includes(x.s) ? 1 : 1 - 0.86 * iso);
+ for (const m of x.smats) fadeM(m, o);
+ }
+ // the cut faces the camera
+ const ca = (fx.clipAz ?? 0) * D2R;
+ CLIP.normal.set(-Math.sin(ca), 0, -Math.cos(ca));
+ CLIP.constant = 0;
+ root.updateMatrixWorld(true);
+ };
+
+ return {root, groups, update, setCut, A, mats, teeth, st, wheels, ctrs, HOUSING};
+}
+
+/** world position (metres) of a model point in mm, before any group transform */
+export const mm = (v) => v.clone().multiplyScalar(0.001);
+export {thetaCarry, stationOf};
diff --git a/machines/pepper/curta-shared/film.js b/machines/pepper/curta-shared/film.js
new file mode 100644
index 0000000..9b1d049
--- /dev/null
+++ b/machines/pepper/curta-shared/film.js
@@ -0,0 +1,233 @@
+// Pepper, the explorer film (a Curta Type I after the public sources): the machine (curta.js) on the catalogue stage
+// (stage.js) under the spec sheet (console.js). One input, the crank angle θ and the discrete state of the plan
+// (schedule.js, machine.js), poses every part, every digit and every carry. An operator sets 1 234, multiplies it by
+// 56, takes one turn back with the crank pulled up, clears the machine with the ring. A pure function of t.
+import * as THREE from 'three';
+import {runSim} from 'teardown-kit/runtime.js';
+import {createConsole} from './console.js';
+import {L, azOf, yRow} from './curta.js';
+import {G, ss, state} from './machine.js';
+import {DURATION, SCRIPT, beatAt, filmFacts, filmPlan} from './schedule.js';
+import {createStage} from './stage.js';
+import {T as tr} from './i18n.js';
+
+export {DURATION, SCRIPT};
+export const DEFAULTS = {duration: DURATION, fps: 60, theme: 'light', hud: {enabled: false, title: 'pepper', subtitle: ''}, director: {enabled: false}, watermark: ''};
+const D2R = Math.PI / 180;
+const V3 = THREE.Vector3;
+const P = (r, a, y) => new V3(r * Math.sin(a * D2R) * 0.001, y * 0.001, r * Math.cos(a * D2R) * 0.001);
+export {ss};
+
+// ------------------------------------------------------------ subsystems: a view, a cut, an explosion
+export const SUB = {
+ crank: {view: 'hero', cut: 'solid'},
+ sliders: {view: 'sliders', cut: 'solid'},
+ drum: {view: 'drumX', cut: 'solid', explode: 1, dof: 1, iso: 1, isoStations: [1, 2, 3, 4]},
+ gears: {view: 'gears', cut: 'glass', dof: 0.8, iso: 1, isoStations: [2]},
+ carry: {view: 'carry', cut: 'section', dof: 0.7, top: 1},
+ carriage: {view: 'top', cut: 'solid'},
+ reset: {view: 'reset', cut: 'solid'},
+};
+export const VIEW_CFG = {
+ hero: {view: 'hero', cut: 'solid'},
+ top: {view: 'top', cut: 'solid'},
+ macro: {view: 'macro', cut: 'glass', dof: 1, iso: 0.7, isoStations: [1, 2, 3, 4]},
+ axial: {view: 'axial', cut: 'section'},
+};
+export const cfgOf = (e) => (e.sub ? {...SUB[e.sub], sub: e.sub, btn: e.sub} : {...VIEW_CFG[e.view], vbtn: e.view, btn: e.view});
+
+// ------------------------------------------------------------ views, each a function of u (seconds since it came on) and the state
+const cascadeAz = (S) => {
+ // the camera follows the carry tooth around the drum
+ const th = S.turn ? S.turn.th : 0;
+ const s = Math.min(10.5, Math.max(2.5, (th - G.carry0) / G.pitch + 1));
+ return azOf(s) + 10;
+};
+export const VIEW_FN = {
+ hero: (u) => ({tx: 0, ty: 0.05, tz: 0, az: 30 + 1.5 * Math.min(u, 8), el: 24 + 0.3 * Math.min(u, 8), d: 0.385, sx: 0, sy: -30}),
+ sliders: (u) => ({tx: 0, ty: 0.034, tz: 0.004, az: 16 - 1.4 * Math.min(u, 4), el: 7, d: 0.3, sx: 0, sy: -14}),
+ drumX: (u) => ({tx: 0, ty: 0.042 - 0.004 * ss(0, 6, u), tz: 0, az: 36 - 2.6 * Math.min(u, 7), el: 12 + 1.2 * Math.min(u, 6), d: 0.19 - 0.006 * Math.min(u, 6), sx: 0, sy: -14}),
+ macro: (u) => ({tx: 0, ty: 0.034, tz: 0, az: 30 - 2 * Math.min(u, 6), el: 12, d: 0.15, sx: 0, sy: 10}),
+ gears: (u) => {
+ const p = P(L.shaftR, azOf(2), yRow(3));
+ return {tx: p.x, ty: p.y, tz: p.z, az: azOf(2) + 16 - 2 * Math.min(u, 5), el: 10, d: 0.09, sx: 0, sy: 10};
+ },
+ carry: (u, S) => ({tx: 0, ty: 0.05 - 0.002 * ss(0, 4, u), tz: 0, az: cascadeAz(S), el: 28, d: 0.16, sx: 0, sy: -16}),
+ top: (u) => ({tx: 0, ty: 0.07, tz: 0.004, az: 8 + 1.0 * Math.min(u, 10), el: 60, d: 0.265, sx: 0, sy: -60}),
+ axial: (u) => ({tx: 0, ty: 0.047, tz: 0, az: 18 - 1.2 * Math.min(u, 5), el: 6, d: 0.31, sx: 0, sy: -22}),
+ reset: (u) => ({tx: 0, ty: 0.078, tz: 0, az: -36 + 2.2 * Math.min(u, 6), el: 40, d: 0.28, sx: 0, sy: -22}),
+};
+const BEATS = SCRIPT.map((e) => [e.t, cfgOf(e)]);
+export function blend(a, b, k) {
+ const o = {};
+ for (const key in b) o[key] = key === 'az' ? a.az + ((((b.az - a.az + 540) % 360) - 180) * k) : key === 'd' ? Math.exp(Math.log(a.d) + (Math.log(b.d) - Math.log(a.d)) * k) : a[key] + (b[key] - a[key]) * k;
+ return o;
+}
+const camOf = (i, t, S) => VIEW_FN[BEATS[i][1].view](t - BEATS[i][0], S);
+export const camAt = (t, S) => {
+ let i = 0;
+ while (i < BEATS.length - 1 && BEATS[i + 1][0] <= t) i++;
+ const cur = camOf(i, t, S);
+ if (i === 0) return cur;
+ const k = ss(BEATS[i][0], BEATS[i][0] + 1.6, t);
+ return k >= 1 ? cur : blend(camOf(i - 1, t, S), cur, k);
+};
+/** cut, glass, explode, dof at t, eased between beats */
+export const fxAt = (t) => {
+ let i = 0;
+ while (i < BEATS.length - 1 && BEATS[i + 1][0] <= t) i++;
+ const c = BEATS[i][1];
+ const p = i ? BEATS[i - 1][1] : c;
+ const k = i ? ss(BEATS[i][0] + 0.2, BEATS[i][0] + 1.7, t) : 1;
+ const mix = (key) => (p[key] ?? 0) + ((c[key] ?? 0) - (p[key] ?? 0)) * k;
+ const glass = (x) => (x.cut === 'glass' ? 1 : 0);
+ return {
+ cut: k > 0.25 ? c.cut : p.cut,
+ glass: glass(p) + (glass(c) - glass(p)) * k,
+ explode: mix('explode'),
+ dof: mix('dof'),
+ iso: mix('iso'),
+ isoStations: (k > 0.5 ? c : p).isoStations ?? c.isoStations,
+ ghost: {carriage: 1 - 0.9 * mix('top'), ring: 1 - 0.9 * mix('top'), crank: 1 - 0.85 * mix('top')},
+ sub: c.sub,
+ view: c.vbtn,
+ since: t - BEATS[i][0],
+ };
+};
+
+// ------------------------------------------------------------ the operator's pointer: a click on the button of each beat
+const REST = [[820, 470], [760, 520], [880, 440], [700, 500]];
+export function makeCursor(ui, script = SCRIPT) {
+ const EV = script.filter((e) => e.t > 0).map((e) => ({t: e.t, spot: ui.spots[e.sub || e.view]}));
+ const WP = [[0, 980, 640]];
+ EV.forEach((e, i) => {
+ WP.push([e.t - 0.16, ...e.spot]);
+ WP.push([e.t + 0.24, ...e.spot]);
+ WP.push([e.t + 1.6, ...REST[i % REST.length]]);
+ });
+ WP.sort((a, b) => a[0] - b[0]);
+ return (t) => {
+ let i = 0;
+ while (i < WP.length - 2 && t > WP[i + 1][0]) i++;
+ const [t0, x0, y0] = WP[i];
+ const [t1, x1, y1] = WP[i + 1];
+ const k = ss(t0, t1, t);
+ const len = Math.hypot(x1 - x0, y1 - y0);
+ const bow = Math.sin(Math.PI * k) * Math.min(24, len * 0.07);
+ let press = false;
+ let ring = 0;
+ let rx = 0;
+ let ry = 0;
+ for (const e of EV) {
+ if (t >= e.t - 0.07 && t < e.t + 0.09) press = true;
+ if (t >= e.t && t < e.t + 0.55) {
+ ring = 1 - (t - e.t) / 0.55;
+ [rx, ry] = e.spot;
+ }
+ }
+ return {x: x0 + (x1 - x0) * k + (len ? (-(y1 - y0) / len) * bow : 0), y: y0 + (y1 - y0) * k + (len ? ((x1 - x0) / len) * bow : 0), press, ring, ringX: rx, ringY: ry};
+ };
+}
+
+// ------------------------------------------------------------ callouts per view
+export function calloutsFor(view, st, S, vis) {
+ const g = st.mac.groups;
+ const A = st.mac.A;
+ const W = (grp, v) => g[grp].localToWorld(v.clone());
+ const out = [];
+ const c = (key, at, label, w, acc = false, args = []) => {
+ const [name, sub] = tr(key, ...args);
+ out.push({name, sub, at: at && st.project(at), label, w, o: vis, acc});
+ };
+ if (view === 'hero') {
+ c('cCrank', W('crank', A.crank), [1210, 120], -160);
+ c('cCarriage', W('carriage', A.carriage(-40)), [1210, 340], -170);
+ c('cSliders', W('shell', A.sliders), [390, 470], 170);
+ } else if (view === 'sliders') {
+ c('cSlider', W('shell', A.sliders), [390, 150], 170, false, [S.set[2]]);
+ c('cTape', W('shell', A.band.clone().setY(50)), [1210, 150], -170);
+ } else if (view === 'drumX' || view === 'macro') {
+ c('cDrum', W('drum', A.drum), [390, 600], 190, true);
+ c('cGear', W('shafts', new V3(L.shaftR * Math.sin(azOf(1) * D2R), yRow(S.set[0]), L.shaftR * Math.cos(azOf(1) * D2R))), [1210, 520], -180);
+ } else if (view === 'gears') {
+ c('cGear', W('shafts', new V3(L.shaftR * Math.sin(azOf(2) * D2R), yRow(S.set[1]), L.shaftR * Math.cos(azOf(2) * D2R))), [390, 160], 180);
+ } else if (view === 'carry') {
+ // the carry in flight, else the last one taken this turn
+ const cy = S.active?.carry || (S.turn && S.turn.ev.carries.filter((x) => x.at <= S.turn.th).pop());
+ if (cy) c('cLever', W('shafts', A.lever(cy.station)), [390, 140], 180, true, [cy.col]);
+ c('cBell', W('drum', A.drumTop), [390, 640], 180);
+ } else if (view === 'top') {
+ c('cResult', A.result(1), [1210, 200], -170);
+ c('cCounter', A.counter(6), [390, 560], 180);
+ } else if (view === 'axial') {
+ c('cRise', W('drum', A.drum), [390, 610], 180, true);
+ } else if (view === 'reset') {
+ c('cRing', W('ring', A.ring), [390, 160], 180);
+ }
+ return out;
+}
+
+// ------------------------------------------------------------ captions on the film's timeline
+export const CAPTIONS = [
+ [0, 'c:hero'],
+ [2.2, 'c:set'],
+ [4.8, 'c:drum'],
+ [8.0, 'c:drum2'],
+ [11.3, 'c:turns'],
+ [14.9, 'c:carriage'],
+ [17.7, 'c:turns2'],
+ [21.2, 'c:pull'],
+ [22.9, 'c:sub'],
+ [25.0, 'c:cascade'],
+ [29.5, 'c:sum'],
+ [30.8, 'c:clear'],
+ [34.4, 'c:end'],
+];
+export const captionAt = (t, facts) => {
+ const c = CAPTIONS.filter(([a]) => a <= t).pop();
+ return tr(c[1], facts);
+};
+
+// ------------------------------------------------------------ the film
+export function makeFilm() {
+ return async ({canvas, capture}) => {
+ const st = createStage({canvas});
+ const resize = () => st.resize(1600, 900, capture ? devicePixelRatio || 1 : Math.min(devicePixelRatio || 1, 2));
+ resize();
+ const ui = createConsole(document.getElementById('app'));
+ const cursorAt = makeCursor(ui);
+ if (window.__SIM_CONFIG?.clean) ui.root.style.display = 'none';
+ const plan = filmPlan();
+ const facts = filmFacts(plan);
+ const lastHist = plan.steps.find((s) => s.kind === 'clear').s0.hist;
+ window.PEPPER = {st, plan};
+ return {
+ resize,
+ render(t) {
+ const S = state(plan, t);
+ const fx = fxAt(t);
+ const c = camAt(t, S);
+ st.setCut(fx.cut);
+ st.frame(S, {...fx}, {...c, az: c.az * D2R, el: c.el * D2R});
+ const b = beatAt(t);
+ const view = cfgOf(b).view;
+ ui.update({
+ S,
+ sub: fx.sub,
+ view: fx.view,
+ cut: fx.cut,
+ explode: fx.explode,
+ paused: false,
+ lastHist: t > 30.8 ? lastHist : null,
+ callouts: calloutsFor(view, st, S, ss(1.3, 2.0, fx.since) * (1 - ss(-0.5, 0, t - (SCRIPT.find((e) => e.t > t)?.t ?? 99)))),
+ cursor: cursorAt(t),
+ caption: captionAt(t, facts),
+ handNote: tr('handFilm'),
+ });
+ return {};
+ },
+ };
+ };
+}
+
+export const run = () => runSim(DEFAULTS, makeFilm());
diff --git a/machines/pepper/curta-shared/i18n.js b/machines/pepper/curta-shared/i18n.js
new file mode 100644
index 0000000..4fa8fd1
--- /dev/null
+++ b/machines/pepper/curta-shared/i18n.js
@@ -0,0 +1,149 @@
+// Every word on screen, in English (the default), French and Spanish. The language itself, the number format and the
+// strings of the page's header and footer are shared by the collection (../../../shared/i18n.js).
+import {LANG, LANGS, makeT, nf} from '../../../shared/i18n.js';
+
+export {LANG, LANGS, nf};
+
+const b = (x) => `${x} `;
+const S = {
+ // ------------------------------------------------------------ the sheet
+ title: {en: 'Pepper', fr: 'Pepper', es: 'Pepper'},
+ subtitle: {en: 'CURTA TYPE I · CURT HERZSTARK · 1948', fr: 'CURTA TYPE I · CURT HERZSTARK · 1948', es: 'CURTA TIPO I · CURT HERZSTARK · 1948'},
+ model: {
+ en: 'A model after the public sources · not affiliated with any maker or rights holder',
+ fr: 'Modèle d’après les sources publiques · sans lien avec un fabricant ni un ayant droit',
+ es: 'Modelo según las fuentes públicas · sin relación con ningún fabricante ni titular de derechos',
+ },
+ sheet: {en: 'Technical sheet', fr: 'Fiche technique', es: 'Ficha técnica'},
+ specReg: {en: 'Setting · counter · result', fr: 'Inscription · tours · totalisateur', es: 'Inscripción · vueltas · totalizador'},
+ specDia: {en: 'Diameter', fr: 'Diamètre', es: 'Diámetro'},
+ specH: {en: 'Height', fr: 'Hauteur', es: 'Altura'},
+ specHv: {en: (h, c) => `${h} mm · ${c} with crank`, fr: (h, c) => `${h} mm · ${c} avec manivelle`, es: (h, c) => `${h} mm · ${c} con manivela`},
+ specMass: {en: 'Mass', fr: 'Masse', es: 'Masa'},
+ specMade: {en: 'Made', fr: 'Fabriquées', es: 'Fabricadas'},
+ specMadev: {en: (n, a, z) => `~${n} · ${a}–${z}`, fr: (n, a, z) => `~${n} · ${a}–${z}`, es: (n, a, z) => `~${n} · ${a}–${z}`},
+ specII: {en: 'Type II, for comparison', fr: 'Type II, pour comparer', es: 'Tipo II, para comparar'},
+ // ------------------------------------------------------------ the turn
+ turnTitle: {en: 'One turn of the crank', fr: 'Un tour de manivelle', es: 'Una vuelta de manivela'},
+ chartLegend: {
+ en: (a) => `Rows: the 11 result columns. ▮ a drum tooth turning the column’s gear ● its carry, taken from the column below. The counter steps at 338°.`,
+ fr: (a) => `Lignes : les 11 colonnes du totalisateur. ▮ une dent du tambour qui tourne la roue de la colonne ● son report, reçu de la colonne d’en dessous. Le compteur avance à 338°.`,
+ es: (a) => `Filas: las 11 columnas del totalizador. ▮ un diente del tambor que gira la rueda de la columna ● su acarreo, recibido de la columna de abajo. El contador avanza a 338°.`,
+ },
+ modeAdd: {en: 'ADDITION', fr: 'ADDITION', es: 'SUMA'},
+ modeSub: {en: 'SUBTRACTION', fr: 'SOUSTRACTION', es: 'RESTA'},
+ turnOf: {en: (n, m) => `TURN ${n} OF ${m}`, fr: (n, m) => `TOUR ${n} SUR ${m}`, es: (n, m) => `VUELTA ${n} DE ${m}`},
+ posN: {en: (p) => `POSITION ${p}`, fr: (p) => `POSITION ${p}`, es: (p) => `POSICIÓN ${p}`},
+ crankDown: {en: 'CRANK DOWN · ADDS', fr: 'MANIVELLE BASSE · ADDITIONNE', es: 'MANIVELA ABAJO · SUMA'},
+ crankUp: {en: 'CRANK UP · SUBTRACTS', fr: 'MANIVELLE HAUTE · SOUSTRAIT', es: 'MANIVELA ARRIBA · RESTA'},
+ overflow: {en: 'OFF THE TOP', fr: 'HORS CAPACITÉ', es: 'FUERA DE CAPACIDAD'},
+ // ------------------------------------------------------------ the hand
+ grpHand: {en: 'YOUR HAND', fr: 'VOTRE MAIN', es: 'TU MANO'},
+ 'act:turn': {en: '↻ Turn', fr: '↻ Un tour', es: '↻ Una vuelta'},
+ 'act:pull': {en: 'Crank up', fr: 'Manivelle haute', es: 'Manivela arriba'},
+ 'act:carL': {en: '◂ Carriage', fr: '◂ Chariot', es: '◂ Carro'},
+ 'act:carR': {en: 'Carriage ▸', fr: 'Chariot ▸', es: 'Carro ▸'},
+ 'act:clear': {en: 'Clear', fr: 'Effacer', es: 'Borrar'},
+ 'act:pause': {en: 'Pause', fr: 'Pause', es: 'Pausa'},
+ 'act:play': {en: 'Play', fr: 'Lecture', es: 'Seguir'},
+ handFilm: {
+ en: 'Set a digit by clicking the setting drums (top, right), turn, shift, clear. Keys: space turn, U crank, ← → carriage.',
+ fr: 'Inscrivez un chiffre en cliquant les tambours d’inscription (en haut à droite), tournez, décalez, effacez. Touches : espace tour, U manivelle, ← → chariot.',
+ es: 'Inscribe una cifra haciendo clic en los tambores de inscripción (arriba a la derecha), gira, desplaza, borra. Teclas: espacio vuelta, U manivela, ← → carro.',
+ },
+ handFull: {en: 'The carriage holds 6 positions.', fr: 'Le chariot a 6 positions.', es: 'El carro tiene 6 posiciones.'},
+ // ------------------------------------------------------------ registers
+ regResult: {en: 'Result', fr: 'Totalisateur', es: 'Totalizador'},
+ regCtr: {en: 'Turns counter', fr: 'Compteur de tours', es: 'Contador de vueltas'},
+ regSet: {en: 'Setting', fr: 'Inscription', es: 'Inscripción'},
+ opTitle: {en: 'The operation, by hand', fr: 'L’opération, à la main', es: 'La operación, a mano'},
+ opAddSide: {en: (p) => `SETTING × 10^${p - 1}`, fr: (p) => `INSCRIPTION × 10^${p - 1}`, es: (p) => `INSCRIPCIÓN × 10^${p - 1}`},
+ opSubSide: {en: 'TEN’S COMPLEMENT', fr: 'COMPLÉMENT À DIX', es: 'COMPLEMENTO A DIEZ'},
+ addNote: {
+ en: (s, p, v) => `Each column takes as many teeth as its slider shows: ${s} in position ${p} adds ${b(v)}.`,
+ fr: (s, p, v) => `Chaque colonne reçoit autant de dents que son curseur l’indique : ${s} en position ${p} ajoute ${b(v)}.`,
+ es: (s, p, v) => `Cada columna recibe tantos dientes como marca su cursor: ${s} en posición ${p} suma ${b(v)}.`,
+ },
+ compNote: {
+ en: (v, a) => `To take away ${b(v)} it adds 1011 − ${v}: 9 − d teeth in every column, 10 − d in the first (the 9’s complement + 1). The 1 that falls off the top is lost.`,
+ fr: (v, a) => `Pour retrancher ${b(v)}, elle ajoute 1011 − ${v} : 9 − d dents par colonne, 10 − d sur la première (le complément à 9, plus 1). Le 1 qui sort par le haut est perdu.`,
+ es: (v, a) => `Para quitar ${b(v)} suma 1011 − ${v}: 9 − d dientes por columna, 10 − d en la primera (el complemento a 9, más 1). El 1 que sale por arriba se pierde.`,
+ },
+ upNote: {
+ en: (a) => `Crank up: the drum has risen 1.2 mm , its complement rows face the gears.`,
+ fr: (a) => `Manivelle haute : le tambour est monté de 1,2 mm , ses rangées de complément font face aux roues.`,
+ es: (a) => `Manivela arriba: el tambor ha subido 1,2 mm , sus filas de complemento quedan frente a las ruedas.`,
+ },
+ histTurns: {en: (n, p) => `${n} turn${n > 1 ? 's' : ''} · pos. ${p}`, fr: (n, p) => `${n} tour${n > 1 ? 's' : ''} · pos. ${p}`, es: (n, p) => `${n} vuelta${n > 1 ? 's' : ''} · pos. ${p}`},
+ // ------------------------------------------------------------ buttons
+ grpSub: {en: 'SUBSYSTEMS', fr: 'SOUS-SYSTÈMES', es: 'SUBSISTEMAS'},
+ 'sub:crank': {en: 'Crank', fr: 'Manivelle', es: 'Manivela'},
+ 'sub:sliders': {en: 'Setting sliders', fr: 'Curseurs d’inscription', es: 'Cursores de inscripción'},
+ 'sub:drum': {en: 'Stepped drum', fr: 'Tambour à gradins', es: 'Tambor escalonado'},
+ 'sub:gears': {en: 'Counting gears', fr: 'Roues de comptage', es: 'Ruedas de conteo'},
+ 'sub:carry': {en: 'Carries', fr: 'Reports', es: 'Acarreos'},
+ 'sub:carriage': {en: 'Carriage & counters', fr: 'Chariot & compteurs', es: 'Carro y contadores'},
+ 'sub:reset': {en: 'Clearing', fr: 'Remise à zéro', es: 'Puesta a cero'},
+ grpCut: {en: 'BODY', fr: 'CORPS', es: 'CUERPO'},
+ 'cut:solid': {en: 'Solid', fr: 'Pleine', es: 'Entera'},
+ 'cut:glass': {en: 'Glass', fr: 'Verre', es: 'Cristal'},
+ 'cut:section': {en: 'Axial section', fr: 'Section axiale', es: 'Sección axial'},
+ 'cut:explode': {en: 'Exploded', fr: 'Éclatée', es: 'Despiece'},
+ grpView: {en: 'VIEW', fr: 'VUE', es: 'VISTA'},
+ 'view:hero': {en: 'In the hand', fr: 'En main', es: 'En la mano'},
+ 'view:top': {en: 'Top', fr: 'Dessus', es: 'Arriba'},
+ 'view:macro': {en: 'Drum macro', fr: 'Macro tambour', es: 'Macro tambor'},
+ 'view:axial': {en: 'Axial cut', fr: 'Coupe axiale', es: 'Corte axial'},
+ hint: {en: 'DRAG TO TURN IT · WHEEL TO ZOOM · H HIDES THE SHEET', fr: 'GLISSER POUR TOURNER · MOLETTE POUR ZOOMER · H MASQUE LA FICHE', es: 'ARRASTRA PARA GIRAR · RUEDA PARA ACERCAR · H OCULTA LA FICHA'},
+ // ------------------------------------------------------------ callouts [name, sub]
+ cCrank: {en: ['Crank', 'CLOCKWISE ONLY · PULL UP TO SUBTRACT'], fr: ['Manivelle', 'SENS HORAIRE · TIRÉE POUR SOUSTRAIRE'], es: ['Manivela', 'SOLO HORARIO · ARRIBA PARA RESTAR']},
+ cCarriage: {en: ['Carriage', 'RESULT 11 · COUNTER 6 · 6 POSITIONS'], fr: ['Chariot', 'TOTALISATEUR 11 · COMPTEUR 6 · 6 POSITIONS'], es: ['Carro', 'TOTALIZADOR 11 · CONTADOR 6 · 6 POSICIONES']},
+ cSliders: {en: ['Setting sliders', '8 · 0 AT THE TOP, 9 AT THE BOTTOM'], fr: ['Curseurs', '8 · 0 EN HAUT, 9 EN BAS'], es: ['Cursores', '8 · 0 ARRIBA, 9 ABAJO']},
+ cSlider: {en: (d) => ['Slider 3', `SET TO ${d}: ITS GEAR ON ROW ${d}`], fr: (d) => ['Curseur 3', `SUR ${d} : SA ROUE SUR LA RANGÉE ${d}`], es: (d) => ['Cursor 3', `EN ${d}: SU RUEDA EN LA FILA ${d}`]},
+ cTape: {en: ['Setting windows', 'THE DIGIT EACH SLIDER SETS'], fr: ['Fenêtres d’inscription', 'LE CHIFFRE DE CHAQUE CURSEUR'], es: ['Ventanas de inscripción', 'LA CIFRA DE CADA CURSOR']},
+ cDrum: {en: ['Stepped drum', '9 ADD ROWS · 9 COMPLEMENT · 1 OF TEN'], fr: ['Tambour à gradins', '9 RANGS ADDITION · 9 COMPLÉMENT · 1 DE DIX'], es: ['Tambor escalonado', '9 FILAS SUMA · 9 COMPLEMENTO · 1 DE DIEZ']},
+ cGear: {en: ['Counting gear', 'SLIDES ON ITS SQUARE SHAFT'], fr: ['Roue de comptage', 'GLISSE SUR SON AXE CARRÉ'], es: ['Rueda de conteo', 'SE DESLIZA EN SU EJE CUADRADO']},
+ cResultWheel: {en: ['Result drum, column 1', 'ONE TOOTH = ONE DIGIT'], fr: ['Tambour du totalisateur, colonne 1', 'UNE DENT = UN CHIFFRE'], es: ['Tambor del totalizador, columna 1', 'UN DIENTE = UNA CIFRA']},
+ cLever: {en: (j) => ['Carry lever', `9 → 0 BELOW · COLUMN ${j} TAKES 1`], fr: (j) => ['Bras de report', `9 → 0 EN DESSOUS · LA COLONNE ${j} PREND 1`], es: (j) => ['Palanca de acarreo', `9 → 0 DEBAJO · LA COLUMNA ${j} TOMA 1`]},
+ cBell: {en: ['Carry bell', 'ONE TOOTH, COLUMN AFTER COLUMN'], fr: ['Cloche des reports', 'UNE DENT, COLONNE APRÈS COLONNE'], es: ['Campana de acarreos', 'UN DIENTE, COLUMNA TRAS COLUMNA']},
+ cResult: {en: ['Result', '11 DIGITS'], fr: ['Totalisateur', '11 CHIFFRES'], es: ['Totalizador', '11 CIFRAS']},
+ cCounter: {en: ['Turns counter', '6 DIGITS · COUNTS AT THE CARRIAGE’S POSITION'], fr: ['Compteur de tours', '6 CHIFFRES · COMPTE À LA POSITION DU CHARIOT'], es: ['Contador de vueltas', '6 CIFRAS · CUENTA EN LA POSICIÓN DEL CARRO']},
+ cRise: {en: ['The drum, raised 1.2 mm', 'COMPLEMENT ROWS ENGAGED'], fr: ['Le tambour, monté de 1,2 mm', 'RANGÉES DE COMPLÉMENT EN PRISE'], es: ['El tambor, subido 1,2 mm', 'FILAS DE COMPLEMENTO ENGRANADAS']},
+ cRing: {en: ['Clearing ring', 'ONE TURN CLEARS BOTH REGISTERS'], fr: ['Anneau de remise à zéro', 'UN TOUR EFFACE LES DEUX REGISTRES'], es: ['Anillo de puesta a cero', 'UNA VUELTA BORRA LOS DOS REGISTROS']},
+ // ------------------------------------------------------------ captions
+ 'c:hero': {en: 'A whole calculator in one hand: 8 sliders, 6 counter digits, 11 result digits.', fr: 'Une calculatrice entière dans la main : 8 curseurs, 6 chiffres de tours, 11 de résultat.', es: 'Una calculadora entera en la mano: 8 cursores, 6 cifras de vueltas, 11 de resultado.'},
+ 'c:set': {en: (f) => `The sliders set ${nf(f.set)}: each takes its gear to the row of its digit.`, fr: (f) => `Les curseurs inscrivent ${nf(f.set)} : chacun mène sa roue à la rangée de son chiffre.`, es: (f) => `Los cursores inscriben ${nf(f.set)}: cada uno lleva su rueda a la fila de su cifra.`},
+ 'c:drum': {en: 'One stepped drum for every column: the gear on 4 meets 4 teeth.', fr: 'Un seul tambour à gradins pour toutes les colonnes : la roue sur 4 rencontre 4 dents.', es: 'Un solo tambor escalonado para todas las columnas: la rueda en 4 encuentra 4 dientes.'},
+ 'c:drum2': {en: 'It serves the gears one after another; one tooth, one digit.', fr: 'Il sert les roues l’une après l’autre ; une dent, un chiffre.', es: 'Sirve las ruedas una tras otra; un diente, una cifra.'},
+ 'c:turns': {en: (f) => `Six turns in position 1: ${nf(f.set)} × ${nf(f.m1)}. The counter counts them.`, fr: (f) => `Six tours en position 1 : ${nf(f.set)} × ${nf(f.m1)}. Le compteur les compte.`, es: (f) => `Seis vueltas en posición 1: ${nf(f.set)} × ${nf(f.m1)}. El contador las cuenta.`},
+ 'c:carriage': {en: 'Lift the carriage, one notch, drop it: each turn now counts ten.', fr: 'Le chariot soulevé, tourné d’un cran, reposé : chaque tour compte dix.', es: 'El carro levantado, girado una muesca, bajado: cada vuelta cuenta diez.'},
+ 'c:turns2': {en: (f) => `Five turns in position 2: ${nf(f.set)} × ${nf(f.m2)} = ${nf(f.r1)}.`, fr: (f) => `Cinq tours en position 2 : ${nf(f.set)} × ${nf(f.m2)} = ${nf(f.r1)}.`, es: (f) => `Cinco vueltas en posición 2: ${nf(f.set)} × ${nf(f.m2)} = ${nf(f.r1)}.`},
+ 'c:pull': {en: 'Crank pulled up: the drum rises half a row, its complement teeth engage.', fr: 'Manivelle tirée : le tambour monte d’une demi-rangée, les dents de complément engrènent.', es: 'Manivela arriba: el tambor sube media fila, engranan los dientes de complemento.'},
+ 'c:sub': {en: 'It subtracts by adding: 9 − d teeth in each column, 10 − d in the first.', fr: 'Elle soustrait en additionnant : 9 − d dents par colonne, 10 − d sur la première.', es: 'Resta sumando: 9 − d dientes por columna, 10 − d en la primera.'},
+ 'c:cascade': {en: 'Each 9 → 0 drops the next lever; the carry tooth takes them in order.', fr: 'Chaque 9 → 0 abaisse le bras suivant ; la dent de report les prend dans l’ordre.', es: 'Cada 9 → 0 baja la palanca siguiente; el diente de acarreo las toma en orden.'},
+ 'c:sum': {en: (f) => `${nf(f.r1)} − ${nf(f.sub)} = ${nf(f.r2)}. The counter went back to ${nf(f.c2)}.`, fr: (f) => `${nf(f.r1)} − ${nf(f.sub)} = ${nf(f.r2)}. Le compteur est revenu à ${nf(f.c2)}.`, es: (f) => `${nf(f.r1)} − ${nf(f.sub)} = ${nf(f.r2)}. El contador volvió a ${nf(f.c2)}.`},
+ 'c:clear': {en: 'Lift the carriage, one turn of the ring: both registers back to zero.', fr: 'Chariot soulevé, un tour d’anneau : les deux registres reviennent à zéro.', es: 'Carro levantado, una vuelta del anillo: los dos registros vuelven a cero.'},
+ 'c:end': {en: (f) => `${nf(f.set)} × ${nf(f.c2)} = ${nf(f.r2)}: six turns, five turns, one back.`, fr: (f) => `${nf(f.set)} × ${nf(f.c2)} = ${nf(f.r2)} : six tours, cinq tours, un en arrière.`, es: (f) => `${nf(f.set)} × ${nf(f.c2)} = ${nf(f.r2)}: seis vueltas, cinco vueltas, una atrás.`},
+ // live captions
+ 'l:idle': {en: 'Set a number, then turn the crank.', fr: 'Inscrivez un nombre, puis tournez la manivelle.', es: 'Inscribe un número y gira la manivela.'},
+ 'l:add': {en: (n) => `Adding ${n}.`, fr: (n) => `Addition de ${n}.`, es: (n) => `Sumando ${n}.`},
+ 'l:sub': {en: (n) => `Subtracting ${n} by its ten’s complement.`, fr: (n) => `Soustraction de ${n} par son complément à dix.`, es: (n) => `Restando ${n} por su complemento a diez.`},
+ 'l:carriage': {en: (p) => `The carriage to position ${p}.`, fr: (p) => `Le chariot en position ${p}.`, es: (p) => `El carro a la posición ${p}.`},
+ 'l:clear': {en: 'Clearing both registers.', fr: 'Remise à zéro des deux registres.', es: 'Puesta a cero de los dos registros.'},
+ 'l:slide': {en: 'Setting.', fr: 'Inscription.', es: 'Inscripción.'},
+ 'l:crank': {en: 'The crank changes mode.', fr: 'La manivelle change de mode.', es: 'La manivela cambia de modo.'},
+ // ------------------------------------------------------------ the live page
+ tourOn: {en: 'AUTO TOUR · CLICK TO TAKE OVER', fr: 'VISITE AUTO · CLIQUER POUR PRENDRE LA MAIN', es: 'VISITA AUTOMÁTICA · CLIC PARA TOMAR EL CONTROL'},
+ tourOff: {en: '▶ RESUME THE TOUR', fr: '▶ REPRENDRE LA VISITE', es: '▶ REANUDAR LA VISITA'},
+ turnPhone: {en: 'Turn your phone sideways for the full sheet', fr: 'Tournez le téléphone pour la fiche complète', es: 'Gira el teléfono para ver la ficha completa'},
+ docTitle: {en: 'Pepper · the Curta calculator, taken apart', fr: 'Pepper · la calculatrice Curta, démontée', es: 'Pepper · la calculadora Curta, desmontada'},
+ footCredit: {
+ en: 'A Curta Type I after the public sources, not affiliated with any maker or rights holder',
+ fr: 'Une Curta Type I d’après les sources publiques, sans lien avec un fabricant ni un ayant droit',
+ es: 'Una Curta Tipo I según las fuentes públicas, sin relación con ningún fabricante ni titular de derechos',
+ },
+};
+
+/** the string for key in the page's language; extra arguments fill a templated one */
+export const T = makeT(S);
+export const STRINGS = S;
diff --git a/machines/pepper/curta-shared/live.js b/machines/pepper/curta-shared/live.js
new file mode 100644
index 0000000..ed8b3d2
--- /dev/null
+++ b/machines/pepper/curta-shared/live.js
@@ -0,0 +1,452 @@
+// Pepper (a Curta Type I after the public sources) as a page you drive: the film's machine and sheet, run in real
+// time. Your hand: one turn of the crank, the crank pulled up or pushed down, the carriage one notch either way, the
+// clearing ring, the setting drums (click the upper half of a digit for +1, the lower half for −1). The subsystem
+// buttons open each part (view, cut, explosion, isolation); the body can be solid, glass, cut or exploded; drag to
+// turn the machine, wheel to zoom, H to hide the sheet. Every action becomes a step of the same plan the film uses
+// (machine.js), so every digit and every carry still comes from the model. Left alone it plays the film as an auto
+// tour with the operator's cursor, looped; a click, drag, wheel or key hands control to the visitor, 25 s of quiet
+// hands it back.
+import {warmComposer} from 'teardown-kit/warm.js';
+import {ACTS, CUTS, SUBS, VIEWS, createConsole} from './console.js';
+import {SUB, VIEW_CFG, VIEW_FN, calloutsFor, camAt, captionAt, fxAt, makeCursor} from './film.js';
+import {CURTA, createPlan, fromDigits, state, turnEvents} from './machine.js';
+import {DURATION, SCRIPT, beatAt, filmFacts, filmPlan} from './schedule.js';
+import {createStage} from './stage.js';
+import {createChrome} from '../../../shared/chrome.js';
+import {LANG, nf, T as tr} from './i18n.js';
+
+const W = 1600;
+const H = 900;
+const IDLE = 25;
+const D2R = Math.PI / 180;
+const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
+const wrap = (d) => ((((d + 180) % 360) + 360) % 360) - 180;
+const ss = (a, b, t) => {
+ const u = clamp((t - a) / (b - a), 0, 1);
+ return u * u * (3 - 2 * u);
+};
+
+const CSS = (top) => `
+html,body{margin:0;background:#e9e7e2 linear-gradient(#efeeea,#e9e7e2 58%,#dedbd4) fixed;overflow:hidden}
+#app{position:absolute!important;inset:auto!important;left:0;top:0;width:${W}px;height:${H}px;transform-origin:0 0}
+#gl{touch-action:none;cursor:grab}
+#gl.drag{cursor:grabbing}
+.tb{pointer-events:auto;cursor:pointer}
+.tb:hover{border-color:#8a877f}
+.hit{position:absolute;pointer-events:auto;cursor:pointer}
+.tourchip{position:absolute;left:800px;top:${18 + top}px;transform:translateX(-50%);display:flex;align-items:center;gap:7px;height:22px;padding:0 12px;border:1px solid #d9d5cc;border-radius:11px;background:rgba(246,244,239,.95);color:#1b1a17;font:600 9px Inter,sans-serif;letter-spacing:1.3px;white-space:nowrap;cursor:pointer;pointer-events:auto}
+.tourchip i{display:block;width:6px;height:6px;border-radius:50%;background:#b07a1c}
+.tourchip.on i{animation:blink 1.2s steps(2) infinite}
+@keyframes blink{50%{opacity:.15}}
+.turn{position:fixed;left:0;right:0;bottom:18px;text-align:center;font:600 12px/1.4 Inter,sans-serif;letter-spacing:1px;color:#8a877f;text-transform:uppercase;display:none}
+@media (orientation:portrait) and (max-width:820px){.turn{display:block}}
+`;
+
+export function runLive() {
+ const style = document.createElement('style');
+ style.textContent = CSS(0);
+ document.head.appendChild(style);
+ document.title = tr('docTitle');
+ document.documentElement.lang = LANG;
+ const app = document.getElementById('app');
+ const canvas = document.createElement('canvas');
+ canvas.id = 'gl';
+ app.appendChild(canvas);
+ const st = createStage({canvas});
+ const ui = createConsole(app);
+ const tourCursor = makeCursor(ui);
+
+ // ------------------------------------------------------------ quality tiers
+ const TIERS = [
+ {samples: 8, ao: true, shadow: 2048, dof: true, pr: 2},
+ {samples: 4, ao: false, shadow: 1024, dof: true, pr: 1.5},
+ {samples: 2, ao: false, shadow: 1024, dof: false, pr: 1},
+ ];
+ const light = matchMedia('(pointer: coarse)').matches || Math.min(innerWidth, innerHeight) < 700;
+ let tier = light ? 2 : 0;
+ let scale = 1;
+ const chrome = createChrome({slug: 'pepper', name: 'Pepper', credit: tr('footCredit')});
+ const fit = () => {
+ const {top, bottom} = chrome.heights();
+ const h = innerHeight - top - bottom;
+ scale = Math.min(innerWidth / W, h / H);
+ app.style.transform = `translate(${(innerWidth - W * scale) / 2}px, ${top + (h - H * scale) / 2}px) scale(${scale})`;
+ st.resize(W, H, clamp((devicePixelRatio || 1) * scale, 0.75, TIERS[tier].pr));
+ };
+ const applyTier = () => {
+ const {pr, ...q} = TIERS[tier];
+ st.setQuality(q);
+ fit();
+ };
+ applyTier();
+ addEventListener('resize', fit);
+ const tourChip = document.createElement('div');
+ tourChip.className = 'tourchip';
+ ui.root.appendChild(tourChip);
+ const note = document.createElement('div');
+ note.className = 'turn';
+ note.textContent = tr('turnPhone');
+ document.body.appendChild(note);
+
+ // ------------------------------------------------------------ the plans: the film's (the tour) and the visitor's
+ const film = filmPlan();
+ const facts = filmFacts(film);
+ const filmHist = film.steps.find((s) => s.kind === 'clear').s0.hist;
+ let T = 0;
+ let mT = 0; // the machine's clock (stops on pause)
+ let plan = null; // the visitor's plan, on the machine clock
+ let handoff = null; // {t1, cyc0, T0}: the film's step still finishing after a takeover
+ const snap = (s) => ({set: s.set.slice(), up: s.up, p: s.p, reg: s.reg.slice(), ctr: s.ctr.slice(), arms: s.arms.slice(), shaft: s.shaft.slice(), turnsDone: s.turnsDone, hist: s.hist.slice()});
+
+ // ------------------------------------------------------------ state
+ let paused = false;
+ let sub = 'crank';
+ let vbtn = null;
+ let view = 'hero';
+ let viewAt = 0;
+ let cut = 'solid';
+ let explodeOn = 0;
+ const cfg = {...SUB.crank};
+ const fxs = {glass: 0, explode: 0, dof: 0, iso: 0, top: 0};
+ const off = {az: 0, el: 0, k: 1};
+ const cam = {...VIEW_FN.hero(0)};
+ let ring = {t: -9, x: 0, y: 0};
+ let hidden = false;
+ const pulse = (x, y) => (ring = {t: T, x, y});
+ const pulseBtn = (k) => ui.spots[k] && pulse(...ui.spots[k]);
+ let handNote = tr('handFilm');
+
+ // ------------------------------------------------------------ the tour
+ const AP = {on: true, t0: 0, loop: 0};
+ let lastInput = 0;
+ const startTour = () => {
+ AP.on = true;
+ AP.t0 = T;
+ AP.loop = 0;
+ paused = false;
+ Object.assign(off, {az: 0, el: 0, k: 1});
+ };
+ const takeOver = () => {
+ lastInput = T;
+ if (!AP.on) return;
+ AP.on = false;
+ const cyc = (T - AP.t0) % DURATION;
+ const b = beatAt(cyc);
+ sub = b.sub || null;
+ vbtn = b.view || null;
+ Object.assign(cfg, b.sub ? SUB[b.sub] : VIEW_CFG[b.view]);
+ view = cfg.view;
+ viewAt = T - (cyc - b.t);
+ cut = cfg.cut;
+ explodeOn = cfg.explode ?? 0;
+ // the machine carries on: the step in progress finishes as in the film, then the visitor's plan takes over
+ const S = state(film, cyc);
+ const step = S.step >= 0 && cyc <= film.steps[S.step].t1 ? film.steps[S.step] : null;
+ mT = 0;
+ if (step) {
+ handoff = {t1: step.t1 - cyc, cyc0: cyc};
+ plan = createPlan(snap(step.s1));
+ } else {
+ handoff = null;
+ plan = createPlan(snap(S));
+ }
+ };
+ tourChip.addEventListener('pointerdown', (e) => {
+ e.stopPropagation();
+ lastInput = T;
+ if (AP.on) takeOver();
+ else startTour();
+ });
+ addEventListener('pointerdown', (e) => {
+ if (e.target !== tourChip) takeOver();
+ });
+
+ // ------------------------------------------------------------ the hand: every action is a step of the plan
+ const queueAt = () => {
+ const last = plan.steps[plan.steps.length - 1];
+ return Math.max(mT, handoff ? handoff.t1 : 0, last ? last.t1 + 0.04 : 0);
+ };
+ const act = (kind, extra, dur) => {
+ if (!plan) return;
+ const t0 = queueAt();
+ if (t0 - mT > 4) return; // a long queue: drop the extra clicks
+ plan.add({kind, t0, t1: t0 + dur, ...extra});
+ };
+ const end = () => plan.end;
+ const doTurn = () => act('turns', {n: 1}, 0.62);
+ const doPull = () => act('crank', {up: !end().up}, 0.45);
+ const doCar = (dp) => {
+ const to = end().p + dp;
+ if (to < 1 || to > CURTA.counter) {
+ handNote = tr('handFull');
+ return;
+ }
+ act('carriage', {to}, 1.3);
+ };
+ const doClear = () => act('clear', {}, 2.2);
+ const doSet = (i, dd) => {
+ const to = end().set.slice();
+ to[i] = (to[i] + dd + 10) % 10;
+ act('slide', {to}, 0.32);
+ };
+ const ACT = {turn: doTurn, pull: doPull, carL: () => doCar(-1), carR: () => doCar(1), clear: doClear, pause: () => (paused = !paused)};
+ for (const k of ACTS)
+ ui.btn[k].addEventListener('click', () => {
+ takeOver();
+ pulseBtn(k);
+ ACT[k]();
+ });
+ const setSub = (k) => {
+ sub = k;
+ vbtn = null;
+ Object.assign(cfg, {iso: 0, isoStations: null, top: 0, dof: 0, explode: 0}, SUB[k]);
+ view = cfg.view;
+ viewAt = T;
+ cut = cfg.cut;
+ explodeOn = cfg.explode ?? 0;
+ Object.assign(off, {az: 0, el: 0, k: 1});
+ };
+ for (const k of SUBS)
+ ui.btn[k].addEventListener('click', () => {
+ takeOver();
+ pulseBtn(k);
+ setSub(k);
+ });
+ for (const k of VIEWS)
+ ui.btn[k].addEventListener('click', () => {
+ takeOver();
+ pulseBtn(k);
+ sub = null;
+ vbtn = k;
+ Object.assign(cfg, {iso: 0, isoStations: null, top: 0, dof: 0, explode: 0}, VIEW_CFG[k]);
+ view = cfg.view;
+ viewAt = T;
+ cut = cfg.cut;
+ explodeOn = 0;
+ Object.assign(off, {az: 0, el: 0, k: 1});
+ });
+ for (const k of CUTS)
+ ui.btn[k].addEventListener('click', () => {
+ takeOver();
+ pulseBtn(k);
+ cut = k;
+ });
+ ui.btn.explode.addEventListener('click', () => {
+ takeOver();
+ pulseBtn('explode');
+ explodeOn = explodeOn > 0.5 ? 0 : 1;
+ });
+ // the setting drums: upper half +1, lower half −1
+ for (let i = 0; i < CURTA.sliders; i++) {
+ const [cx, cy] = ui.setSpots[i];
+ const d = document.createElement('div');
+ d.className = 'hit';
+ Object.assign(d.style, {left: `${cx - 12}px`, top: `${cy - 18}px`, width: '24px', height: '36px'});
+ d.title = '+1 / −1';
+ d.addEventListener('click', (e) => {
+ takeOver();
+ const r = d.getBoundingClientRect();
+ doSet(i, e.clientY < r.top + r.height / 2 ? 1 : -1);
+ pulse(cx, cy);
+ });
+ ui.root.appendChild(d);
+ ui.setHit = ui.setHit || [];
+ ui.setHit.push(d);
+ }
+ addEventListener('keydown', (e) => {
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
+ const key = e.key.toLowerCase();
+ takeOver();
+ if (key === ' ') doTurn();
+ else if (key === 'u') doPull();
+ else if (key === 'arrowleft') doCar(-1);
+ else if (key === 'arrowright') doCar(1);
+ else if (key === 'c') doClear();
+ else if (key === 'h') hidden = !hidden;
+ else if (key === 'p') paused = !paused;
+ else if (/^[1-7]$/.test(key)) setSub(SUBS[+key - 1]);
+ else return;
+ e.preventDefault();
+ });
+ let drag = null;
+ canvas.addEventListener('pointerdown', (e) => {
+ drag = {x: e.clientX, y: e.clientY};
+ canvas.setPointerCapture(e.pointerId);
+ canvas.classList.add('drag');
+ });
+ canvas.addEventListener('pointermove', (e) => {
+ if (!drag) return;
+ off.az -= ((e.clientX - drag.x) / scale) * 0.3;
+ off.el = clamp(off.el + ((e.clientY - drag.y) / scale) * 0.2, -20, 60);
+ drag = {x: e.clientX, y: e.clientY};
+ lastInput = T;
+ });
+ const endDrag = () => {
+ drag = null;
+ canvas.classList.remove('drag');
+ };
+ canvas.addEventListener('pointerup', endDrag);
+ canvas.addEventListener('pointercancel', endDrag);
+ canvas.addEventListener(
+ 'wheel',
+ (e) => {
+ e.preventDefault();
+ takeOver();
+ off.k = clamp(off.k * Math.exp(e.deltaY * 0.0012), 0.35, 2.5);
+ },
+ {passive: false},
+ );
+
+ // the next turn's chart, between turns: what one more turn would do
+ let preview = {key: '', ev: null};
+ const nextEv = (S) => {
+ const key = `${S.reg.join('')}|${S.ctr.join('')}|${S.set.join('')}|${S.p}|${S.up}`;
+ if (key !== preview.key) preview = {key, ev: turnEvents(S.reg, S.ctr, S.set, S.p, S.up > 0.5 ? -1 : 1)};
+ return preview.ev;
+ };
+
+ // ------------------------------------------------------------ the frame
+ startTour();
+ let last = performance.now();
+ const perf = {n: 0, sum: 0, slow: 0, age: 0};
+ const frame = (now) => {
+ const dt = Math.min(0.1, Math.max(0, (now - last) / 1000));
+ last = now;
+ T += dt;
+ if (tier < TIERS.length - 1) {
+ perf.age += dt;
+ if (perf.age > 0.6) {
+ perf.sum += dt;
+ perf.n++;
+ if (perf.sum >= 0.5 && perf.n >= 6) {
+ perf.slow = perf.sum / perf.n > 0.022 ? perf.slow + 1 : 0;
+ perf.sum = 0;
+ perf.n = 0;
+ if (perf.slow >= (perf.age < 6 ? 1 : 2)) {
+ tier++;
+ applyTier();
+ Object.assign(perf, {age: 0, slow: 0});
+ }
+ }
+ }
+ }
+
+ let S;
+ let tgt;
+ let fx;
+ let cursor;
+ let caption;
+ let since;
+ let hist = null;
+ if (AP.on) {
+ const c = T - AP.t0;
+ const loop = Math.floor(c / DURATION);
+ const cyc = c - loop * DURATION;
+ if (loop > AP.loop) AP.loop = loop;
+ S = state(film, cyc);
+ tgt = camAt(cyc, S);
+ const f = fxAt(cyc);
+ fx = f;
+ cut = f.cut;
+ sub = f.sub;
+ vbtn = f.view;
+ const b = beatAt(cyc);
+ view = (b.sub ? SUB[b.sub] : VIEW_CFG[b.view]).view;
+ since = f.since;
+ cursor = tourCursor(cyc);
+ if (AP.loop > 0 && cyc < 0.8) {
+ const e = tourCursor(DURATION - 0.01);
+ const k = ss(0, 0.8, cyc);
+ cursor = {...cursor, x: e.x + (cursor.x - e.x) * k, y: e.y + (cursor.y - e.y) * k};
+ }
+ caption = captionAt(cyc, facts);
+ if (cyc > 30.8) hist = filmHist;
+ Object.assign(fxs, {glass: f.glass, explode: f.explode, dof: f.dof, iso: f.iso, top: 1 - f.ghost.carriage});
+ tgt = {...tgt};
+ } else {
+ if (T - lastInput > IDLE) startTour();
+ if (!paused) mT += dt;
+ // the film's last step finishes first
+ if (handoff && mT < handoff.t1) S = state(film, handoff.cyc0 + mT);
+ else {
+ handoff = null;
+ S = state(plan, mT);
+ }
+ since = T - viewAt;
+ const k = 1 - Math.exp(-dt / 0.35);
+ const want = {glass: cut === 'glass' ? 1 : 0, explode: explodeOn, dof: cfg.dof ?? 0, iso: cfg.iso ?? 0, top: cfg.top ?? 0};
+ for (const key in fxs) fxs[key] += (want[key] - fxs[key]) * k;
+ fx = {cut, glass: fxs.glass, explode: fxs.explode, dof: fxs.dof, iso: fxs.iso, isoStations: cfg.isoStations, ghost: {carriage: 1 - 0.9 * fxs.top, ring: 1 - 0.9 * fxs.top, crank: 1 - 0.85 * fxs.top}};
+ tgt = VIEW_FN[view](since, S);
+ tgt.az += off.az;
+ tgt.el = clamp(tgt.el + off.el, -10, 85);
+ tgt.d *= off.k;
+ const rk = Math.max(0, 1 - (T - ring.t) / 0.55);
+ cursor = {x: -99, y: -99, press: false, ring: rk, ringX: ring.x, ringY: ring.y};
+ const ph = S.phase;
+ const tt = S.turn;
+ caption = tt
+ ? tr(tt.sign < 0 ? 'l:sub' : 'l:add', nf(fromDigits(tt.ev.set) * 10 ** (tt.ev.p - 1)))
+ : ph === 'carriage'
+ ? tr('l:carriage', plan.steps[S.step]?.to ?? S.p)
+ : ph === 'clear'
+ ? tr('l:clear')
+ : ph === 'slide'
+ ? tr('l:slide')
+ : ph === 'crank'
+ ? tr('l:crank')
+ : tr('l:idle');
+ }
+
+ // the camera eases toward its target, logarithmically in distance
+ const k = 1 - Math.exp(-dt / (AP.on ? 0.1 : 0.35));
+ cam.az += wrap(tgt.az - cam.az) * k;
+ for (const key of ['tx', 'ty', 'tz', 'el', 'sx', 'sy']) cam[key] += (tgt[key] - cam[key]) * k;
+ cam.d = Math.exp(Math.log(cam.d) + (Math.log(tgt.d) - Math.log(cam.d)) * k);
+ st.setCut(fx.cut);
+ st.frame(S, fx, {...cam, az: cam.az * D2R, el: cam.el * D2R});
+
+ ui.root.style.display = hidden ? 'none' : '';
+ ui.update({
+ S,
+ sub,
+ view: vbtn,
+ cut: fx.cut,
+ explode: AP.on ? fx.explode : explodeOn,
+ paused,
+ lastHist: hist,
+ nextEv: S.turn ? null : nextEv(S),
+ callouts: calloutsFor(view, st, S, ss(1.3, 2.0, since)),
+ cursor,
+ caption,
+ handNote,
+ });
+ if (tourChip.classList.contains('on') !== AP.on || !tourChip.firstChild) {
+ tourChip.classList.toggle('on', AP.on);
+ tourChip.innerHTML = AP.on ? ` ${tr('tourOn')}` : tr('tourOff');
+ }
+ if (!window.SIM_SHOWN) {
+ window.SIM_SHOWN = true;
+ dispatchEvent(new Event('simframe'));
+ }
+ requestAnimationFrame(frame);
+ };
+ warmComposer(st.renderer, st.composer, st.scene, st.camera).then(() => requestAnimationFrame(frame));
+ window.LIVE = {
+ st,
+ cam,
+ startTour,
+ takeOver,
+ act: ACT,
+ get plan() {
+ return plan;
+ },
+ get tier() {
+ return tier;
+ },
+ get T() {
+ return T;
+ },
+ };
+ void SCRIPT;
+}
diff --git a/machines/pepper/curta-shared/machine.js b/machines/pepper/curta-shared/machine.js
new file mode 100644
index 0000000..e8bdae1
--- /dev/null
+++ b/machines/pepper/curta-shared/machine.js
@@ -0,0 +1,408 @@
+// Pepper, the Curta Type I: the one source of its figures and of its arithmetic. No three.js, so check.mjs, the
+// timeline and the score import it in node. The geometry (curta.js), the console and the sound all read from here.
+//
+// One input: the cumulative crank angle θ, plus the discrete state between turns (sliders, crank up or down, carriage
+// position, clearing ring). A plan of steps is built once; state(plan, t) returns every pose, every digit on every
+// wheel, every carry lever, as a pure function of t. The letters in comments are the sources in ../REFERENCES.md.
+
+// ------------------------------------------------------------ the figures
+export const CURTA = {
+ // registers [W][H][V]
+ sliders: 8,
+ counter: 6,
+ result: 11,
+ typeII: {sliders: 11, counter: 8, result: 15},
+ // body [F]: 5.3 cm across, 8.5 cm high (10.5 cm with the crank [H]), 230 g, ~600 parts
+ diameter: 53,
+ height: 85,
+ heightCrank: 105,
+ mass: 230,
+ parts: 600,
+ // production [F][W]: ~80,000 Type I (and ~60,000 Type II), 1948 to 1970 (Wikipedia gives 1972 for the last one)
+ made: 80000,
+ madeII: 60000,
+ years: [1948, 1970],
+ // drum [V]: 19 rows of teeth, 9 for addition, 9 for the nines' complement, 1 extra row of ten for the first digit
+ rowsAdd: 9,
+ rowsComp: 9,
+ rowsTen: 1,
+};
+export const POSITIONS = CURTA.counter; // the carriage turns through 6 positions [V]
+
+// ------------------------------------------------------------ the timing over one turn [E]
+// The ring of stations around the drum: 8 slider shafts (stations 1-8), 3 carry-only shafts (9-11) and 5 parked
+// places where the result wheels below the active column wait. 16 stations, 22.5° apart. A drum tooth or the single
+// carry tooth meets station s at θ = its angle on the drum + (s − 1)·22.5°, so the drum serves the stations one after
+// the other, and the carry tooth reaches column s + 1 after column s has finished: the carries run in order, from
+// the units up, during the second half of the turn [F].
+export const G = {
+ stations: 16,
+ active: 11,
+ pitch: 22.5, // ° between stations
+ tooth: 10, // ° between two teeth of a row
+ toothDur: 7, // ° a tooth drives its counting gear
+ entry0: 15, // ° where the first tooth meets station 1
+ carry0: 105, // ° where the carry tooth meets station 1 (= entry0 + 9 teeth)
+ carryDur: 8,
+ counterAt: 338, // ° the counting tooth steps the revolution counter
+ counterDur: 14,
+ armReset: [2, 14], // ° the reset cam lifts the carry levers at the start of the next turn [F]
+ ratchet: 12, // anti-reverse ratchet teeth per turn [F] (count estimated)
+ rowPitch: 2.4, // mm between rows of the same set; the complement rows sit half a pitch away [V]
+ shift: 1.2, // mm the drum rises when the crank is pulled up [V][F]
+ lift: 3.2, // mm the carriage lifts to turn or to clear [F]
+};
+export const stationOf = (col, p) => col - p + 1;
+export const thetaTooth = (s, k) => G.entry0 + (s - 1) * G.pitch + k * G.tooth;
+export const thetaCarry = (s) => G.carry0 + (s - 1) * G.pitch;
+
+// ------------------------------------------------------------ numbers as digit arrays (index 0 = units)
+export const toDigits = (v, n) => Array.from({length: n}, (_, i) => Math.floor(v / 10 ** i) % 10);
+export const fromDigits = (d) => d.reduce((a, x, i) => a + x * 10 ** i, 0);
+const mod = (a, m) => ((a % m) + m) % m;
+
+/** teeth that reach station s in one turn: the slider's digit, or its nines' complement; the first column ten's */
+export function teethAt(s, set, sign) {
+ const d = s <= CURTA.sliders ? set[s - 1] : 0;
+ if (sign > 0) return d;
+ return 9 - d + (s === 1 ? 1 : 0);
+}
+
+/**
+ * One turn of the crank from the registers reg (11 digits) and ctr (6), the setting set (8), carriage position p,
+ * sign +1 (crank down, add) or −1 (crank up, subtract). Returns every column's teeth and carries with their angles.
+ */
+export function turnEvents(reg, ctr, set, p, sign) {
+ const cols = [];
+ const carries = [];
+ let overflow = null;
+ let armIn = false;
+ let armAt = null;
+ const after = reg.slice();
+ const arms = new Array(G.active + 2).fill(0); // carry levers down after the turn, by station
+ for (let j = p; j <= CURTA.result; j++) {
+ const s = stationOf(j, p);
+ const v = reg[j - 1];
+ const n = teethAt(s, set, sign);
+ const teeth = Array.from({length: n}, (_, k) => thetaTooth(s, k));
+ const cin = armIn ? 1 : 0;
+ if (cin) carries.push({col: j, station: s, at: thetaCarry(s), from: armAt});
+ // where the wheel passes 9 → 0 (the pin on its 9 pushes the next lever down) [F]
+ let wrap = null;
+ if (v + n >= 10) wrap = teeth[9 - v] + G.toothDur;
+ else if (cin && v + n === 9) wrap = thetaCarry(s) + G.carryDur;
+ after[j - 1] = (v + n + cin) % 10;
+ cols.push({col: j, station: s, v0: v, n, teeth, cin, wrap, v1: after[j - 1]});
+ armIn = wrap !== null;
+ armAt = wrap;
+ if (wrap !== null) arms[s + 1] = 1;
+ }
+ if (armIn) overflow = {at: armAt, station: stationOf(CURTA.result, p) + 1};
+ // the revolution counter: ±1 on the digit of the active position
+ const cv = fromDigits(ctr);
+ const cv1 = mod(cv + sign * 10 ** (p - 1), 10 ** CURTA.counter);
+ const ctrAfter = toDigits(cv1, CURTA.counter);
+ const ctrSteps = ctrAfter.map((d, i) => (d !== ctr[i] ? 1 : 0));
+ return {cols, carries, overflow, after, ctrAfter, ctrSteps, sign, p, set: set.slice(), arms, reg0: reg.slice(), ctr0: ctr.slice()};
+}
+
+/** the addend one turn puts into the 11 columns (the setting shifted, or its ten's complement), for the panel */
+export function addendOf(set, p, sign) {
+ const out = new Array(CURTA.result).fill(null);
+ for (let j = p; j <= CURTA.result; j++) out[j - 1] = teethAt(stationOf(j, p), set, sign);
+ return out;
+}
+
+// ------------------------------------------------------------ easing
+const clamp01 = (x) => Math.min(1, Math.max(0, x));
+export const smoother = (x) => {
+ const u = clamp01(x);
+ return u * u * u * (u * (u * 6 - 15) + 10);
+};
+export const ss = (a, b, t) => smoother((t - a) / (b - a));
+// state to state, cubic-bezier(0.65, 0, 0.35, 1)-like
+const inout = (x) => {
+ const u = clamp01(x);
+ return u < 0.5 ? 4 * u * u * u : 1 - (-2 * u + 2) ** 3 / 2;
+};
+
+// Fritsch-Carlson monotone cubic: θ never runs backwards and slows smoothly into each key
+export function monotone(keys) {
+ const n = keys.length;
+ const x = keys.map((k) => k[0]);
+ const y = keys.map((k) => k[1]);
+ if (n === 1) return () => y[0];
+ const d = [];
+ const m = new Array(n).fill(0);
+ for (let i = 0; i < n - 1; i++) d.push((y[i + 1] - y[i]) / (x[i + 1] - x[i]));
+ m[0] = keys[0][2] ?? d[0];
+ m[n - 1] = keys[n - 1][2] ?? d[n - 2];
+ for (let i = 1; i < n - 1; i++) m[i] = keys[i][2] ?? (d[i - 1] * d[i] <= 0 ? 0 : (3 * (d[i - 1] + d[i])) / ((2 * d[i] + d[i - 1]) / d[i - 1] + (d[i] + 2 * d[i - 1]) / d[i]));
+ return (t) => {
+ if (t <= x[0]) return y[0];
+ if (t >= x[n - 1]) return y[n - 1];
+ let i = 0;
+ while (t > x[i + 1]) i++;
+ const h = x[i + 1] - x[i];
+ const s = (t - x[i]) / h;
+ const s2 = s * s;
+ const s3 = s2 * s;
+ return (2 * s3 - 3 * s2 + 1) * y[i] + (s3 - 2 * s2 + s) * h * m[i] + (-2 * s3 + 3 * s2) * y[i + 1] + (s3 - s2) * h * m[i + 1];
+ };
+}
+
+// ------------------------------------------------------------ the plan
+// Steps, each {kind, t0, t1, ...}:
+// slide {to: [8 digits]} the sliders move to a new setting, one after the other
+// turns {n, keys?} n turns; keys [[t, θ in this step, slope?]] shape the crank's motion
+// carriage {to} lift, turn to position `to`, drop
+// crank {up} pull the crank up (subtract) or push it down
+// clear {to?} lift the carriage, the ring clears both registers in one turn, (turn to `to`), drop
+export const ZERO = () => ({set: new Array(CURTA.sliders).fill(0), up: 0, p: 1, reg: new Array(CURTA.result).fill(0), ctr: new Array(CURTA.counter).fill(0), arms: new Array(G.active + 2).fill(0), shaft: new Array(G.active + 2).fill(0), turnsDone: 0, hist: []});
+
+export function createPlan(init = ZERO()) {
+ const plan = {steps: [], end: init, turns: []};
+ plan.add = (step) => addStep(plan, step);
+ return plan;
+}
+
+function addStep(plan, step) {
+ const s0 = plan.end;
+ const st = {...step, s0};
+ const e = {...s0, set: s0.set.slice(), reg: s0.reg.slice(), ctr: s0.ctr.slice(), arms: s0.arms.slice(), shaft: s0.shaft.slice(), hist: s0.hist.slice()};
+ if (step.kind === 'slide') {
+ st.from = s0.set.slice();
+ e.set = step.to.slice();
+ // each slider that moves gets its own slice of the step
+ const moving = st.from.map((d, i) => (d !== step.to[i] ? i : -1)).filter((i) => i >= 0);
+ st.order = moving;
+ } else if (step.kind === 'turns') {
+ st.ev = [];
+ let reg = s0.reg;
+ let ctr = s0.ctr;
+ const sign = s0.up ? -1 : 1;
+ for (let k = 0; k < step.n; k++) {
+ const ev = turnEvents(reg, ctr, s0.set, s0.p, sign);
+ ev.index = s0.turnsDone + k;
+ // each shaft's cumulative teeth, so the gears' pose is a function of the plan alone
+ ev.shaft0 = (k ? st.ev[k - 1].shaft1 : s0.shaft).slice();
+ ev.shaft1 = ev.shaft0.slice();
+ for (const c of ev.cols) ev.shaft1[c.station] += c.n + c.cin;
+ st.ev.push(ev);
+ plan.turns.push(ev);
+ reg = ev.after;
+ ctr = ev.ctrAfter;
+ }
+ e.reg = reg;
+ e.ctr = ctr;
+ e.arms = st.ev[st.ev.length - 1].arms;
+ e.shaft = st.ev[st.ev.length - 1].shaft1;
+ e.turnsDone = s0.turnsDone + step.n;
+ // the operation as one would write it by hand: consecutive turns at one position and sign group together
+ const last = e.hist[e.hist.length - 1];
+ const val = fromDigits(s0.set);
+ if (last && last.p === s0.p && last.sign === sign && last.set === val && !last.closed) e.hist[e.hist.length - 1] = {...last, n: last.n + step.n};
+ else e.hist.push({p: s0.p, sign, set: val, n: step.n});
+ const f = step.keys ? monotone(step.keys.map(([t, th, m]) => [step.t0 + t, th, m])) : null;
+ st.theta = f || ((t) => 360 * step.n * inout((t - step.t0) / (step.t1 - step.t0)));
+ } else if (step.kind === 'carriage') {
+ e.p = step.to;
+ e.hist = e.hist.map((h) => ({...h, closed: true}));
+ } else if (step.kind === 'crank') {
+ e.up = step.up ? 1 : 0;
+ } else if (step.kind === 'clear') {
+ e.reg = new Array(CURTA.result).fill(0);
+ e.ctr = new Array(CURTA.counter).fill(0);
+ if (step.to) e.p = step.to;
+ e.hist = [];
+ e.cleared = s0.hist;
+ }
+ st.s1 = e;
+ plan.steps.push(st);
+ plan.end = e;
+ return st;
+}
+
+/** the step running at t (or the last one before it), and the index */
+function stepAt(plan, t) {
+ let lo = 0;
+ let hi = plan.steps.length - 1;
+ if (hi < 0 || t < plan.steps[0].t0) return -1;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ if (plan.steps[mid].t0 <= t) lo = mid;
+ else hi = mid - 1;
+ }
+ return lo;
+}
+
+/** wheel position (digits, float, not wrapped) of one column at θ inside a turn */
+function colPos(c, th) {
+ let x = c.v0;
+ for (const a of c.teeth) x += ss(a, a + G.toothDur, th);
+ if (c.cin) x += ss(thetaCarry(c.station), thetaCarry(c.station) + G.carryDur, th);
+ return x;
+}
+
+/**
+ * The machine at time t: {set, setPos[8], up (0..1), p, carPos (float position), lift (0..1), ring (°), reg[11],
+ * regPos[11], ctr[6], ctrPos[6], arms[station] (0..1), turn: {ev, θ, n, slow} | null, theta (cumulative °), phase}
+ */
+export function state(plan, t) {
+ const i = stepAt(plan, t);
+ const base = i < 0 ? plan.steps[0]?.s0 ?? plan.end : t > plan.steps[i].t1 ? plan.steps[i].s1 : plan.steps[i].s0;
+ const S = {
+ set: base.set,
+ setPos: base.set.slice(),
+ up: base.up,
+ p: base.p,
+ carPos: base.p,
+ lift: 0,
+ ring: 0,
+ reg: base.reg,
+ regPos: base.reg.slice(),
+ ctr: base.ctr,
+ ctrPos: base.ctr.slice(),
+ arms: base.arms.slice(),
+ shaft: base.shaft.slice(),
+ turn: null,
+ theta: 360 * base.turnsDone,
+ turnsDone: base.turnsDone,
+ hist: base.hist,
+ phase: 'idle',
+ step: i,
+ k: 0,
+ };
+ if (i < 0 || t > plan.steps[i].t1) return S;
+ const st = plan.steps[i];
+ const k = (t - st.t0) / Math.max(1e-6, st.t1 - st.t0);
+ S.k = k;
+ S.phase = st.kind;
+ if (st.kind === 'slide') {
+ const n = st.order.length;
+ st.order.forEach((si, q) => {
+ const a = n > 1 ? (q / n) * 0.55 : 0;
+ const u = inout((k - a) / (1 - (n > 1 ? 0.55 : 0)));
+ S.setPos[si] = st.from[si] + (st.to[si] - st.from[si]) * u;
+ });
+ S.set = S.setPos.map((x) => Math.round(x));
+ } else if (st.kind === 'crank') {
+ const u = inout(k);
+ S.up = st.up ? u : 1 - u;
+ } else if (st.kind === 'carriage') {
+ S.lift = ss(0, 0.28, k) * (1 - ss(0.78, 1, k));
+ S.carPos = st.s0.p + (st.to - st.s0.p) * inout((k - 0.3) / 0.46);
+ } else if (st.kind === 'clear') {
+ S.lift = ss(0, 0.14, k) * (1 - ss(0.88, 1, k));
+ const r = inout((k - 0.16) / 0.56);
+ S.ring = 360 * r;
+ if (st.to) S.carPos = st.s0.p + (st.to - st.s0.p) * inout((k - 0.72) / 0.15);
+ // the ring's racks sweep each wheel back to 0 as they pass it, forward [F]
+ const sweep = (vals, n, a0) =>
+ vals.map((v, j) => {
+ if (!v) return 0;
+ const a = a0 + (j / n) * 300;
+ return v + (10 - v) * ss(a, a + 50, S.ring);
+ });
+ S.regPos = sweep(st.s0.reg, CURTA.result, 0);
+ S.ctrPos = sweep(st.s0.ctr, CURTA.counter, 20);
+ S.reg = S.regPos.map((x) => Math.round(x) % 10);
+ S.ctr = S.ctrPos.map((x) => Math.round(x) % 10);
+ } else if (st.kind === 'turns') {
+ const th = Math.min(360 * st.n, Math.max(0, st.theta(t)));
+ const n = Math.min(st.n - 1, Math.floor(th / 360));
+ const thIn = th - 360 * n;
+ const ev = st.ev[n];
+ S.theta = 360 * st.s0.turnsDone + th;
+ S.turn = {ev, th: thIn, n, of: st.n, sign: ev.sign};
+ // registers in motion
+ S.shaft = ev.shaft0.slice();
+ for (const c of ev.cols) {
+ S.regPos[c.col - 1] = colPos(c, thIn);
+ S.shaft[c.station] += S.regPos[c.col - 1] - c.v0;
+ }
+ for (let c = 0; c < CURTA.counter; c++) S.ctrPos[c] = ev.ctr0[c] + ev.sign * ev.ctrSteps[c] * ss(G.counterAt, G.counterAt + G.counterDur, thIn);
+ S.reg = S.regPos.map((x) => Math.floor(x + 1e-6) % 10);
+ S.ctr = S.ctrPos.map((x) => mod(Math.round(x), 10));
+ // carry levers: the previous turn's lift at the start; this turn's drop when the wheel below passes 9 → 0
+ const prev = n > 0 ? st.ev[n - 1].arms : st.s0.arms;
+ const up = ss(G.armReset[0], G.armReset[1], thIn);
+ S.arms = prev.map((a) => a * (1 - up));
+ for (const c of ev.cols) if (c.wrap !== null) S.arms[c.station + 1] = Math.max(S.arms[c.station + 1], ss(c.wrap - 4, c.wrap, thIn));
+ // the turn's active station: the one a tooth or the carry tooth is driving now
+ S.active = activeAt(ev, thIn);
+ }
+ return S;
+}
+
+/** the station being driven at θ (tooth or carry), the carry in flight, for the accent */
+export function activeAt(ev, th) {
+ let tooth = null;
+ let carry = null;
+ for (const c of ev.cols) {
+ for (const a of c.teeth) if (th >= a - 1 && th < a + G.toothDur + 1) tooth = {station: c.station, col: c.col};
+ if (c.cin) {
+ const a = thetaCarry(c.station);
+ if (th >= a - 6 && th < a + G.carryDur + 4) carry = {station: c.station, col: c.col};
+ }
+ }
+ return {tooth, carry};
+}
+
+/** every timed event of a plan, for the score: ratchet clicks, teeth, carries, steps */
+export function soundEvents(plan) {
+ const out = {ratchet: [], home: [], carry: [], overflow: [], counter: [], slide: [], carriageLift: [], carriageNotch: [], carriageDrop: [], crank: [], ring: []};
+ // invert θ(t) inside a step by bisection
+ const tAt = (st, th) => {
+ let lo = st.t0;
+ let hi = st.t1;
+ for (let q = 0; q < 40; q++) {
+ const mid = (lo + hi) / 2;
+ if (st.theta(mid) < th) lo = mid;
+ else hi = mid;
+ }
+ return (lo + hi) / 2;
+ };
+ for (const st of plan.steps) {
+ if (st.kind === 'turns') {
+ for (let n = 0; n < st.n; n++) {
+ const ev = st.ev[n];
+ for (let r = 1; r < G.ratchet; r++) out.ratchet.push(tAt(st, n * 360 + (r * 360) / G.ratchet));
+ out.home.push(tAt(st, (n + 1) * 360 - 0.5));
+ for (const c of ev.carries) out.carry.push({t: tAt(st, n * 360 + c.at), col: c.col});
+ if (ev.overflow) out.overflow.push(tAt(st, n * 360 + ev.overflow.at + G.pitch));
+ out.counter.push(tAt(st, n * 360 + G.counterAt + G.counterDur * 0.6));
+ }
+ } else if (st.kind === 'slide') {
+ const n = st.order.length;
+ st.order.forEach((si, q) => {
+ const a = n > 1 ? (q / n) * 0.55 : 0;
+ const span = 1 - (n > 1 ? 0.55 : 0);
+ const steps = Math.abs(st.to[si] - st.from[si]);
+ for (let d = 1; d <= steps; d++) {
+ // the slider's detent at each digit, on its eased path
+ const target = d / steps;
+ let lo = 0;
+ let hi = 1;
+ for (let q2 = 0; q2 < 30; q2++) {
+ const mid = (lo + hi) / 2;
+ if (inout(mid) < target - 0.5 / steps) lo = mid;
+ else hi = mid;
+ }
+ out.slide.push(st.t0 + (a + lo * span) * (st.t1 - st.t0));
+ }
+ });
+ } else if (st.kind === 'carriage' || st.kind === 'clear') {
+ const L = (x) => st.t0 + x * (st.t1 - st.t0);
+ out.carriageLift.push(L(st.kind === 'clear' ? 0.07 : 0.14));
+ out.carriageDrop.push(L(st.kind === 'clear' ? 0.97 : 0.96));
+ const from = st.s0.p;
+ const to = st.to ?? from;
+ const a0 = st.kind === 'clear' ? 0.72 : 0.3;
+ const span = st.kind === 'clear' ? 0.15 : 0.46;
+ for (let q = 1; q <= Math.abs(to - from); q++) out.carriageNotch.push(L(a0 + span * (q / Math.max(1, Math.abs(to - from))) * 0.95));
+ if (st.kind === 'clear') out.ring.push([L(0.16), L(0.72)]);
+ } else if (st.kind === 'crank') out.crank.push(st.t0 + 0.75 * (st.t1 - st.t0));
+ }
+ return out;
+}
diff --git a/machines/pepper/curta-shared/make-timeline.mjs b/machines/pepper/curta-shared/make-timeline.mjs
new file mode 100644
index 0000000..30c0dfe
--- /dev/null
+++ b/machines/pepper/curta-shared/make-timeline.mjs
@@ -0,0 +1,30 @@
+// Writes ../curta-film/timeline.json (the beats, for review sheets and the score) and ../curta-film/sounds.json
+// (every ratchet tooth, carry, counter step, slider detent and carriage notch of the film, from the same plan and
+// model the film draws). node curta-shared/make-timeline.mjs (from machines/pepper)
+import fs from 'node:fs';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {soundEvents} from './machine.js';
+import {DURATION, SCRIPT, filmPlan} from './schedule.js';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const r = (x) => +x.toFixed(3);
+const plan = filmPlan();
+const ev = soundEvents(plan);
+const turns = plan.steps.filter((s) => s.kind === 'turns');
+const sub = turns[turns.length - 1];
+const beats = {
+ views: SCRIPT.filter((e) => e.t > 0).map((e) => e.t),
+ firstTooth: r(turns[0].t0 + 1.2),
+ turnsEnd: r(turns[1].t1),
+ carriage: r(plan.steps.find((s) => s.kind === 'carriage').t0 + 1.2),
+ subStart: r(sub.t0 + 0.8),
+ cascade: r(ev.carry.filter((c) => c.t > sub.t0)[2].t),
+ overflow: r(ev.overflow[0]),
+ clear: r(plan.steps.find((s) => s.kind === 'clear').t0 + 1.4),
+ end: 36.5,
+};
+fs.writeFileSync(path.join(HERE, '../curta-film/timeline.json'), JSON.stringify({duration: DURATION, beats}));
+const round = (a) => a.map((x) => (typeof x === 'number' ? r(x) : Array.isArray(x) ? x.map(r) : {...x, t: r(x.t)}));
+fs.writeFileSync(path.join(HERE, '../curta-film/sounds.json'), JSON.stringify(Object.fromEntries(Object.entries(ev).map(([k, v]) => [k, round(v)]))));
+console.log(beats, Object.fromEntries(Object.entries(ev).map(([k, v]) => [k, v.length])));
diff --git a/machines/pepper/curta-shared/schedule.js b/machines/pepper/curta-shared/schedule.js
new file mode 100644
index 0000000..ca7bfeb
--- /dev/null
+++ b/machines/pepper/curta-shared/schedule.js
@@ -0,0 +1,57 @@
+// The film's plan and its operator script. No three.js: the timeline, the score and check.mjs import it in node.
+//
+// 1 234 × 56 the Curta way: 6 turns in position 1, lift the carriage one notch, 5 turns in position 2 (69 104,
+// the counter reads 56). Then the crank pulled up and one turn back in position 2: 69 104 − 12 340 = 56 764 by adding
+// the ten's complement, the turn whose carries run from the fourth column off the top. The ring clears it all.
+import {createPlan, fromDigits} from './machine.js';
+
+export const DURATION = 38;
+export const SETTING = [4, 3, 2, 1, 0, 0, 0, 0]; // 1 234, slider 1 = units
+
+// the plan: every state change, in film seconds
+export function filmPlan() {
+ const plan = createPlan();
+ plan.add({kind: 'slide', t0: 2.4, t1: 4.5, to: SETTING});
+ // the first turn in slow motion (the drum's teeth meet the gears one by one), then five at a hand's pace
+ plan.add({kind: 'turns', t0: 5.0, t1: 11.0, n: 1, keys: [[0, 0, 0], [0.9, 12], [4.6, 300], [6.0, 360, 0]]});
+ plan.add({kind: 'turns', t0: 11.5, t1: 14.6, n: 5});
+ plan.add({kind: 'carriage', t0: 15.0, t1: 17.4, to: 2});
+ plan.add({kind: 'turns', t0: 17.8, t1: 20.9, n: 5});
+ plan.add({kind: 'crank', t0: 21.5, t1: 22.5, up: true});
+ // the subtraction: slow through the entry, slower through the carries (θ 150° to 310°), then home
+ plan.add({kind: 'turns', t0: 23.0, t1: 29.4, n: 1, keys: [[0, 0, 0], [1.6, 140], [5.4, 312], [6.4, 360, 0]]});
+ plan.add({kind: 'crank', t0: 29.7, t1: 30.4, up: false});
+ plan.add({kind: 'clear', t0: 30.9, t1: 33.7, to: 1});
+ plan.add({kind: 'slide', t0: 34.0, t1: 35.4, to: [0, 0, 0, 0, 0, 0, 0, 0]});
+ return plan;
+}
+
+// operator moves: a subsystem or a view, clicked on the console; the cut and the explosion follow from it
+export const SCRIPT = [
+ {t: 0, sub: 'crank'},
+ {t: 2.0, sub: 'sliders'},
+ {t: 4.6, sub: 'drum'},
+ {t: 11.2, sub: 'carriage'},
+ {t: 21.1, view: 'axial'},
+ {t: 23.9, sub: 'carry'},
+ {t: 30.4, sub: 'reset'},
+ {t: 34.2, sub: 'crank'},
+];
+/** the figures the captions quote, read from the plan's own turns */
+export function filmFacts(plan) {
+ const T = plan.turns;
+ const add = T.filter((e) => e.sign > 0);
+ const p1 = add.filter((e) => e.p === 1);
+ const last = T[T.length - 1];
+ const r1 = fromDigits(add[add.length - 1].after);
+ return {
+ set: fromDigits(SETTING),
+ m1: fromDigits(p1[p1.length - 1].ctrAfter),
+ m2: fromDigits(add[add.length - 1].ctrAfter),
+ r1,
+ sub: fromDigits(SETTING) * 10 ** (last.p - 1),
+ r2: fromDigits(last.after),
+ c2: fromDigits(last.ctrAfter),
+ };
+}
+export const beatAt = (t) => SCRIPT.filter((e) => e.t <= t).pop();
diff --git a/machines/pepper/curta-shared/score.py b/machines/pepper/curta-shared/score.py
new file mode 100644
index 0000000..f3321b9
--- /dev/null
+++ b/machines/pepper/curta-shared/score.py
@@ -0,0 +1,102 @@
+"""Score for Pepper (a Curta Type I after the public sources): mechanical, dry, precise. A ratchet tick at every tooth
+of the crank's anti-reverse ratchet and a firmer detent when the crank comes home, a small click at every carry (the
+cascade of the subtraction is a burst of them, one per column), the counter's step, a detent per digit as a slider
+moves, the carriage's lift, notch and drop, a soft friction under the clearing ring. No continuous music: a single
+plucked music-box note on the strong beats, all in D major. Every time comes from sounds.json and timeline.json,
+written by make-timeline.mjs from the same plan the film draws.
+
+ node curta-shared/make-timeline.mjs && python3 "$SKILL_DIR/run_sfx.py" curta-shared/score.py
+"""
+
+import json
+import math
+import os
+import random
+
+from sfx.score_kit import hz, thud
+from sfx.synth import SR, bandnoise, click
+
+TIMELINE = "../curta-film/timeline.json"
+OUT = os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), "pepper-score.wav")
+TARGET_LUFS = -14.0
+SEED = 1948
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+
+def tick(freq, dur=0.03, body=0.0, body_f=420, decay=0.004):
+ """a short metal tick: a damped partial and a burst of noise, with an optional low body"""
+ n = int(dur * SR)
+ out = []
+ for k in range(n):
+ t = k / SR
+ s = math.sin(2 * math.pi * freq * t) * math.exp(-t / decay)
+ s += math.sin(2 * math.pi * freq * 1.47 * t) * math.exp(-t / (decay * 0.6)) * 0.4
+ s += random.uniform(-1, 1) * math.exp(-t / 0.0009) * 0.7
+ if body:
+ s += body * math.sin(2 * math.pi * body_f * t) * math.exp(-t / 0.012)
+ out.append(s)
+ return out
+
+
+def pluck(freq, dur=1.6):
+ """a music-box tine: a bright attack, inharmonic partials that die fast, a clean fundamental"""
+ n = int(dur * SR)
+ out = []
+ for k in range(n):
+ t = k / SR
+ a = min(1, t / 0.002)
+ s = math.sin(2 * math.pi * freq * t) * math.exp(-t / 0.55)
+ s += 0.35 * math.sin(2 * math.pi * freq * 3.01 * t) * math.exp(-t / 0.12)
+ s += 0.18 * math.sin(2 * math.pi * freq * 5.4 * t) * math.exp(-t / 0.05)
+ out.append(a * s)
+ return out
+
+
+def friction(dur, level=1.0):
+ """the clearing ring sliding: low band noise with a slow swell"""
+ n = int(dur * SR)
+ x = bandnoise(n, lambda k: 900 + 500 * math.sin(k / SR * 7.0), q=0.7)
+ return [x[k] * level * math.sin(math.pi * min(1.0, k / n)) ** 0.6 for k in range(n)]
+
+
+def score(mix, tl):
+ dur = tl["duration"]
+ b = tl["beats"]
+ with open(os.path.join(HERE, "../curta-film/sounds.json"), encoding="utf8") as f:
+ ev = json.load(f)
+ rnd = random.Random(SEED)
+ # the crank's ratchet and its home detent
+ for t in ev["ratchet"]:
+ mix.put(t, tick(rnd.uniform(5200, 5800), 0.018, decay=0.0025), 0.05, rnd.uniform(-0.15, 0.15))
+ for t in ev["home"]:
+ mix.put(t, tick(2600, 0.05, body=0.6, body_f=310, decay=0.004), 0.12)
+ # carries: one dry click each, a little higher up the columns
+ for c in ev["carry"]:
+ mix.put(c["t"], tick(3400 + 180 * c["col"], 0.03, body=0.25, body_f=900, decay=0.003), 0.13, -0.3 + 0.06 * c["col"])
+ for t in ev["overflow"]:
+ mix.put(t, tick(6400, 0.03, decay=0.002), 0.06, 0.4)
+ for t in ev["counter"]:
+ mix.put(t, tick(3000, 0.03, body=0.3, body_f=600), 0.06, 0.2)
+ # sliders: a soft detent per digit
+ for t in ev["slide"]:
+ mix.put(t, tick(rnd.uniform(4200, 4600), 0.02, decay=0.002), 0.07, -0.25)
+ # carriage: lift, notch, drop
+ for t in ev["carriageLift"]:
+ mix.put(t, tick(1900, 0.06, body=0.5, body_f=240, decay=0.006), 0.08)
+ for t in ev["carriageNotch"]:
+ mix.put(t, tick(2300, 0.05, body=0.7, body_f=280, decay=0.005), 0.12)
+ for t in ev["carriageDrop"]:
+ mix.put(t, thud(160, 0.05, 0.25), 0.14)
+ mix.put(t, tick(2800, 0.04, decay=0.004), 0.07)
+ for t in ev["crank"]:
+ mix.put(t, tick(2100, 0.05, body=0.6, body_f=260, decay=0.005), 0.1)
+ for t0, t1 in ev["ring"]:
+ mix.put(t0, friction(t1 - t0, 1.0), 0.05)
+ # one plucked note on the strong beats, D major
+ notes = {"firstTooth": "A5", "turnsEnd": "D6", "carriage": "F#5", "subStart": "B5", "cascade": "E6", "clear": "A5"}
+ for k, n in notes.items():
+ mix.put(b[k], pluck(hz(n)), 0.06)
+ # the result: two notes, the fifth then the octave
+ mix.put(b["end"] - 1.6, pluck(hz("A5")), 0.06)
+ mix.put(b["end"] - 1.2, pluck(hz("D6")), 0.065)
+ _ = dur
diff --git a/machines/pepper/curta-shared/stage.js b/machines/pepper/curta-shared/stage.js
new file mode 100644
index 0000000..7e48631
--- /dev/null
+++ b/machines/pepper/curta-shared/stage.js
@@ -0,0 +1,175 @@
+// Pepper's stage: a watchmaker's catalogue shot. Pale cool stone behind (#e9e7e2, a soft gradient), one large light
+// box overhead that the satin steel and the knurls catch, deep contact shadows under the machine, and a short depth of
+// field on the macro views only (a bokeh pass the working views never pay for). frame(S, fx, cam) draws one frame.
+import * as THREE from 'three';
+import {BokehPass} from 'three/addons/postprocessing/BokehPass.js';
+import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
+import {GTAOPass} from 'three/addons/postprocessing/GTAOPass.js';
+import {OutputPass} from 'three/addons/postprocessing/OutputPass.js';
+import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
+import {buildCurta} from './curta.js';
+
+const V3 = THREE.Vector3;
+export const BG = {top: '#efeeea', mid: '#e9e7e2', low: '#dedbd4'};
+
+export function createStage({canvas}) {
+ const renderer = new THREE.WebGLRenderer({canvas, antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance'});
+ renderer.toneMapping = THREE.NeutralToneMapping;
+ renderer.toneMappingExposure = 1.0;
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.VSMShadowMap;
+ renderer.localClippingEnabled = true;
+ const scene = new THREE.Scene();
+ {
+ // stone: a vertical gradient with a faint warm-cool falloff toward the corners
+ const c = document.createElement('canvas');
+ c.width = 512;
+ c.height = 512;
+ const g = c.getContext('2d');
+ const gr = g.createLinearGradient(0, 0, 0, 512);
+ gr.addColorStop(0, BG.top);
+ gr.addColorStop(0.58, BG.mid);
+ gr.addColorStop(1, BG.low);
+ g.fillStyle = gr;
+ g.fillRect(0, 0, 512, 512);
+ const rv = g.createRadialGradient(256, 210, 60, 256, 256, 420);
+ rv.addColorStop(0, 'rgba(255,255,255,0.22)');
+ rv.addColorStop(1, 'rgba(150,152,158,0.18)');
+ g.fillStyle = rv;
+ g.fillRect(0, 0, 512, 512);
+ const tex = new THREE.CanvasTexture(c);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ scene.background = tex;
+ }
+ const camera = new THREE.PerspectiveCamera(24, 16 / 9, 0.005, 5);
+ const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(4, 4, {type: THREE.HalfFloatType, samples: 8}));
+ composer.addPass(new RenderPass(scene, camera));
+ const gtao = new GTAOPass(scene, camera, 4, 4);
+ gtao.output = GTAOPass.OUTPUT.Default;
+ gtao.blendIntensity = 0.85;
+ gtao.updateGtaoMaterial({radius: 0.006, distanceExponent: 1.3, thickness: 0.004, scale: 1.0, samples: 16, distanceFallOff: 1.0, screenSpaceRadius: false});
+ gtao.updatePdMaterial({lumaPhi: 10, depthPhi: 2, normalPhi: 3, radius: 5, rings: 2, samples: 16});
+ composer.addPass(gtao);
+ const bokeh = new BokehPass(scene, camera, {focus: 0.1, aperture: 0.002, maxblur: 0.008});
+ bokeh.enabled = false;
+ composer.addPass(bokeh);
+ composer.addPass(new OutputPass());
+
+ // the light box: one big panel overhead, a dim stone room, a narrow strip low on the right for an edge on the knurls
+ {
+ const env = new THREE.Scene();
+ env.background = new THREE.Color('#8e8c88');
+ const box = (w, h, pos, k, tint = '#ffffff') => {
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({color: new THREE.Color(tint).multiplyScalar(k), side: THREE.DoubleSide}));
+ m.position.copy(pos);
+ m.lookAt(0, 0, 0);
+ env.add(m);
+ };
+ box(6, 6, new V3(0, 5, 0.6), 4.2);
+ box(1.2, 5, new V3(4.5, 1.2, -1.5), 1.4, '#f4f1ea');
+ box(1.0, 5, new V3(-4.5, 1.0, 1.5), 0.8, '#eef0f4');
+ box(8, 1.2, new V3(0, 0.6, 5), 0.9, '#efede8');
+ const g = new THREE.Mesh(new THREE.CircleGeometry(20, 32), new THREE.MeshBasicMaterial({color: '#c9c6bf', side: THREE.DoubleSide}));
+ g.rotation.x = -Math.PI / 2;
+ g.position.y = -0.6;
+ env.add(g);
+ const pm = new THREE.PMREMGenerator(renderer);
+ scene.environment = pm.fromScene(env, 0.02).texture;
+ scene.environmentIntensity = 1.0;
+ pm.dispose();
+ }
+ const sun = new THREE.DirectionalLight('#fffdf8', 2.6);
+ sun.position.set(0.06, 0.5, 0.12);
+ sun.target.position.set(0, 0.04, 0);
+ sun.castShadow = true;
+ Object.assign(sun.shadow.camera, {left: -0.12, right: 0.12, top: 0.12, bottom: -0.12, near: 0.2, far: 0.8});
+ sun.shadow.mapSize.set(2048, 2048);
+ sun.shadow.radius = 6;
+ sun.shadow.blurSamples = 16;
+ sun.shadow.bias = -0.00005;
+ sun.shadow.normalBias = 0.0002;
+ scene.add(sun, sun.target);
+ scene.add(new THREE.HemisphereLight('#ffffff', '#b8b4ac', 0.55));
+ // a small fill for the inside of the open machine
+ const fill = new THREE.PointLight('#fff8ec', 0.06, 0.4, 1.5);
+ fill.position.set(0.05, 0.06, 0.09);
+ scene.add(fill);
+
+ const shade = new THREE.Mesh(
+ new THREE.PlaneGeometry(1.2, 1.2),
+ new THREE.ShadowMaterial({color: '#3c3830', opacity: 0.42, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -8}),
+ );
+ shade.rotation.x = -Math.PI / 2;
+ shade.receiveShadow = true;
+ scene.add(shade);
+
+ const mac = buildCurta();
+ scene.add(mac.root);
+
+ const size = {w: 1600, h: 900, pr: 1};
+ const resize = (w, h, pr) => {
+ size.w = w;
+ size.h = h;
+ size.pr = pr;
+ renderer.setPixelRatio(pr);
+ renderer.setSize(w, h, false);
+ composer.setPixelRatio(pr);
+ composer.setSize(w, h);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ };
+ let aoWanted = true;
+ let dofWanted = true;
+ // cam: target (tx, ty, tz) in metres, azimuth from +z toward +x, elevation, distance, sx/sy screen shift (px)
+ const frame = (S, fx, cam) => {
+ const {tx, ty, tz, az, el, d} = cam;
+ camera.position.set(tx + d * Math.cos(el) * Math.sin(az), ty + d * Math.sin(el), tz + d * Math.cos(el) * Math.cos(az));
+ camera.lookAt(tx, ty, tz);
+ camera.near = Math.max(0.002, d * 0.08);
+ camera.far = Math.max(1.5, d * 12);
+ camera.updateProjectionMatrix();
+ camera.setViewOffset(size.w, size.h, -(cam.sx ?? 0), -(cam.sy ?? 0), size.w, size.h);
+ camera.updateMatrixWorld();
+ shade.position.y = (fx.explode ?? 0) * -0.056;
+ mac.update(S, {...fx, clipAz: fx.clipAz ?? (az * 180) / Math.PI});
+ gtao.enabled = aoWanted && (fx.cut !== 'section');
+ const dof = dofWanted ? fx.dof ?? 0 : 0;
+ bokeh.enabled = dof > 0.02;
+ if (bokeh.enabled) {
+ bokeh.uniforms.focus.value = d;
+ bokeh.uniforms.aperture.value = 0.0022 * dof;
+ bokeh.uniforms.maxblur.value = 0.009 * dof;
+ }
+ composer.render();
+ };
+ const pv = new V3();
+ const project = (p) => {
+ pv.copy(p).project(camera);
+ if (pv.z > 1) return null;
+ return [(pv.x * 0.5 + 0.5) * size.w, (-pv.y * 0.5 + 0.5) * size.h];
+ };
+ const setQuality = ({samples, ao, shadow, dof}) => {
+ if (samples !== undefined) {
+ for (const t of [composer.renderTarget1, composer.renderTarget2]) {
+ t.samples = samples;
+ t.dispose();
+ }
+ }
+ if (ao !== undefined) aoWanted = ao;
+ if (dof !== undefined) dofWanted = dof;
+ if (shadow !== undefined) {
+ sun.shadow.mapSize.set(shadow, shadow);
+ sun.shadow.map?.dispose();
+ sun.shadow.map = null;
+ }
+ };
+ let cutNow = '';
+ const setCut = (mode) => {
+ if (mode === cutNow) return;
+ cutNow = mode;
+ mac.setCut(mode);
+ };
+ setCut('solid');
+ return {renderer, scene, camera, mac, composer, gtao, bokeh, sun, resize, frame, project, size, setQuality, setCut};
+}
diff --git a/machines/pepper/og.png b/machines/pepper/og.png
new file mode 100644
index 0000000..ebe3984
Binary files /dev/null and b/machines/pepper/og.png differ
diff --git a/package.json b/package.json
index 12c5ea9..5725482 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"description": "Exploded: real machines, built from code with their real numbers, that you can take apart in the browser.",
"license": "MIT",
"scripts": {
- "check": "node machines/spindl/check.mjs && node machines/quantum/check.mjs && node scripts/test-root-lang.mjs",
+ "check": "node machines/pepper/check.mjs && node machines/spindl/check.mjs && node machines/quantum/check.mjs && node scripts/test-root-lang.mjs",
"build": "node scripts/build.mjs",
"test": "npm run check && npm run build"
},
diff --git a/scripts/machines.mjs b/scripts/machines.mjs
index a73ce2c..b106583 100644
--- a/scripts/machines.mjs
+++ b/scripts/machines.mjs
@@ -1,6 +1,35 @@
// The collection: one entry per machine, newest first (the gallery's order). Each machine lives in machines//,
// its live page's entry point is `entry`, its share card `og` (1200 x 630, also the gallery's thumbnail).
export const MACHINES = [
+ {
+ slug: 'pepper',
+ name: 'Pepper',
+ entry: 'machines/pepper/curta-live/main.js',
+ og: 'machines/pepper/og.png',
+ meta: {
+ en: {
+ title: 'Pepper · the Curta calculator, taken apart',
+ line: 'The Curta Type I, the mechanical calculator you hold in one hand: one stepped drum, carries that run column after column.',
+ description:
+ 'The Curta Type I mechanical calculator to take apart in the browser: one stepped drum for all eight sliders, subtraction by the ten’s complement, carries that run column after column, the carriage that shifts to multiply. A model after the public sources; not affiliated with any maker or rights holder.',
+ locale: 'en_US',
+ },
+ fr: {
+ title: 'Pepper · la calculatrice Curta, démontée',
+ line: 'La Curta Type I, la calculatrice mécanique qui tient dans la main : un seul tambour à gradins, des reports qui passent colonne après colonne.',
+ description:
+ 'La calculatrice mécanique Curta Type I à démonter dans le navigateur : un seul tambour à gradins pour les huit curseurs, la soustraction par complément à dix, les reports colonne après colonne, le chariot qui se décale pour multiplier. Modèle d’après les sources publiques ; sans lien avec un fabricant ni un ayant droit.',
+ locale: 'fr_FR',
+ },
+ es: {
+ title: 'Pepper · la calculadora Curta, desmontada',
+ line: 'La Curta Tipo I, la calculadora mecánica que cabe en la mano: un solo tambor escalonado, acarreos que pasan columna tras columna.',
+ description:
+ 'La calculadora mecánica Curta Tipo I para desmontar en el navegador: un solo tambor escalonado para los ocho cursores, la resta por complemento a diez, los acarreos columna tras columna, el carro que se desplaza para multiplicar. Modelo según las fuentes públicas; sin relación con ningún fabricante ni titular de derechos.',
+ locale: 'es_ES',
+ },
+ },
+ },
{
slug: 'quantum',
name: 'Quantum',
@@ -67,7 +96,7 @@ export const GALLERY = {
lead: 'Real machines, built from code with their real numbers, that you can take apart in the browser.',
sub: 'Every gauge reads the same model that moves the parts. Left alone, each one plays a guided tour; any click hands control over.',
open: 'Open',
- description: 'Real machines, built from code with their real numbers, that you can take apart in the browser: a hard drive, a photonic quantum computer.',
+ description: 'Real machines, built from code with their real numbers, that you can take apart in the browser: a hard drive, a photonic quantum computer, a Curta mechanical calculator.',
locale: 'en_US',
},
fr: {
@@ -75,7 +104,7 @@ export const GALLERY = {
lead: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur.',
sub: 'Chaque jauge lit le modèle qui fait bouger les pièces. Laissée seule, chacune joue une visite guidée ; un clic suffit pour prendre la main.',
open: 'Ouvrir',
- description: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur : un disque dur, un ordinateur quantique photonique.',
+ description: 'De vraies machines, construites en code avec leurs vrais chiffres, à démonter dans le navigateur : un disque dur, un ordinateur quantique photonique, une calculatrice mécanique Curta.',
locale: 'fr_FR',
},
es: {
@@ -83,7 +112,7 @@ export const GALLERY = {
lead: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador.',
sub: 'Cada indicador lee el mismo modelo que mueve las piezas. Si la dejas sola, cada una hace una visita guiada; cualquier clic te da el control.',
open: 'Abrir',
- description: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador: un disco duro, un ordenador cuántico fotónico.',
+ description: 'Máquinas reales, construidas en código con sus cifras reales, para desmontar en el navegador: un disco duro, un ordenador cuántico fotónico, una calculadora mecánica Curta.',
locale: 'es_ES',
},
};