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', }, };