diff --git a/README.md b/README.md index b69c711..15a65e7 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,15 @@ the same model that moves the parts. Left alone, each page plays a guided tour; **Live:** (English, French, Spanish). -![Spindl, a hard drive, and Quantum, a photonic quantum computer](static/og.png) +![Rope, the Apollo Guidance Computer; Radial, an R-2800 radial engine; Pepper, a Curta calculator; Quantum, a photonic quantum computer; Spindl, a hard drive](static/og.png) ## The machines | | The machine | What drives it | Checked by | |---|---|---|---| +| [**Rope**](machines/rope) | The Apollo Guidance Computer Block II and its DSKY, as flown in Apollo 11's lunar module: a word read from the core rope (a wire through a core is a 1, around it a 0), an erasable core read and rewritten, one kind of NOR gate, the Executive running out of core sets as the rendezvous radar steals cycles, the 1202. A model after public sources (MIT Instrumentation Lab, NASA); not affiliated with NASA or MIT. | The mission time, counted in memory cycles since PDI. The Executive's log (jobs, priorities, radar steals, the crew's keys) is planned once. | `machines/rope/check.mjs` | +| [**Radial**](machines/radial) | The Pratt & Whitney R-2800 Double Wasp, an 18-cylinder twin-row radial: the starter, the first firings missed then caught, the firing order going round the star, one row in section with its master rod and eight articulated rods (whose pistons do not quite follow the master's law), the rows pulled apart, a cam ring lifting a valve, the climb to 2,700 rpm. A C-series CB16 after public sources; not affiliated with Pratt & Whitney. | The crank angle, integrated once from a planned run of the engine speed. Power, torque and manifold pressure follow from it. | `machines/radial/check.mjs` | +| [**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` | @@ -66,8 +69,8 @@ Content-Security-Policy that allows the pages' inline scripts by their hashes, c ## Credits Built with Claude Code (Claude Opus 5.5) and the teardown skill, whose kit, frame capture and sound engine come from -[whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion) (MIT). three.js (MIT). Inter, JetBrains Mono and -Instrument Serif (SIL Open Font License). +[whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion) (MIT). three.js (MIT). Inter, JetBrains Mono, +Instrument Serif, Barlow Condensed and Saira Stencil One (SIL Open Font License). ## License diff --git a/machines/pepper/FILM.md b/machines/pepper/FILM.md new file mode 100644 index 0000000..703e766 --- /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 without the sheet, framed on the calculator, 1200 × 630, without metadata: + +```sh +node "$SKILL_DIR/sims/capture.mjs" curta-film --config /tmp/clean.json --size 1600x900 --dpr 1.2 --still 0 --out teardown-out/frame0.png # {"lang":"en","clean":true} +ffmpeg -y -i teardown-out/frame0.png -vf "crop=1600:840:145:80,scale=1200:630" -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..cec3775 Binary files /dev/null and b/machines/pepper/og.png differ diff --git a/machines/radial/FILM.md b/machines/radial/FILM.md new file mode 100644 index 0000000..ed01b87 --- /dev/null +++ b/machines/radial/FILM.md @@ -0,0 +1,31 @@ +# The film + +The tour (40 s) is recorded frame by frame from `machines/radial/r2800-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/radial +node r2800-shared/make-timeline.mjs # r2800-film/timeline.json (beats) and events.json (every spark, the run at 100 Hz) +node "$SKILL_DIR/sims/build.mjs" ./r2800-film +echo '{"lang":"en"}' > /tmp/en.json # or fr / es +node "$SKILL_DIR/sims/capture.mjs" r2800-film --timeline r2800-film/timeline.json --config /tmp/en.json --size 1600x900 --dpr 1.2 --stills beats --cols 3 --out teardown-out/beats.png +node "$SKILL_DIR/sims/capture.mjs" r2800-film --timeline r2800-film/timeline.json --config /tmp/en.json --size 1600x900 --dpr 1.2 --out teardown-out/silent.mp4 +python3 "$SKILL_DIR/run_sfx.py" r2800-shared/score.py # teardown-out/radial-score.wav, mastered to −14 LUFS +ffmpeg -y -i teardown-out/silent.mp4 -i teardown-out/radial-score.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart teardown-out/radial-en.mp4 +``` + +The share card is a frame of the film without the console, the star from the front at idle: + +```sh +echo '{"lang":"en","clean":true}' > /tmp/clean.json +node "$SKILL_DIR/sims/capture.mjs" r2800-film --config /tmp/clean.json --size 1600x900 --dpr 1.2 --still 11 --out teardown-out/og-src.png +ffmpeg -y -i teardown-out/og-src.png -vf "scale=1200:-1,crop=1200:630:0:(ih-630)/2" -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"`. + +The sound is built on the same timeline: the starter's whine, a cough for each spark that lights while the engine +catches (the missed ones stay silent), a thud per firing while they come slower than ~12 a second on screen, then a +growl whose pulse is the firing rate on screen, and the propeller's wash. diff --git a/machines/radial/REFERENCES.md b/machines/radial/REFERENCES.md new file mode 100644 index 0000000..fd58fbb --- /dev/null +++ b/machines/radial/REFERENCES.md @@ -0,0 +1,87 @@ +# Radial: references + +Radial is a model of a Pratt & Whitney R-2800 Double Wasp, the 18-cylinder twin-row radial of the F6F Hellcat, the +F4U Corsair, the P-47 and, after the war, the Douglas DC-6. It is built from public sources, as a **C-series CB16** +(the DC-6B's engine, single-stage two-speed supercharger, 0.45 : 1 propeller gear), and it is not affiliated with +Pratt & Whitney. Where a figure is not public, the model uses a plausible value, listed below under **[E]**. + +The letters match the comments in [`r2800-shared/engine.js`](r2800-shared/engine.js), the one file every pose, +gauge and sound reads its numbers from. + +## Sources + +- **[F]** FAA, Type Certificate Data Sheet **6A3**, Douglas DC-6A / DC-6B, revision 28 + ([copy on enginehistory.org](https://www.enginehistory.org/Operations/WaterInjServErrors/6A3Rev28.pdf)). Engine + limits of the P&W Double Wasp CB-16: take-off (two minutes, dry) 2,050 hp at 2,700 rpm and 55.0 inHg; take-off wet + (water-alcohol) 2,400 hp at 2,800 rpm and 59.5 inHg; maximum continuous 1,800 hp at 2,600 rpm and 48.5 inHg; + impeller gear ratios 7.29 : 1 (low) and 8.58 : 1 (high); ".45:1 propeller reduction gearing". The film climbs to + the dry take-off point. +- **[N]** Smithsonian National Air and Space Museum, + [Pratt & Whitney Double Wasp R-2800 CB16](https://airandspace.si.edu/collection-objects/pratt-whitney-double-wasp-r-2800-cb16-2-row-radial-18-engine/nasm_A19580058000) + (A19580058000): weight 1,068 kg (2,350 lb), diameter 52.8 in, length 81.4 in, gear-driven single-stage two-speed + supercharger, 2,400 hp at 2,800 rpm. +- **[W]** K. McCutcheon, [Pratt & Whitney Double Wasp (R-2800)](https://enginehistory.org/Piston/P&W/R-2800/r-2800.shtml), + Aircraft Engine Historical Society: bore 5.75 in, stroke 6.000 in, 2,804.5 in³; two master rod assemblies, each a + one-piece master rod and eight "I"-section articulated rods on knuckle pins; a crankshaft of three steel forgings + joined at the crankpins; forged aluminium heads screwed and shrunk on forged steel barrels, finned aluminium muffs; + one inlet and one exhaust valve per cylinder; valve gear and ignition "substantially the same as the Twin Wasp". + The C series' compression ratio, 6.75 : 1, is from the + [R-2800 article](https://en.wikipedia.org/wiki/Pratt_%26_Whitney_R-2800_Double_Wasp) (Wikipedia, citing the + manufacturer's data). +- **[H]** FAA, *Aviation Maintenance Technician Handbook, Powerplant* (FAA-H-8083-32), the chapters on radial + engines: the firing order numbers of an 18-cylinder twin-row radial are **+11 and −7**, which gives + 1-12-5-16-9-2-13-6-17-10-3-14-7-18-11-4-15-8; cylinders numbered clockwise from the top of the rear row, seen from + the rear, odd in the rear row; a nine-cylinder row's cam ring has 4 lobes per track and turns against the crank + (a lobe every 90°, at ⅛ crank speed). Copies of those pages: + [firing order](https://www.aircraftsystemstech.com/p/firingorder-firing-order-of-engine-is.html), + [valve operating mechanism](https://aircraftsystemstech.com/p/valveoperating-mechanism-for.html). + +The geometry was built against photographs of real engines (Wikimedia Commons): +[a sectioned R-2800](https://commons.wikimedia.org/wiki/File:Cross_Section_of_a_Pratt_and_Whitney_R2800_Double_Wasp.JPG) +(the profile in section: nose case and reduction gear, both rows, master rods, crank, impeller), +[an R-2800 on its stand](https://commons.wikimedia.org/wiki/File:HARP_Pratt_%26_Whitney_R-2800_Double_Wasp.JPG) +(nose, magnetos, harness ring, pushrod tubes, baffles), +[the R-2800-21 at the National Museum of the USAF](https://commons.wikimedia.org/wiki/File:Pratt_%26_Whitney_R-2800-21_USAF.jpg) +(the star from the front, black intake pipes, aluminium rocker boxes) and +[an R-2800 in profile](https://commons.wikimedia.org/wiki/File:20-09-083-R_2800.jpg) (the long supercharger and +accessory section). They belong to their authors and are not in this repository. + +## Estimates **[E]** + +Not public, or not found in a primary source; chosen to be plausible: + +| Figure | Value | Why | +|---|---|---| +| Master rod, crankpin to piston pin | 292 mm (11.5 in) | about 3.8 crank throws; fits the 1,341 mm diameter with the heads and rocker boxes | +| Knuckle-pin circle | 60.3 mm (2.375 in) | just outside the 92 mm crankpin and its bearing | +| Articulated rod | 231.7 mm (master − knuckle circle) | the usual first choice; the knuckle pins sit at exactly 40° steps (see below) | +| Master rods in cylinders | 13 (rear row), 8 (front row) | not found in the sources above | +| Row spacing along the axis | 210 mm | the reference photos | +| Valve timing | inlet 20° BTDC – 60° ABDC, exhaust 50° BBDC – 20° ATDC | typical of the period; lift 15.9 mm | +| Spark | 25° before top centre | typical | +| Idle, starter cranking | 700 rpm, 140 rpm | typical | +| Propeller | three blades, 4.0 m | a Hamilton Standard of the DC-6B's size | +| Power at other speeds | the propeller absorbs P ∝ N³ through the run, 2,050 hp at 2,700 rpm | a fixed-pitch law; inertia neglected | +| Manifold pressure | bmep = k · MAP − friction (1.2 + 0.6 (N/2,700)² bar) − pumping | k set so that 2,050 hp needs 55.0 inHg exactly; the same law gives 1,702 hp at the METO point (1,800 hp [F], −5.4 %) | +| Reduction gear layout | sun 72 teeth on the crank, ring 88 fixed, carrier on the shaft | gives 0.45 exactly; P&W's tooth counts are not public | + +## The model, by hand + +- π/4 × 5.75² × 6 × 18 = **2,804.5 in³** (45.96 L). +- 2,050 hp = 1,528.7 kW at 2,700 rpm (282.7 rad/s): **5,407 N·m** at the crank, a brake mean effective pressure of + 14.8 bar. +- 18 firings in two turns: 9 × 2,700 / 60 = **405 firings a second**, one every 40° of crank. +- Mean piston speed 2 × 0.1524 m × 45 rev/s = **13.7 m/s**. Propeller 0.45 × 2,700 = 1,215 rpm; impeller + 7.29 × 2,700 = 19,683 rpm. +- The cam ring: each tappet needs one lift in 720° of crank; 4 lobes on a ring turning at ⅛ crank speed against it + bring one every 90° × 8 = 720°. The model reads every valve's lift off the turning ring and check.mjs verifies it + equals the valve timing for all 36 valves. +- **The articulated rods.** The master rod's piston follows the plain slider-crank law: stroke 152.40 mm, top centre + when its crankpin points at it. An articulated rod hangs on a knuckle pin that orbits on the master rod's big end, + an ellipse rather than a circle, so its piston does not: with the knuckle pins at exact 40° steps the model gives + strokes from 152.41 to **153.08 mm**, top centres up to **3.7°** early or late and up to 2.45 mm lower, so a + compression ratio down to 6.27 against the master's 6.75. Real engines trim the knuckle-pin positions to even this + out; how P&W did it for the R-2800 is not in the public sources, so the page shows the uncorrected geometry and + says so. + +`node machines/radial/check.mjs` recomputes each of these from the code. diff --git a/machines/radial/check.mjs b/machines/radial/check.mjs new file mode 100644 index 0000000..117694e --- /dev/null +++ b/machines/radial/check.mjs @@ -0,0 +1,99 @@ +// Recomputes, from the same functions the page draws with, every figure Radial shows, and fails if one leaves the +// range its source gives. node machines/radial/check.mjs (CI runs it on every push). +import {CAM, CYL, DISPL_IN3, DISPL_L, E, FIRE, GEAR, hpOf, LAW, lift, liftFromTiming, ORDER, pinAngle, RATING, ROWS, stateAt} from './r2800-shared/engine.js'; +import {DURATION, rpmAt, sparkList, thetaAt} from './r2800-shared/schedule.js'; + +let bad = 0; +const near = (name, v, lo, hi, unit = '') => { + const ok = v >= lo && v <= hi; + if (!ok) bad++; + console.log(`${ok ? 'ok ' : 'BAD'} ${name.padEnd(52)} ${Number(v.toPrecision(6))} ${unit} [${lo} … ${hi}]`); +}; +const mod = (a, m) => ((a % m) + m) % m; + +// displacement: π/4 × 5.75² × 6 × 18 = 2,804.5 in³ [W] +near('displacement', DISPL_IN3, 2804, 2805, 'in³'); +near('displacement', DISPL_L, 45.9, 46.0, 'L'); + +// the firing order: +11, −7 [H], each cylinder once in 720°, 40° apart, the rows alternating +const BRIEF = '1-12-5-16-9-2-13-6-17-10-3-14-7-18-11-4-15-8'; +near('firing order is ' + BRIEF, ORDER.join('-') === BRIEF ? 1 : 0, 1, 1); +near('cylinders in the order, each once', new Set(ORDER).size, 18, 18); +let alt = 0; +for (let i = 0; i < 18; i++) if (CYL[ORDER[i] - 1].row !== CYL[ORDER[(i + 1) % 18] - 1].row) alt++; +near('consecutive firings in different rows', alt, 18, 18); +const gaps = ORDER.map((c, i) => mod(FIRE[ORDER[(i + 1) % 18]] - FIRE[c], 720)); +near('interval between firings (min)', Math.min(...gaps), 40, 40, '°'); +near('interval between firings (max)', Math.max(...gaps), 40, 40, '°'); +// at its firing TDC the row's crankpin points at the cylinder (the geometry agrees with the order) +let miss = 0; +for (const cy of CYL) miss = Math.max(miss, Math.abs(((mod(pinAngle(cy.row, FIRE[cy.c]) - cy.angle, 360) + 180) % 360) - 180)); +near('crankpin on the cylinder at its firing', miss, 0, 1e-9, '°'); +// every spark of the film, counted per cylinder over the running part: once per two turns +{ + const sp = sparkList(); + const t0 = 8; + const t1 = DURATION - 0.5; + const turns = (thetaAt(t1) - thetaAt(t0)) / 360; + const per = CYL.map((cy) => sp.filter((s) => s[1] === cy.c && s[0] >= t0 && s[0] < t1).length); + near('sparks per cylinder per 2 turns (film)', Math.min(...per) / (turns / 2), 0.98, 1.02); + near('', Math.max(...per) / (turns / 2), 0.98, 1.02); +} + +// the master rod and the articulated rods, exact: the master's piston follows the plain slider-crank law +const mF = LAW[E.master.front]; +near('master stroke (2 × crank throw)', mF.stroke, 152.399, 152.401, 'mm'); +near('master TDC at its pin angle', Math.abs(mF.tdcLag), 0, 0.05, '°'); +const arts = CYL.filter((c) => c.c !== E.master.front && c.c !== E.master.rear).map((c) => LAW[c.c]); +near('articulated strokes, shortest', Math.min(...arts.map((a) => a.stroke)), 152.4, 153.5, 'mm'); +near('articulated strokes, longest', Math.max(...arts.map((a) => a.stroke)), 152.4, 153.5, 'mm'); +near('articulated TDC, largest shift', Math.max(...arts.map((a) => Math.abs(a.tdcLag))), 0.1, 6, '°'); +near('articulated TDC, lowest below the master', -Math.min(...arts.map((a) => a.dTDC)), 0, 4, 'mm'); +near('compression ratio, master', mF.cr, 6.7499, 6.7501); +near('compression ratio, lowest articulated', Math.min(...arts.map((a) => a.cr)), 6.0, 6.75); +for (const row of ['rear', 'front']) { + const ws = ROWS[row].filter((c) => c.c !== E.master[row]).sort((a, b) => Math.abs(LAW[b.c].tdcLag) - Math.abs(LAW[a.c].tdcLag))[0]; + const x = LAW[ws.c]; + console.log(` ${row} row: master ${E.master[row]}, most different ${ws.c}: stroke ${x.stroke.toFixed(3)} mm, TDC ${x.tdcLag.toFixed(2)}°, ${(-x.dTDC).toFixed(2)} mm lower, CR ${x.cr.toFixed(3)}`); +} + +// the cam rings: 4 lobes at ⅛ crank speed against the crank [H]; the lift read off the ring equals the valve timing +near('cam ring speed', CAM.ratio, -0.125, -0.125); +near('lobes × 2 × speed = once per two turns', CAM.lobes * 2 * Math.abs(CAM.ratio), 1, 1); +let err = 0; +let opens = 0; +for (const cy of CYL) + for (const ev of ['in', 'ex']) { + let prev = lift(cy.c, ev, -0.5); + for (let th = 0; th < 720; th += 0.5) { + const a = lift(cy.c, ev, th); + err = Math.max(err, Math.abs(a - liftFromTiming(cy.c, ev, th))); + if (a > 0.5 && prev <= 0.5) opens++; + prev = a; + } + } +near('cam lift vs valve timing (max error)', err, 0, 1e-9); +near('valve openings in 720° (36 valves)', opens, 36, 36); + +// power: the take-off point is exact [F], the maximum-continuous point within 7 % +const to = stateAt(RATING.takeoff.rpm); +near('take-off power at 2,700 rpm', to.hp, 2049.9, 2050.1, 'hp'); +near('take-off manifold pressure', to.map, 54.99, 55.01, 'inHg'); +near('METO 1,800 hp at 2,600 rpm, 48.5″ (model)', hpOf(RATING.meto.map, RATING.meto.rpm), 1800 * 0.93, 1800 * 1.07, 'hp'); +near('take-off torque', to.torque, 5400, 5415, 'N·m'); +near('firing frequency at 2,700 rpm', to.fireHz, 405, 405, 'Hz'); +near('mean piston speed at 2,700 rpm', to.pistonMs, 13.71, 13.72, 'm/s'); +near('propeller at 2,700 rpm (0.45 : 1)', to.propRpm, 1215, 1215, 'rpm'); +near('impeller at 2,700 rpm (7.29 : 1)', to.impRpm, 19683, 19683, 'rpm'); +near('drawn reduction gear 72 / (72 + 88)', 72 / (72 + 88), GEAR.prop, GEAR.prop); +near('idle manifold pressure (model)', stateAt(RATING.idle).map, 8, 16, 'inHg'); + +// the film's run reaches take-off rpm and comes back to idle +near('film: peak rpm', Math.max(...Array.from({length: 400}, (_, i) => rpmAt(i / 10))), 2690, 2701, 'rpm'); +near('film: rpm at the end', rpmAt(DURATION), RATING.idle - 5, RATING.idle + 5, 'rpm'); + +if (bad) { + console.error(`\n${bad} figure(s) out of range`); + process.exit(1); +} +console.log('\nevery figure in range'); diff --git a/machines/radial/og.png b/machines/radial/og.png new file mode 100644 index 0000000..2221eb4 Binary files /dev/null and b/machines/radial/og.png differ diff --git a/machines/radial/r2800-film/events.json b/machines/radial/r2800-film/events.json new file mode 100644 index 0000000..65eb309 --- /dev/null +++ b/machines/radial/r2800-film/events.json @@ -0,0 +1 @@ +{"rate":100,"fields":["rpm","slow","map","fireHzScreen","theta"],"samples":[[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0,1,29.92,0,6],[0.001,1,29.92,0,6],[0.011,1,29.92,0,6],[0.036,1,29.92,0,6.002],[0.084,1,29.919,0,6.005],[0.162,1,29.919,0,6.012],[0.276,1,29.918,0,6.025],[0.431,1,29.916,0,6.046],[0.634,1,29.914,0,6.078],[0.888,1,29.912,0,6.123],[1.198,1,29.909,0,6.186],[1.569,1,29.906,0,6.268],[2.005,1,29.902,0,6.375],[2.507,1,29.897,0,6.51],[3.08,1,29.892,0,6.678],[3.726,1,29.886,0,6.881],[4.446,1,29.88,0,7.126],[5.244,1,29.873,0,7.416],[6.119,1,29.865,0,7.757],[7.074,1,29.856,0,8.152],[8.109,1,29.847,0,8.607],[9.224,1,29.837,0,9.127],[10.421,1,29.826,0,9.716],[11.698,1,29.815,0,10.379],[13.055,1,29.803,0,11.121],[14.492,1,29.79,0,11.947],[16.008,1,29.776,0,12.862],[17.601,1,29.762,0,13.87],[19.271,1,29.747,0,14.976],[21.015,1,29.731,0,16.184],[22.831,1,29.715,0,17.499],[24.718,1,29.698,0,18.925],[26.674,1,29.68,0,20.466],[28.695,1,29.662,0,22.127],[30.779,1,29.643,0,23.911],[32.924,1,29.624,0,25.822],[35.126,1,29.604,0,27.863],[37.382,1,29.584,0,30.038],[39.689,1,29.563,0,32.35],[42.043,1,29.542,0,34.801],[44.442,1,29.52,0,37.396],[46.88,1,29.498,0,40.135],[49.356,1,29.476,0,43.022],[51.864,1,29.453,0,46.059],[54.401,1,29.43,0,49.246],[56.962,1,29.407,0,52.587],[59.545,1,29.384,0,56.082],[62.144,1,29.361,0,59.733],[64.756,1,29.337,0,63.54],[67.376,1,29.314,0,67.504],[70,1,29.29,0,71.625],[72.624,1,29.266,0,75.904],[75.244,1,29.243,0,80.34],[77.856,1,29.219,0,84.933],[80.455,1,29.196,0,89.682],[83.038,1,29.173,0,94.587],[85.599,1,29.15,0,99.646],[88.136,1,29.127,0,104.859],[90.644,1,29.104,0,110.222],[93.12,1,29.082,0,115.735],[95.558,1,29.06,0,121.396],[97.957,1,29.038,0,127.201],[100.311,1,29.02,0,133.15],[102.618,1,29.02,0,139.238],[104.874,1,29.02,0,145.463],[107.076,1,29.02,0,151.822],[109.221,1,29.02,0,158.311],[111.305,1,29.02,0,164.927],[113.326,1,29.02,0,171.666],[115.282,1,29.02,0,178.525],[117.169,1,29.02,0,185.499],[118.985,1,29.02,0,192.584],[120.729,1,29.02,0,199.776],[122.399,1,29.02,0,207.07],[123.992,1,29.02,0,214.462],[125.508,1,29.02,0,221.947],[126.945,1,29.02,0,229.521],[128.302,1,29.02,0,237.179],[129.579,1,29.02,0,244.916],[130.776,1,29.02,0,252.727],[131.891,1,29.02,0,260.607],[132.926,1,29.02,0,268.552],[133.881,1,29.02,0,276.557],[134.756,1,29.02,0,284.616],[135.554,1,29.02,0,292.726],[136.274,1,29.02,0,300.881],[136.92,1,29.02,0,309.078],[137.493,1,29.02,0,317.31],[137.995,1,29.02,0,325.575],[138.431,1,29.02,0,333.868],[138.802,1,29.02,0,342.186],[139.112,1,29.02,0,350.523],[139.366,1,29.02,0,358.878],[139.569,1,29.02,0,367.246],[139.724,1,29.02,0,375.625],[139.838,1,29.02,0,384.012],[139.916,1,29.02,0,392.405],[139.964,1,29.02,0,400.802],[139.989,1,29.02,0,409.2],[139.999,1,29.02,0,417.6],[140,1,29.02,0,426],[140,1,29.02,0,434.4],[140,1,29.02,0,442.8],[140,1,29.02,0,451.2],[140,1,29.02,0,459.6],[140,1,29.02,0,468],[140,1,29.02,0,476.4],[140,1,29.02,0,484.8],[140,1,29.02,0,493.2],[140,1,29.02,0,501.6],[140,1,29.02,0,510],[140,1,29.02,0,518.4],[140,1,29.02,0,526.8],[140,1,29.02,0,535.2],[140,1,29.02,0,543.6],[140,1,29.02,0,552],[140,1,29.02,0,560.4],[140,1,29.02,0,568.8],[140,1,29.02,0,577.2],[140,1,29.02,0,585.6],[140,1,29.02,0,594],[140,1,29.02,0,602.4],[140,1,29.02,0,610.8],[140,1,29.02,0,619.2],[140,1,29.02,0,627.6],[140,1,29.02,0,636],[140,1,29.02,0,644.4],[140,1,29.02,0,652.8],[140,1,29.02,0,661.2],[140,1,29.02,0,669.6],[140,1,29.02,0,678],[140,1,29.02,0,686.4],[140,1,29.02,0,694.8],[140,1,29.02,0,703.2],[140,1,29.02,0,711.6],[140,1,29.02,0,720],[140,1,29.02,0,728.4],[140,1,29.02,0,736.8],[140,1,29.02,0,745.2],[140,1,29.02,0,753.6],[140,1,29.02,0,762],[140,1,29.02,0,770.4],[140,1,29.02,0,778.8],[140,1,29.02,0,787.2],[140,1,29.02,0,795.6],[140,1,29.02,0,804],[140,1,29.02,0,812.4],[140,1,29.02,0,820.8],[140,1,29.02,0,829.2],[140,1,29.02,0,837.6],[140,1,29.02,0,846],[140,1,29.02,0,854.4],[140,1,29.02,0,862.8],[140,1,29.02,0,871.2],[140,1,29.02,0,879.6],[140,1,29.02,0,888],[140,1,29.02,0,896.4],[140,1,29.02,0,904.8],[140,1,29.02,0,913.2],[140,1,29.02,0,921.6],[140,1,29.02,0,930],[140,1,29.02,0,938.4],[140,1,29.02,0,946.8],[140,1,29.02,0,955.2],[140,1,29.02,0,963.6],[140,1,29.02,0,972],[140,1,29.02,0,980.4],[140,1,29.02,0,988.8],[140,1,29.02,0,997.2],[140,1,29.02,0,1005.6],[140,1,29.02,0,1014],[140,1,29.02,0,1022.4],[140,1,29.02,0,1030.8],[140,1,29.02,0,1039.2],[140,1,29.02,0,1047.6],[140,1,29.02,0,1056],[140,1,29.02,0,1064.4],[140,1,29.02,0,1072.8],[140,1,29.02,0,1081.2],[140,1,29.02,0,1089.6],[140,1,29.02,0,1098],[140,1,29.02,0,1106.4],[140,1,29.02,0,1114.8],[140,1,29.02,0,1123.2],[140,1,29.02,0,1131.6],[140,1,29.02,0,1140],[140,1,29.02,0,1148.4],[140,1,29.02,0,1156.8],[140,1,29.02,0,1165.2],[140,1,29.02,0,1173.6],[140,1,29.02,0,1182],[140,1,29.02,0,1190.4],[140,1,29.02,0,1198.8],[140,1,29.02,0,1207.2],[140,1,29.02,0,1215.6],[140,1,29.02,0,1224],[140,1,29.02,0,1232.4],[140,1,29.02,0,1240.8],[140,1,29.02,0,1249.2],[140,1,29.02,0,1257.6],[140,1,29.02,0,1266],[140,1,29.02,0,1274.4],[140,1,29.02,0,1282.8],[140,1,29.02,0,1291.2],[140,1,29.02,0,1299.6],[140,1,29.02,0,1308],[140,1,29.02,0,1316.4],[140,1,29.02,0,1324.8],[140,1,29.02,0,1333.2],[140,1,29.02,0,1341.6],[140,1,29.02,0,1350],[140,1,29.02,0,1358.4],[140,1,29.02,0,1366.8],[140,1,29.02,0,1375.2],[140,1,29.02,0,1383.6],[140,1,29.02,0,1392],[140,1,29.02,0,1400.4],[140,1,29.02,0,1408.8],[140,1,29.02,0,1417.2],[140,1,29.02,0,1425.6],[140,1,29.02,0,1434],[140,1,29.02,0,1442.4],[140,1,29.02,0,1450.8],[140,1,29.02,0,1459.2],[140,1,29.02,0,1467.6],[140,1,29.02,0,1476],[140,1,29.02,0,1484.4],[140,1,29.02,0,1492.8],[140,1,29.02,0,1501.2],[140,1,29.02,0,1509.6],[140,1,29.02,0,1518],[140,1,29.02,0,1526.4],[140,1,29.018,0.002,1534.8],[140,1,29.014,0.005,1543.2],[140,1,29.007,0.013,1551.6],[140,1,28.995,0.024,1560],[140,1,28.977,0.041,1568.4],[140,1,28.952,0.065,1576.8],[140,1,28.921,0.095,1585.2],[140,1,28.881,0.133,1593.6],[140,1,28.832,0.18,1602],[140,1,28.774,0.235,1610.4],[140,1,28.705,0.301,1618.8],[140,1,28.626,0.376,1627.2],[140,1,28.537,0.462,1635.6],[140,1,28.435,0.559,1644],[140,1,28.322,0.667,1652.4],[140,1,28.197,0.787,1660.8],[140,1,28.06,0.918,1669.2],[140,1,27.91,1.061,1677.6],[140,1,27.747,1.216,1686],[140,1,27.572,1.384,1694.4],[140,1,27.384,1.563,1702.8],[140,1,27.184,1.755,1711.2],[140,1,26.971,1.958,1719.6],[140,1,26.745,2.174,1728],[140.009,1,26.508,2.401,1736.4],[140.071,1,26.257,2.642,1744.802],[140.235,1,25.995,2.895,1753.211],[140.548,1,25.722,3.165,1761.633],[141.054,1,25.437,3.45,1770.08],[141.793,1,25.141,3.755,1778.564],[142.803,1,24.835,4.081,1787.101],[144.118,1,24.518,4.431,1795.707],[145.771,1,24.192,4.807,1804.402],[147.79,1,23.856,5.213,1813.207],[150.202,1,23.512,5.653,1822.144],[153.03,1,23.16,6.129,1831.239],[156.297,1,22.8,6.646,1840.517],[160.02,1,22.433,7.208,1850.004],[164.217,1,22.06,7.819,1859.729],[168.9,1,21.682,8.484,1869.72],[174.083,1,21.298,9.206,1880.007],[179.774,1,20.911,9.99,1890.62],[185.98,1,20.52,10.84,1901.59],[192.707,1,20.127,11.761,1912.948],[199.958,1,19.732,12.757,1924.725],[207.735,1,19.335,13.832,1936.953],[216.036,1,18.939,14.989,1949.664],[224.859,1,18.544,16.232,1962.888],[234.199,1,18.15,17.565,1976.657],[244.052,1,17.758,18.99,1991.002],[254.408,1,17.37,20.51,2005.953],[265.26,1,16.987,22.127,2021.541],[276.595,1,16.608,23.843,2037.794],[288.403,1,16.236,25.659,2054.742],[300.669,1,15.871,27.575,2072.412],[313.379,1,15.513,29.593,2090.831],[326.516,1,15.165,31.711,2110.026],[340.064,1,14.826,33.929,2130.021],[354.004,1,14.498,36.244,2150.841],[368.317,1,14.181,38.656,2172.509],[382.981,1,13.876,41.161,2195.046],[397.977,1,13.585,43.757,2218.473],[413.281,1,13.307,46.438,2242.81],[428.87,1,13.043,49.202,2268.073],[444.722,1,12.794,52.042,2294.279],[460.812,1,12.561,54.954,2321.444],[477.114,1,12.344,57.932,2349.581],[493.604,1,12.143,60.968,2378.702],[510.255,1,11.959,64.056,2408.817],[527.041,1,11.792,67.189,2439.935],[543.935,1,11.643,70.36,2472.064],[560.911,1,11.511,73.559,2505.209],[577.942,1,11.396,76.779,2539.374],[595,1,11.299,80.011,2574.562],[612.058,1,11.219,83.247,2610.774],[629.089,1,11.156,86.479,2648.009],[646.065,1,11.111,89.696,2686.264],[662.959,1,11.082,92.892,2725.535],[679.745,1,11.069,96.056,2765.817],[696.396,1,11.071,99.181,2807.102],[712.886,1,11.089,102.259,2849.381],[729.188,1,11.121,105.282,2892.644],[745.278,1,11.167,108.241,2936.879],[761.13,1,11.225,111.131,2982.073],[776.719,1,11.295,113.945,3028.21],[792.023,1,11.377,116.676,3075.273],[807.019,1,11.468,119.319,3123.246],[821.683,1,11.568,121.871,3172.109],[835.996,1,11.677,124.326,3221.841],[849.936,1,11.792,126.682,3272.421],[863.484,1,11.913,128.936,3323.826],[876.621,1,12.038,131.088,3376.031],[889.331,1,12.167,133.137,3429.012],[901.597,1,12.298,135.083,3482.742],[913.405,1,12.43,136.928,3537.194],[924.74,1,12.562,138.675,3592.341],[935.592,1,12.692,140.328,3648.153],[945.948,1,12.82,141.891,3704.602],[955.801,1,12.943,143.37,3761.657],[965.141,1,13.061,144.771,3819.288],[973.964,1,13.174,146.095,3877.464],[982.265,1,13.281,147.34,3936.153],[990.042,1,13.382,148.506,3995.325],[997.293,1,13.477,149.594,4054.948],[1004.02,1,13.566,150.603,4114.99],[1010.226,1,13.649,151.534,4175.42],[1015.917,1,13.725,152.388,4236.207],[1021.1,1,13.794,153.165,4297.32],[1025.783,1,13.858,153.867,4358.729],[1029.98,1,13.915,154.497,4420.404],[1033.703,1,13.965,155.055,4482.317],[1036.97,1,14.01,155.545,4544.439],[1039.798,1,14.049,155.97,4606.744],[1042.21,1,14.082,156.332,4669.207],[1044.229,1,14.11,156.634,4731.802],[1045.882,1,14.132,156.882,4794.507],[1047.197,1,14.15,157.08,4857.301],[1048.207,1,14.164,157.231,4920.164],[1048.946,1,14.175,157.342,4983.08],[1049.452,1,14.182,157.418,5046.033],[1049.765,1,14.186,157.465,5109.011],[1049.929,1,14.188,157.489,5172.002],[1049.991,1,14.189,157.499,5235],[1050,1,14.189,157.5,5298],[1049.998,1,14.189,157.5,5361],[1049.982,1,14.189,157.497,5423.999],[1049.94,1,14.188,157.491,5486.997],[1049.86,1,14.187,157.479,5549.992],[1049.731,1,14.185,157.46,5612.98],[1049.541,1,14.183,157.431,5675.958],[1049.281,1,14.179,157.392,5738.923],[1048.941,1,14.175,157.341,5801.87],[1048.513,1,14.169,157.277,5864.794],[1047.988,1,14.161,157.198,5927.69],[1047.36,1,14.153,157.104,5990.551],[1046.62,1,14.142,156.993,6053.371],[1045.763,1,14.131,156.864,6116.143],[1044.782,1,14.117,156.717,6178.86],[1043.673,1,14.102,156.551,6241.514],[1042.432,1,14.085,156.365,6304.098],[1041.052,1,14.066,156.158,6366.603],[1039.533,1,14.045,155.93,6429.021],[1037.869,1,14.022,155.68,6491.344],[1036.058,1,13.997,155.409,6553.563],[1034.099,1,13.971,155.115,6615.668],[1031.989,1,13.942,154.798,6677.652],[1029.728,1,13.911,154.459,6739.504],[1027.314,1,13.878,154.097,6801.216],[1024.748,1,13.844,153.712,6862.779],[1022.029,1,13.807,153.304,6924.183],[1019.158,1,13.768,152.874,6985.419],[1016.135,1,13.728,152.42,7046.478],[1012.962,1,13.685,151.944,7107.352],[1009.641,1,13.641,151.446,7168.031],[1006.174,1,13.595,150.926,7228.506],[1002.563,1,13.547,150.384,7288.769],[998.81,1,13.497,149.821,7348.811],[994.919,1,13.446,149.238,7408.623],[990.892,1,13.394,148.634,7468.198],[986.734,1,13.339,148.01,7527.528],[982.449,1,13.284,147.367,7586.604],[978.04,1,13.227,146.706,7645.419],[973.511,1,13.168,146.027,7703.966],[968.869,1,13.109,145.33,7762.238],[964.116,1,13.048,144.617,7820.228],[959.26,1,12.987,143.889,7877.93],[954.304,1,12.924,143.146,7935.338],[949.254,1,12.861,142.388,7992.445],[944.116,1,12.797,141.617,8049.246],[938.896,1,12.732,140.834,8105.737],[933.6,1,12.666,140.04,8161.912],[928.233,1,12.6,139.235,8217.768],[922.803,1,12.534,138.421,8273.299],[917.316,1,12.468,137.597,8328.503],[911.778,1,12.401,136.767,8383.376],[906.195,1,12.334,135.929,8437.915],[900.575,1,12.267,135.086,8492.119],[894.924,1,12.2,134.239,8545.984],[889.248,1,12.133,133.387,8599.509],[883.556,1,12.067,132.533,8652.693],[877.853,1,12,131.678,8705.535],[872.147,1,11.934,130.822,8758.035],[866.444,1,11.869,129.967,8810.193],[860.752,1,11.804,129.113,8862.009],[855.076,1,11.74,128.261,8913.484],[849.425,1,11.676,127.414,8964.619],[843.805,1,11.614,126.571,9015.415],[838.222,1,11.552,125.733,9065.876],[832.684,1,11.491,124.903,9116.003],[827.197,1,11.431,124.079,9165.799],[821.767,1,11.372,123.265,9215.268],[816.4,1,11.314,122.46,9264.412],[811.104,1,11.257,121.666,9313.237],[805.884,1,11.201,120.883,9361.746],[800.746,1,11.147,120.112,9409.945],[795.696,1,11.093,119.354,9457.838],[790.74,1,11.042,118.611,9505.43],[785.884,1,10.991,117.883,9552.728],[781.131,1,10.942,117.17,9599.738],[776.489,1,10.894,116.473,9646.466],[771.96,1,10.848,115.794,9692.919],[767.551,1,10.803,115.133,9739.104],[763.266,1,10.76,114.49,9785.028],[759.108,1,10.718,113.866,9830.698],[755.081,1,10.678,113.262,9876.123],[751.19,1,10.639,112.679,9921.311],[747.437,1,10.602,112.116,9966.269],[743.826,1,10.567,111.574,10011.006],[740.359,1,10.533,111.054,10055.531],[737.038,1,10.5,110.556,10099.852],[733.865,1,10.47,110.08,10143.978],[730.842,1,10.44,109.626,10187.919],[727.971,1,10.413,109.196,10231.683],[725.252,1,10.387,108.788,10275.279],[722.686,1,10.362,108.403,10318.716],[720.272,1,10.339,108.041,10362.004],[718.011,1,10.318,107.702,10405.152],[715.901,1,10.298,107.385,10448.168],[713.942,1,10.279,107.091,10491.063],[712.131,1,10.262,106.82,10533.844],[710.467,1,10.247,106.57,10576.521],[708.948,1,10.233,106.342,10619.103],[707.568,1,10.22,106.135,10661.598],[706.327,1,10.208,105.949,10704.014],[705.218,1,10.198,105.783,10746.36],[704.237,1,10.189,105.636,10788.643],[703.38,1,10.181,105.507,10830.871],[702.64,1,10.174,105.396,10873.051],[702.012,1,10.168,105.302,10915.19],[701.487,1,10.163,105.223,10957.294],[701.059,1,10.159,105.159,10999.37],[700.719,1,10.156,105.108,11041.423],[700.459,1,10.154,105.069,11083.458],[700.269,1,10.152,105.04,11125.48],[700.14,1,10.151,105.021,11167.492],[700.06,1,10.15,105.009,11209.497],[700.018,1,10.15,105.003,11251.499],[700.002,1,10.149,105,11293.5],[700,1,10.149,105,11335.5],[700,1,10.149,105,11377.5],[700,1,10.149,105,11419.5],[700,1,10.149,105,11461.5],[700,1,10.149,105,11503.5],[700,1,10.149,105,11545.5],[700,1,10.149,105,11587.5],[700,1,10.149,105,11629.5],[700,1,10.149,105,11671.5],[700,1,10.149,105,11713.5],[700,1,10.149,105,11755.5],[700,1,10.149,104.998,11797.5],[700,1,10.149,104.985,11839.497],[700,1,10.149,104.949,11881.485],[700,0.999,10.149,104.881,11923.452],[700,0.998,10.149,104.771,11965.384],[700,0.996,10.149,104.609,12007.262],[700,0.994,10.149,104.388,12049.063],[700,0.991,10.149,104.1,12090.763],[700,0.988,10.149,103.737,12132.333],[700,0.984,10.149,103.294,12173.743],[700,0.979,10.149,102.766,12214.958],[700,0.973,10.149,102.146,12255.943],[700,0.966,10.149,101.433,12296.662],[700,0.958,10.149,100.622,12337.076],[700,0.95,10.149,99.712,12377.147],[700,0.94,10.149,98.702,12416.833],[700,0.929,10.149,97.591,12456.095],[700,0.918,10.149,96.38,12494.893],[700,0.905,10.149,95.07,12533.186],[700,0.892,10.149,93.664,12570.936],[700,0.878,10.149,92.163,12608.104],[700,0.863,10.149,90.573,12644.655],[700,0.847,10.149,88.897,12680.552],[700,0.83,10.149,87.141,12715.762],[700,0.812,10.149,85.309,12750.254],[700,0.794,10.149,83.408,12784],[700,0.776,10.149,81.444,12816.972],[700,0.756,10.149,79.424,12849.147],[700,0.737,10.149,77.356,12880.505],[700,0.717,10.149,75.246,12911.027],[700,0.696,10.149,73.101,12940.697],[700,0.676,10.149,70.929,12969.504],[700,0.655,10.149,68.738,12997.438],[700,0.634,10.149,66.534,13024.492],[700,0.613,10.149,64.325,13050.664],[700,0.592,10.149,62.117,13075.952],[700,0.571,10.149,59.918,13100.359],[700,0.55,10.149,57.733,13123.889],[700,0.529,10.149,55.569,13146.548],[700,0.509,10.149,53.431,13168.348],[700,0.489,10.149,51.324,13189.298],[700,0.469,10.149,49.253,13209.412],[700,0.45,10.149,47.223,13228.706],[700,0.431,10.149,45.237,13247.196],[700,0.412,10.149,43.298,13264.901],[700,0.394,10.149,41.41,13281.841],[700,0.377,10.149,39.574,13298.036],[700,0.36,10.149,37.794,13313.508],[700,0.344,10.149,36.071,13328.279],[700,0.328,10.149,34.406,13342.373],[700,0.312,10.149,32.8,13355.812],[700,0.298,10.149,31.254,13368.621],[700,0.283,10.149,29.767,13380.823],[700,0.27,10.149,28.34,13392.442],[700,0.257,10.149,26.973,13403.503],[700,0.244,10.149,25.664,13414.028],[700,0.233,10.149,24.414,13424.042],[700,0.221,10.149,23.221,13433.567],[700,0.21,10.149,22.083,13442.626],[700,0.2,10.149,21,13451.241],[700,0.19,10.149,19.97,13459.433],[700,0.181,10.149,18.992,13467.224],[700,0.172,10.149,18.063,13474.633],[700,0.164,10.149,17.183,13481.681],[700,0.156,10.149,16.35,13488.386],[700,0.148,10.149,15.561,13494.767],[700,0.141,10.149,14.815,13500.84],[700,0.134,10.149,14.11,13506.624],[700,0.128,10.149,13.445,13512.134],[700,0.122,10.149,12.817,13517.385],[700,0.116,10.149,12.226,13522.393],[700,0.111,10.149,11.668,13527.17],[700,0.106,10.149,11.144,13531.732],[700,0.101,10.149,10.65,13536.089],[700,0.097,10.149,10.185,13540.256],[700,0.093,10.149,9.749,13544.241],[700,0.089,10.149,9.339,13548.058],[700,0.085,10.149,8.954,13551.716],[700,0.082,10.149,8.592,13555.224],[700,0.079,10.149,8.254,13558.593],[700,0.076,10.149,7.936,13561.83],[700,0.073,10.149,7.639,13564.944],[700,0.07,10.149,7.36,13567.943],[700,0.068,10.149,7.099,13570.835],[700,0.065,10.149,6.856,13573.625],[700,0.063,10.149,6.628,13576.321],[700,0.061,10.149,6.416,13578.93],[700,0.059,10.149,6.217,13581.456],[700,0.057,10.149,6.033,13583.905],[700,0.056,10.149,5.861,13586.284],[700,0.054,10.149,5.701,13588.596],[700,0.053,10.149,5.552,13590.846],[700,0.052,10.149,5.415,13593.039],[700,0.05,10.149,5.287,13595.179],[700,0.049,10.149,5.169,13597.27],[700,0.048,10.149,5.061,13599.316],[700,0.047,10.149,4.961,13601.32],[700,0.046,10.149,4.869,13603.286],[700,0.046,10.149,4.785,13605.216],[700,0.045,10.149,4.708,13607.115],[700,0.044,10.149,4.639,13608.984],[700,0.044,10.149,4.576,13610.826],[700,0.043,10.149,4.519,13612.645],[700,0.043,10.149,4.468,13614.442],[700,0.042,10.149,4.423,13616.22],[700,0.042,10.149,4.383,13617.981],[700,0.041,10.149,4.348,13619.727],[700,0.041,10.149,4.317,13621.46],[700,0.041,10.149,4.291,13623.182],[700,0.041,10.149,4.269,13624.894],[700,0.04,10.149,4.251,13626.597],[700,0.04,10.149,4.236,13628.295],[700,0.04,10.149,4.225,13629.987],[700,0.04,10.149,4.216,13631.675],[700,0.04,10.149,4.209,13633.36],[700,0.04,10.149,4.205,13635.043],[700,0.04,10.149,4.202,13636.724],[700,0.04,10.149,4.201,13638.404],[700,0.04,10.149,4.2,13640.084],[700,0.04,10.149,4.2,13641.764],[700,0.04,10.149,4.2,13643.444],[700,0.04,10.149,4.2,13645.124],[700,0.04,10.149,4.2,13646.804],[700,0.04,10.149,4.2,13648.484],[700,0.04,10.149,4.2,13650.164],[700,0.04,10.149,4.2,13651.844],[700,0.04,10.149,4.2,13653.524],[700,0.04,10.149,4.2,13655.204],[700,0.04,10.149,4.2,13656.884],[700,0.04,10.149,4.2,13658.564],[700,0.04,10.149,4.2,13660.244],[700,0.04,10.149,4.2,13661.924],[700,0.04,10.149,4.2,13663.604],[700,0.04,10.149,4.2,13665.284],[700,0.04,10.149,4.2,13666.964],[700,0.04,10.149,4.2,13668.644],[700,0.04,10.149,4.2,13670.324],[700,0.04,10.149,4.2,13672.004],[700,0.04,10.149,4.2,13673.684],[700,0.04,10.149,4.2,13675.364],[700,0.04,10.149,4.2,13677.044],[700,0.04,10.149,4.2,13678.724],[700,0.04,10.149,4.2,13680.404],[700,0.04,10.149,4.2,13682.084],[700,0.04,10.149,4.2,13683.764],[700,0.04,10.149,4.2,13685.444],[700,0.04,10.149,4.2,13687.124],[700,0.04,10.149,4.2,13688.804],[700,0.04,10.149,4.2,13690.484],[700,0.04,10.149,4.2,13692.164],[700,0.04,10.149,4.2,13693.844],[700,0.04,10.149,4.2,13695.524],[700,0.04,10.149,4.2,13697.204],[700,0.04,10.149,4.2,13698.884],[700,0.04,10.149,4.2,13700.564],[700,0.04,10.149,4.2,13702.244],[700,0.04,10.149,4.2,13703.924],[700,0.04,10.149,4.2,13705.604],[700,0.04,10.149,4.2,13707.284],[700,0.04,10.149,4.2,13708.964],[700,0.04,10.149,4.2,13710.644],[700,0.04,10.149,4.2,13712.324],[700,0.04,10.149,4.2,13714.004],[700,0.04,10.149,4.2,13715.684],[700,0.04,10.149,4.2,13717.364],[700,0.04,10.149,4.2,13719.044],[700,0.04,10.149,4.2,13720.724],[700,0.04,10.149,4.2,13722.404],[700,0.04,10.149,4.2,13724.084],[700,0.04,10.149,4.2,13725.764],[700,0.04,10.149,4.2,13727.444],[700,0.04,10.149,4.2,13729.124],[700,0.04,10.149,4.2,13730.804],[700,0.04,10.149,4.2,13732.484],[700,0.04,10.149,4.2,13734.164],[700,0.04,10.149,4.2,13735.844],[700,0.04,10.149,4.2,13737.524],[700,0.04,10.149,4.2,13739.204],[700,0.04,10.149,4.2,13740.884],[700,0.04,10.149,4.2,13742.564],[700,0.04,10.149,4.2,13744.244],[700,0.04,10.149,4.2,13745.924],[700,0.04,10.149,4.2,13747.604],[700,0.04,10.149,4.2,13749.284],[700,0.04,10.149,4.2,13750.964],[700,0.04,10.149,4.2,13752.644],[700,0.04,10.149,4.2,13754.324],[700,0.04,10.149,4.2,13756.004],[700,0.04,10.149,4.2,13757.684],[700,0.04,10.149,4.2,13759.364],[700,0.04,10.149,4.2,13761.044],[700,0.04,10.149,4.2,13762.724],[700,0.04,10.149,4.2,13764.404],[700,0.04,10.149,4.2,13766.084],[700,0.04,10.149,4.2,13767.764],[700,0.04,10.149,4.2,13769.444],[700,0.04,10.149,4.2,13771.124],[700,0.04,10.149,4.2,13772.804],[700,0.04,10.149,4.2,13774.484],[700,0.04,10.149,4.2,13776.164],[700,0.04,10.149,4.2,13777.844],[700,0.04,10.149,4.2,13779.524],[700,0.04,10.149,4.2,13781.204],[700,0.04,10.149,4.2,13782.884],[700,0.04,10.149,4.2,13784.564],[700,0.04,10.149,4.2,13786.244],[700,0.04,10.149,4.2,13787.924],[700,0.04,10.149,4.2,13789.604],[700,0.04,10.149,4.2,13791.284],[700,0.04,10.149,4.2,13792.964],[700,0.04,10.149,4.2,13794.644],[700,0.04,10.149,4.2,13796.324],[700,0.04,10.149,4.2,13798.004],[700,0.04,10.149,4.2,13799.684],[700,0.04,10.149,4.2,13801.364],[700,0.04,10.149,4.2,13803.044],[700,0.04,10.149,4.2,13804.724],[700,0.04,10.149,4.2,13806.404],[700,0.04,10.149,4.2,13808.084],[700,0.04,10.149,4.2,13809.764],[700,0.04,10.149,4.2,13811.444],[700,0.04,10.149,4.2,13813.124],[700,0.04,10.149,4.2,13814.804],[700,0.04,10.149,4.2,13816.484],[700,0.04,10.149,4.2,13818.164],[700,0.04,10.149,4.2,13819.844],[700,0.04,10.149,4.2,13821.524],[700,0.04,10.149,4.2,13823.204],[700,0.04,10.149,4.2,13824.884],[700,0.04,10.149,4.2,13826.564],[700,0.04,10.149,4.2,13828.244],[700,0.04,10.149,4.2,13829.924],[700,0.04,10.149,4.2,13831.604],[700,0.04,10.149,4.2,13833.284],[700,0.04,10.149,4.2,13834.964],[700,0.04,10.149,4.2,13836.644],[700,0.04,10.149,4.2,13838.324],[700,0.04,10.149,4.2,13840.004],[700,0.04,10.149,4.2,13841.684],[700,0.04,10.149,4.2,13843.364],[700,0.04,10.149,4.2,13845.044],[700,0.04,10.149,4.2,13846.724],[700,0.04,10.149,4.2,13848.404],[700,0.04,10.149,4.2,13850.084],[700,0.04,10.149,4.2,13851.764],[700,0.04,10.149,4.2,13853.444],[700,0.04,10.149,4.2,13855.124],[700,0.04,10.149,4.2,13856.804],[700,0.04,10.149,4.2,13858.484],[700,0.04,10.149,4.2,13860.164],[700,0.04,10.149,4.2,13861.844],[700,0.04,10.149,4.2,13863.524],[700,0.04,10.149,4.2,13865.204],[700,0.04,10.149,4.2,13866.884],[700,0.04,10.149,4.2,13868.564],[700,0.04,10.149,4.2,13870.244],[700,0.04,10.149,4.2,13871.924],[700,0.04,10.149,4.2,13873.604],[700,0.04,10.149,4.2,13875.284],[700,0.04,10.149,4.2,13876.964],[700,0.04,10.149,4.2,13878.644],[700,0.04,10.149,4.2,13880.324],[700,0.04,10.149,4.2,13882.004],[700,0.04,10.149,4.2,13883.684],[700,0.04,10.149,4.2,13885.364],[700,0.04,10.149,4.2,13887.044],[700,0.04,10.149,4.2,13888.724],[700,0.04,10.149,4.2,13890.404],[700,0.04,10.149,4.2,13892.084],[700,0.04,10.149,4.2,13893.764],[700,0.04,10.149,4.2,13895.444],[700,0.04,10.149,4.2,13897.124],[700,0.04,10.149,4.2,13898.804],[700,0.04,10.149,4.2,13900.484],[700,0.04,10.149,4.2,13902.164],[700,0.04,10.149,4.2,13903.844],[700,0.04,10.149,4.2,13905.524],[700,0.04,10.149,4.2,13907.204],[700,0.04,10.149,4.2,13908.884],[700,0.04,10.149,4.2,13910.564],[700,0.04,10.149,4.2,13912.244],[700,0.04,10.149,4.2,13913.924],[700,0.04,10.149,4.2,13915.604],[700,0.04,10.149,4.2,13917.284],[700,0.04,10.149,4.2,13918.964],[700,0.04,10.149,4.2,13920.644],[700,0.04,10.149,4.2,13922.324],[700,0.04,10.149,4.2,13924.004],[700,0.04,10.149,4.2,13925.684],[700,0.04,10.149,4.2,13927.364],[700,0.04,10.149,4.2,13929.044],[700,0.04,10.149,4.2,13930.724],[700,0.04,10.149,4.2,13932.404],[700,0.04,10.149,4.2,13934.084],[700,0.04,10.149,4.2,13935.764],[700,0.04,10.149,4.2,13937.444],[700,0.04,10.149,4.2,13939.124],[700,0.04,10.149,4.2,13940.804],[700,0.04,10.149,4.2,13942.484],[700,0.04,10.149,4.2,13944.164],[700,0.04,10.149,4.2,13945.844],[700,0.04,10.149,4.2,13947.524],[700,0.04,10.149,4.2,13949.204],[700,0.04,10.149,4.2,13950.884],[700,0.04,10.149,4.2,13952.564],[700,0.04,10.149,4.2,13954.244],[700,0.04,10.149,4.2,13955.924],[700,0.04,10.149,4.2,13957.604],[700,0.04,10.149,4.2,13959.284],[700,0.04,10.149,4.2,13960.964],[700,0.04,10.149,4.2,13962.644],[700,0.04,10.149,4.2,13964.324],[700,0.04,10.149,4.2,13966.004],[700,0.04,10.149,4.2,13967.684],[700,0.04,10.149,4.2,13969.364],[700,0.04,10.149,4.2,13971.044],[700,0.04,10.149,4.2,13972.724],[700,0.04,10.149,4.2,13974.404],[700,0.04,10.149,4.2,13976.084],[700,0.04,10.149,4.2,13977.764],[700,0.04,10.149,4.2,13979.444],[700,0.04,10.149,4.2,13981.124],[700,0.04,10.149,4.2,13982.804],[700,0.04,10.149,4.2,13984.484],[700,0.04,10.149,4.2,13986.164],[700,0.04,10.149,4.2,13987.844],[700,0.04,10.149,4.2,13989.524],[700,0.04,10.149,4.2,13991.204],[700,0.04,10.149,4.2,13992.884],[700,0.04,10.149,4.2,13994.564],[700,0.04,10.149,4.2,13996.244],[700,0.04,10.149,4.2,13997.924],[700,0.04,10.149,4.2,13999.604],[700,0.04,10.149,4.2,14001.284],[700,0.04,10.149,4.2,14002.964],[700,0.04,10.149,4.2,14004.644],[700,0.04,10.149,4.2,14006.324],[700,0.04,10.149,4.2,14008.004],[700,0.04,10.149,4.2,14009.684],[700,0.04,10.149,4.2,14011.364],[700,0.04,10.149,4.2,14013.044],[700,0.04,10.149,4.2,14014.724],[700,0.04,10.149,4.2,14016.404],[700,0.04,10.149,4.2,14018.084],[700,0.04,10.149,4.2,14019.764],[700,0.04,10.149,4.2,14021.444],[700,0.04,10.149,4.2,14023.124],[700,0.04,10.149,4.2,14024.804],[700,0.04,10.149,4.2,14026.484],[700,0.04,10.149,4.2,14028.164],[700,0.04,10.149,4.2,14029.844],[700,0.04,10.149,4.2,14031.524],[700,0.04,10.149,4.2,14033.204],[700,0.04,10.149,4.2,14034.884],[700,0.04,10.149,4.2,14036.564],[700,0.04,10.149,4.2,14038.244],[700,0.04,10.149,4.2,14039.924],[700,0.04,10.149,4.2,14041.604],[700,0.04,10.149,4.2,14043.284],[700,0.04,10.149,4.2,14044.964],[700,0.04,10.149,4.2,14046.644],[700,0.04,10.149,4.2,14048.324],[700,0.04,10.149,4.2,14050.004],[700,0.04,10.149,4.2,14051.684],[700,0.04,10.149,4.2,14053.364],[700,0.04,10.149,4.2,14055.044],[700,0.04,10.149,4.2,14056.724],[700,0.04,10.149,4.2,14058.404],[700,0.04,10.149,4.2,14060.084],[700,0.04,10.149,4.2,14061.764],[700,0.04,10.149,4.2,14063.444],[700,0.04,10.149,4.2,14065.124],[700,0.04,10.149,4.2,14066.804],[700,0.04,10.149,4.2,14068.484],[700,0.04,10.149,4.2,14070.164],[700,0.04,10.149,4.2,14071.844],[700,0.04,10.149,4.2,14073.524],[700,0.04,10.149,4.2,14075.204],[700,0.04,10.149,4.2,14076.884],[700,0.04,10.149,4.2,14078.564],[700,0.04,10.149,4.2,14080.244],[700,0.04,10.149,4.2,14081.924],[700,0.04,10.149,4.2,14083.604],[700,0.04,10.149,4.2,14085.284],[700,0.04,10.149,4.2,14086.964],[700,0.04,10.149,4.2,14088.644],[700,0.04,10.149,4.2,14090.324],[700,0.04,10.149,4.2,14092.004],[700,0.04,10.149,4.2,14093.684],[700,0.04,10.149,4.2,14095.364],[700,0.04,10.149,4.2,14097.044],[700,0.04,10.149,4.2,14098.724],[700,0.04,10.149,4.2,14100.404],[700,0.04,10.149,4.2,14102.084],[700,0.04,10.149,4.2,14103.764],[700,0.04,10.149,4.2,14105.444],[700,0.04,10.149,4.2,14107.124],[700,0.04,10.149,4.2,14108.804],[700,0.04,10.149,4.2,14110.484],[700,0.04,10.149,4.2,14112.164],[700,0.04,10.149,4.2,14113.844],[700,0.04,10.149,4.2,14115.524],[700,0.04,10.149,4.2,14117.204],[700,0.04,10.149,4.2,14118.884],[700,0.04,10.149,4.2,14120.564],[700,0.04,10.149,4.2,14122.244],[700,0.04,10.149,4.2,14123.924],[700,0.04,10.149,4.2,14125.604],[700,0.04,10.149,4.2,14127.284],[700,0.04,10.149,4.2,14128.964],[700,0.04,10.149,4.2,14130.644],[700,0.04,10.149,4.2,14132.324],[700,0.04,10.149,4.2,14134.004],[700,0.04,10.149,4.2,14135.684],[700,0.04,10.149,4.2,14137.364],[700,0.04,10.149,4.2,14139.044],[700,0.04,10.149,4.2,14140.724],[700,0.04,10.149,4.2,14142.404],[700,0.04,10.149,4.2,14144.084],[700,0.04,10.149,4.2,14145.764],[700,0.04,10.149,4.2,14147.444],[700,0.04,10.149,4.2,14149.124],[700,0.04,10.149,4.2,14150.804],[700,0.04,10.149,4.2,14152.484],[700,0.04,10.149,4.2,14154.164],[700,0.04,10.149,4.2,14155.844],[700,0.04,10.149,4.2,14157.524],[700,0.04,10.149,4.2,14159.204],[700,0.04,10.149,4.2,14160.884],[700,0.04,10.149,4.2,14162.564],[700,0.04,10.149,4.2,14164.244],[700,0.04,10.149,4.2,14165.924],[700,0.04,10.149,4.2,14167.604],[700,0.04,10.149,4.2,14169.284],[700,0.04,10.149,4.2,14170.964],[700,0.04,10.149,4.2,14172.644],[700,0.04,10.149,4.2,14174.324],[700,0.04,10.149,4.2,14176.004],[700,0.04,10.149,4.2,14177.684],[700,0.04,10.149,4.2,14179.364],[700,0.04,10.149,4.2,14181.044],[700,0.04,10.149,4.2,14182.724],[700,0.04,10.149,4.2,14184.404],[700,0.04,10.149,4.2,14186.084],[700,0.04,10.149,4.2,14187.764],[700,0.04,10.149,4.2,14189.444],[700,0.04,10.149,4.2,14191.124],[700,0.04,10.149,4.2,14192.804],[700,0.04,10.149,4.2,14194.484],[700,0.04,10.149,4.2,14196.164],[700,0.04,10.149,4.2,14197.844],[700,0.04,10.149,4.2,14199.524],[700,0.04,10.149,4.2,14201.204],[700,0.04,10.149,4.2,14202.884],[700,0.04,10.149,4.2,14204.564],[700,0.04,10.149,4.2,14206.244],[700,0.04,10.149,4.2,14207.924],[700,0.04,10.149,4.2,14209.604],[700,0.04,10.149,4.2,14211.284],[700,0.04,10.149,4.2,14212.964],[700,0.04,10.149,4.2,14214.644],[700,0.04,10.149,4.2,14216.324],[700,0.04,10.149,4.2,14218.004],[700,0.04,10.149,4.2,14219.684],[700,0.04,10.149,4.2,14221.364],[700,0.04,10.149,4.2,14223.044],[700,0.04,10.149,4.2,14224.724],[700,0.04,10.149,4.2,14226.404],[700,0.04,10.149,4.2,14228.084],[700,0.04,10.149,4.2,14229.764],[700,0.04,10.149,4.2,14231.444],[700,0.04,10.149,4.2,14233.124],[700,0.04,10.149,4.2,14234.804],[700,0.04,10.149,4.2,14236.484],[700,0.04,10.149,4.2,14238.164],[700,0.04,10.149,4.2,14239.844],[700,0.04,10.149,4.2,14241.524],[700,0.04,10.149,4.2,14243.204],[700,0.04,10.149,4.2,14244.884],[700,0.04,10.149,4.2,14246.564],[700,0.04,10.149,4.2,14248.244],[700,0.04,10.149,4.2,14249.924],[700,0.04,10.149,4.2,14251.604],[700,0.04,10.149,4.2,14253.284],[700,0.04,10.149,4.2,14254.964],[700,0.04,10.149,4.2,14256.644],[700,0.04,10.149,4.2,14258.324],[700,0.04,10.149,4.2,14260.004],[700,0.04,10.149,4.2,14261.684],[700,0.04,10.149,4.2,14263.364],[700,0.04,10.149,4.2,14265.044],[700,0.04,10.149,4.2,14266.724],[700,0.04,10.149,4.2,14268.404],[700,0.04,10.149,4.2,14270.084],[700,0.04,10.149,4.2,14271.764],[700,0.04,10.149,4.2,14273.444],[700,0.04,10.149,4.2,14275.124],[700,0.04,10.149,4.2,14276.804],[700,0.04,10.149,4.2,14278.484],[700,0.04,10.149,4.2,14280.164],[700,0.04,10.149,4.2,14281.844],[700,0.04,10.149,4.2,14283.524],[700,0.04,10.149,4.2,14285.204],[700,0.04,10.149,4.2,14286.884],[700,0.04,10.149,4.2,14288.564],[700,0.04,10.149,4.2,14290.244],[700,0.04,10.149,4.2,14291.924],[700,0.04,10.149,4.2,14293.604],[700,0.04,10.149,4.2,14295.284],[700,0.04,10.149,4.2,14296.964],[700,0.04,10.149,4.2,14298.644],[700,0.04,10.149,4.2,14300.324],[700,0.04,10.149,4.2,14302.004],[700,0.04,10.149,4.2,14303.684],[700,0.04,10.149,4.2,14305.364],[700,0.04,10.149,4.2,14307.044],[700,0.04,10.149,4.2,14308.724],[700,0.04,10.149,4.2,14310.404],[700,0.04,10.149,4.2,14312.084],[700,0.04,10.149,4.2,14313.764],[700,0.04,10.149,4.2,14315.444],[700,0.04,10.149,4.2,14317.124],[700,0.04,10.149,4.2,14318.804],[700,0.04,10.149,4.2,14320.484],[700,0.04,10.149,4.2,14322.164],[700,0.04,10.149,4.2,14323.844],[700,0.04,10.149,4.2,14325.524],[700,0.04,10.149,4.2,14327.204],[700,0.04,10.149,4.2,14328.884],[700,0.04,10.149,4.2,14330.564],[700,0.04,10.149,4.2,14332.244],[700,0.04,10.149,4.2,14333.924],[700,0.04,10.149,4.2,14335.604],[700,0.04,10.149,4.2,14337.284],[700,0.04,10.149,4.2,14338.964],[700,0.04,10.149,4.2,14340.644],[700,0.04,10.149,4.2,14342.324],[700,0.04,10.149,4.2,14344.004],[700,0.04,10.149,4.2,14345.684],[700,0.04,10.149,4.2,14347.364],[700,0.04,10.149,4.2,14349.044],[700,0.04,10.149,4.2,14350.724],[700,0.04,10.149,4.2,14352.404],[700,0.04,10.149,4.2,14354.084],[700,0.04,10.149,4.2,14355.764],[700,0.04,10.149,4.2,14357.444],[700,0.04,10.149,4.2,14359.124],[700,0.04,10.149,4.2,14360.804],[700,0.04,10.149,4.2,14362.484],[700,0.04,10.149,4.2,14364.164],[700,0.04,10.149,4.2,14365.844],[700,0.04,10.149,4.2,14367.524],[700,0.04,10.149,4.2,14369.204],[700,0.04,10.149,4.2,14370.884],[700,0.04,10.149,4.2,14372.564],[700,0.04,10.149,4.2,14374.244],[700,0.04,10.149,4.2,14375.924],[700,0.04,10.149,4.2,14377.604],[700,0.04,10.149,4.2,14379.284],[700,0.04,10.149,4.2,14380.964],[700,0.04,10.149,4.2,14382.644],[700,0.04,10.149,4.2,14384.324],[700,0.04,10.149,4.2,14386.004],[700,0.04,10.149,4.2,14387.684],[700,0.04,10.149,4.2,14389.364],[700,0.04,10.149,4.2,14391.044],[700,0.04,10.149,4.2,14392.724],[700,0.04,10.149,4.2,14394.404],[700,0.04,10.149,4.2,14396.084],[700,0.04,10.149,4.2,14397.764],[700,0.04,10.149,4.2,14399.444],[700,0.04,10.149,4.2,14401.124],[700,0.04,10.149,4.2,14402.804],[700,0.04,10.149,4.2,14404.484],[700,0.04,10.149,4.2,14406.164],[700,0.04,10.149,4.2,14407.844],[700,0.04,10.149,4.2,14409.524],[700,0.04,10.149,4.2,14411.204],[700,0.04,10.149,4.2,14412.884],[700,0.04,10.149,4.2,14414.564],[700,0.04,10.149,4.2,14416.244],[700,0.04,10.149,4.2,14417.924],[700,0.04,10.149,4.2,14419.604],[700,0.04,10.149,4.2,14421.284],[700,0.04,10.149,4.2,14422.964],[700,0.04,10.149,4.2,14424.644],[700,0.04,10.149,4.2,14426.324],[700,0.04,10.149,4.2,14428.004],[700,0.04,10.149,4.2,14429.684],[700,0.04,10.149,4.2,14431.364],[700,0.04,10.149,4.2,14433.044],[700,0.04,10.149,4.2,14434.724],[700,0.04,10.149,4.2,14436.404],[700,0.04,10.149,4.2,14438.084],[700,0.04,10.149,4.2,14439.764],[700,0.04,10.149,4.2,14441.444],[700,0.04,10.149,4.2,14443.124],[700,0.04,10.149,4.2,14444.804],[700,0.04,10.149,4.2,14446.484],[700,0.04,10.149,4.2,14448.164],[700,0.04,10.149,4.2,14449.844],[700,0.04,10.149,4.2,14451.524],[700,0.04,10.149,4.2,14453.204],[700,0.04,10.149,4.2,14454.884],[700,0.04,10.149,4.2,14456.564],[700,0.04,10.149,4.2,14458.244],[700,0.04,10.149,4.2,14459.924],[700,0.04,10.149,4.2,14461.604],[700,0.04,10.149,4.2,14463.284],[700,0.04,10.149,4.2,14464.964],[700,0.04,10.149,4.2,14466.644],[700,0.04,10.149,4.2,14468.324],[700,0.04,10.149,4.2,14470.004],[700,0.04,10.149,4.2,14471.684],[700,0.04,10.149,4.2,14473.364],[700,0.04,10.149,4.2,14475.044],[700,0.04,10.149,4.2,14476.724],[700,0.04,10.149,4.2,14478.404],[700,0.04,10.149,4.2,14480.084],[700,0.04,10.149,4.2,14481.764],[700,0.04,10.149,4.2,14483.444],[700,0.04,10.149,4.2,14485.124],[700,0.04,10.149,4.199,14486.804],[700,0.04,10.149,4.198,14488.484],[700,0.04,10.149,4.196,14490.162],[700,0.04,10.149,4.193,14491.84],[700,0.04,10.149,4.188,14493.516],[700,0.04,10.149,4.183,14495.191],[700,0.04,10.149,4.176,14496.863],[700,0.04,10.149,4.168,14498.532],[700,0.04,10.149,4.159,14500.197],[700,0.04,10.149,4.148,14501.858],[700,0.039,10.149,4.135,14503.515],[700,0.039,10.149,4.121,14505.166],[700,0.039,10.149,4.105,14506.811],[700,0.039,10.149,4.087,14508.45],[700,0.039,10.149,4.068,14510.081],[700,0.039,10.149,4.047,14511.704],[700,0.038,10.149,4.025,14513.318],[700,0.038,10.149,4.001,14514.924],[700,0.038,10.149,3.975,14516.519],[700,0.038,10.149,3.948,14518.103],[700,0.037,10.149,3.92,14519.677],[700,0.037,10.149,3.89,14521.239],[700,0.037,10.149,3.859,14522.789],[700,0.036,10.149,3.827,14524.326],[700,0.036,10.149,3.794,14525.851],[700,0.036,10.149,3.761,14527.362],[700,0.035,10.149,3.726,14528.859],[700,0.035,10.149,3.69,14530.342],[700,0.035,10.149,3.654,14531.811],[700,0.034,10.149,3.618,14533.266],[700,0.034,10.149,3.581,14534.705],[700,0.034,10.149,3.544,14536.13],[700,0.033,10.149,3.506,14537.54],[700,0.033,10.149,3.469,14538.936],[700,0.033,10.149,3.432,14540.316],[700,0.032,10.149,3.394,14541.681],[700,0.032,10.149,3.357,14543.031],[700,0.032,10.149,3.32,14544.367],[700,0.031,10.149,3.284,14545.687],[700,0.031,10.149,3.248,14546.994],[700,0.031,10.149,3.213,14548.286],[700,0.03,10.149,3.178,14549.564],[700,0.03,10.149,3.144,14550.829],[700,0.03,10.149,3.111,14552.08],[700,0.029,10.149,3.079,14553.318],[700,0.029,10.149,3.047,14554.543],[700,0.029,10.149,3.017,14555.756],[700,0.028,10.149,2.987,14556.956],[700,0.028,10.149,2.959,14558.146],[700,0.028,10.149,2.932,14559.324],[700,0.028,10.149,2.906,14560.491],[700,0.027,10.149,2.881,14561.649],[700,0.027,10.149,2.857,14562.796],[700,0.027,10.149,2.834,14563.934],[700,0.027,10.149,2.813,14565.063],[700,0.027,10.149,2.793,14566.184],[700,0.026,10.149,2.774,14567.298],[700,0.026,10.149,2.756,14568.403],[700,0.026,10.149,2.739,14569.502],[700,0.026,10.149,2.724,14570.595],[700,0.026,10.149,2.71,14571.682],[700,0.026,10.149,2.697,14572.763],[700,0.026,10.149,2.686,14573.84],[700,0.025,10.149,2.675,14574.912],[700,0.025,10.149,2.666,14575.981],[700,0.025,10.149,2.658,14577.045],[700,0.025,10.149,2.651,14578.107],[700,0.025,10.149,2.645,14579.166],[700,0.025,10.149,2.64,14580.223],[700,0.025,10.149,2.636,14581.278],[700,0.025,10.149,2.632,14582.332],[700,0.025,10.149,2.63,14583.384],[700,0.025,10.149,2.628,14584.436],[700,0.025,10.149,2.626,14585.486],[700,0.025,10.149,2.626,14586.537],[700,0.025,10.149,2.625,14587.587],[700,0.025,10.149,2.625,14588.637],[700,0.025,10.149,2.625,14589.687],[700,0.025,10.149,2.625,14590.737],[700,0.025,10.149,2.625,14591.787],[700,0.025,10.149,2.625,14592.837],[700,0.025,10.149,2.625,14593.887],[700,0.025,10.149,2.625,14594.937],[700,0.025,10.149,2.625,14595.987],[700,0.025,10.149,2.625,14597.037],[700,0.025,10.149,2.625,14598.087],[700,0.025,10.149,2.625,14599.137],[700,0.025,10.149,2.625,14600.187],[700,0.025,10.149,2.625,14601.237],[700,0.025,10.149,2.625,14602.287],[700,0.025,10.149,2.625,14603.337],[700,0.025,10.149,2.625,14604.387],[700,0.025,10.149,2.625,14605.437],[700,0.025,10.149,2.625,14606.487],[700,0.025,10.149,2.625,14607.537],[700,0.025,10.149,2.625,14608.587],[700,0.025,10.149,2.625,14609.637],[700,0.025,10.149,2.625,14610.687],[700,0.025,10.149,2.625,14611.737],[700,0.025,10.149,2.625,14612.787],[700,0.025,10.149,2.625,14613.837],[700,0.025,10.149,2.625,14614.887],[700,0.025,10.149,2.625,14615.937],[700,0.025,10.149,2.625,14616.987],[700,0.025,10.149,2.625,14618.037],[700,0.025,10.149,2.625,14619.087],[700,0.025,10.149,2.625,14620.137],[700,0.025,10.149,2.625,14621.187],[700,0.025,10.149,2.625,14622.237],[700,0.025,10.149,2.625,14623.287],[700,0.025,10.149,2.625,14624.337],[700,0.025,10.149,2.625,14625.387],[700,0.025,10.149,2.625,14626.437],[700,0.025,10.149,2.625,14627.487],[700,0.025,10.149,2.625,14628.537],[700,0.025,10.149,2.625,14629.587],[700,0.025,10.149,2.625,14630.637],[700,0.025,10.149,2.625,14631.687],[700,0.025,10.149,2.625,14632.737],[700,0.025,10.149,2.625,14633.787],[700,0.025,10.149,2.625,14634.837],[700,0.025,10.149,2.625,14635.887],[700,0.025,10.149,2.625,14636.937],[700,0.025,10.149,2.625,14637.987],[700,0.025,10.149,2.625,14639.037],[700,0.025,10.149,2.625,14640.087],[700,0.025,10.149,2.625,14641.137],[700,0.025,10.149,2.625,14642.187],[700,0.025,10.149,2.625,14643.237],[700,0.025,10.149,2.625,14644.287],[700,0.025,10.149,2.625,14645.337],[700,0.025,10.149,2.625,14646.387],[700,0.025,10.149,2.625,14647.437],[700,0.025,10.149,2.625,14648.487],[700,0.025,10.149,2.625,14649.537],[700,0.025,10.149,2.625,14650.587],[700,0.025,10.149,2.625,14651.637],[700,0.025,10.149,2.625,14652.687],[700,0.025,10.149,2.625,14653.737],[700,0.025,10.149,2.625,14654.787],[700,0.025,10.149,2.625,14655.837],[700,0.025,10.149,2.625,14656.887],[700,0.025,10.149,2.625,14657.937],[700,0.025,10.149,2.625,14658.987],[700,0.025,10.149,2.625,14660.037],[700,0.025,10.149,2.625,14661.087],[700,0.025,10.149,2.625,14662.137],[700,0.025,10.149,2.625,14663.187],[700,0.025,10.149,2.625,14664.237],[700,0.025,10.149,2.625,14665.287],[700,0.025,10.149,2.625,14666.337],[700,0.025,10.149,2.625,14667.387],[700,0.025,10.149,2.625,14668.437],[700,0.025,10.149,2.625,14669.487],[700,0.025,10.149,2.625,14670.537],[700,0.025,10.149,2.625,14671.587],[700,0.025,10.149,2.625,14672.637],[700,0.025,10.149,2.625,14673.687],[700,0.025,10.149,2.625,14674.737],[700,0.025,10.149,2.625,14675.787],[700,0.025,10.149,2.625,14676.837],[700,0.025,10.149,2.625,14677.887],[700,0.025,10.149,2.625,14678.937],[700,0.025,10.149,2.625,14679.987],[700,0.025,10.149,2.625,14681.037],[700,0.025,10.149,2.625,14682.087],[700,0.025,10.149,2.625,14683.137],[700,0.025,10.149,2.625,14684.187],[700,0.025,10.149,2.625,14685.237],[700,0.025,10.149,2.625,14686.287],[700,0.025,10.149,2.625,14687.337],[700,0.025,10.149,2.625,14688.387],[700,0.025,10.149,2.625,14689.437],[700,0.025,10.149,2.625,14690.487],[700,0.025,10.149,2.625,14691.537],[700,0.025,10.149,2.625,14692.587],[700,0.025,10.149,2.625,14693.637],[700,0.025,10.149,2.625,14694.687],[700,0.025,10.149,2.625,14695.737],[700,0.025,10.149,2.625,14696.787],[700,0.025,10.149,2.625,14697.837],[700,0.025,10.149,2.625,14698.887],[700,0.025,10.149,2.625,14699.937],[700,0.025,10.149,2.625,14700.987],[700,0.025,10.149,2.625,14702.037],[700,0.025,10.149,2.625,14703.087],[700,0.025,10.149,2.625,14704.137],[700,0.025,10.149,2.625,14705.187],[700,0.025,10.149,2.625,14706.237],[700,0.025,10.149,2.625,14707.287],[700,0.025,10.149,2.625,14708.337],[700,0.025,10.149,2.625,14709.387],[700,0.025,10.149,2.625,14710.437],[700,0.025,10.149,2.625,14711.487],[700,0.025,10.149,2.625,14712.537],[700,0.025,10.149,2.625,14713.587],[700,0.025,10.149,2.625,14714.637],[700,0.025,10.149,2.625,14715.687],[700,0.025,10.149,2.625,14716.737],[700,0.025,10.149,2.625,14717.787],[700,0.025,10.149,2.625,14718.837],[700,0.025,10.149,2.625,14719.887],[700,0.025,10.149,2.625,14720.937],[700,0.025,10.149,2.625,14721.987],[700,0.025,10.149,2.625,14723.037],[700,0.025,10.149,2.625,14724.087],[700,0.025,10.149,2.625,14725.137],[700,0.025,10.149,2.625,14726.187],[700,0.025,10.149,2.625,14727.237],[700,0.025,10.149,2.625,14728.287],[700,0.025,10.149,2.625,14729.337],[700,0.025,10.149,2.625,14730.387],[700,0.025,10.149,2.625,14731.437],[700,0.025,10.149,2.625,14732.487],[700,0.025,10.149,2.625,14733.537],[700,0.025,10.149,2.625,14734.587],[700,0.025,10.149,2.625,14735.637],[700,0.025,10.149,2.625,14736.687],[700,0.025,10.149,2.625,14737.737],[700,0.025,10.149,2.625,14738.787],[700,0.025,10.149,2.625,14739.837],[700,0.025,10.149,2.625,14740.887],[700,0.025,10.149,2.625,14741.937],[700,0.025,10.149,2.625,14742.987],[700,0.025,10.149,2.625,14744.037],[700,0.025,10.149,2.625,14745.087],[700,0.025,10.149,2.625,14746.137],[700,0.025,10.149,2.625,14747.187],[700,0.025,10.149,2.625,14748.237],[700,0.025,10.149,2.625,14749.287],[700,0.025,10.149,2.625,14750.337],[700,0.025,10.149,2.625,14751.387],[700,0.025,10.149,2.625,14752.437],[700,0.025,10.149,2.625,14753.487],[700,0.025,10.149,2.625,14754.537],[700,0.025,10.149,2.625,14755.587],[700,0.025,10.149,2.625,14756.637],[700,0.025,10.149,2.625,14757.687],[700,0.025,10.149,2.625,14758.737],[700,0.025,10.149,2.625,14759.787],[700,0.025,10.149,2.625,14760.837],[700,0.025,10.149,2.625,14761.887],[700,0.025,10.149,2.625,14762.937],[700,0.025,10.149,2.625,14763.987],[700,0.025,10.149,2.625,14765.037],[700,0.025,10.149,2.625,14766.087],[700,0.025,10.149,2.625,14767.137],[700,0.025,10.149,2.625,14768.187],[700,0.025,10.149,2.625,14769.237],[700,0.025,10.149,2.625,14770.287],[700,0.025,10.149,2.625,14771.337],[700,0.025,10.149,2.625,14772.387],[700,0.025,10.149,2.625,14773.437],[700,0.025,10.149,2.625,14774.487],[700,0.025,10.149,2.625,14775.537],[700,0.025,10.149,2.625,14776.587],[700,0.025,10.149,2.625,14777.637],[700,0.025,10.149,2.625,14778.687],[700,0.025,10.149,2.625,14779.737],[700,0.025,10.149,2.625,14780.787],[700,0.025,10.149,2.625,14781.837],[700,0.025,10.149,2.625,14782.887],[700,0.025,10.149,2.625,14783.937],[700,0.025,10.149,2.625,14784.987],[700,0.025,10.149,2.625,14786.037],[700,0.025,10.149,2.625,14787.087],[700,0.025,10.149,2.625,14788.137],[700,0.025,10.149,2.625,14789.187],[700,0.025,10.149,2.625,14790.237],[700,0.025,10.149,2.625,14791.287],[700,0.025,10.149,2.625,14792.337],[700,0.025,10.149,2.625,14793.387],[700,0.025,10.149,2.625,14794.437],[700,0.025,10.149,2.625,14795.487],[700,0.025,10.149,2.625,14796.537],[700,0.025,10.149,2.625,14797.587],[700,0.025,10.149,2.625,14798.637],[700,0.025,10.149,2.625,14799.687],[700,0.025,10.149,2.625,14800.737],[700,0.025,10.149,2.625,14801.787],[700,0.025,10.149,2.625,14802.837],[700,0.025,10.149,2.625,14803.887],[700,0.025,10.149,2.625,14804.937],[700,0.025,10.149,2.625,14805.987],[700,0.025,10.149,2.625,14807.037],[700,0.025,10.149,2.625,14808.087],[700,0.025,10.149,2.625,14809.137],[700,0.025,10.149,2.625,14810.187],[700,0.025,10.149,2.625,14811.237],[700,0.025,10.149,2.625,14812.287],[700,0.025,10.149,2.625,14813.337],[700,0.025,10.149,2.625,14814.387],[700,0.025,10.149,2.625,14815.437],[700,0.025,10.149,2.625,14816.487],[700,0.025,10.149,2.625,14817.537],[700,0.025,10.149,2.625,14818.587],[700,0.025,10.149,2.625,14819.637],[700,0.025,10.149,2.625,14820.687],[700,0.025,10.149,2.625,14821.737],[700,0.025,10.149,2.625,14822.787],[700,0.025,10.149,2.625,14823.837],[700,0.025,10.149,2.625,14824.887],[700,0.025,10.149,2.625,14825.937],[700,0.025,10.149,2.625,14826.987],[700,0.025,10.149,2.625,14828.037],[700,0.025,10.149,2.625,14829.087],[700,0.025,10.149,2.625,14830.137],[700,0.025,10.149,2.625,14831.187],[700,0.025,10.149,2.625,14832.237],[700,0.025,10.149,2.625,14833.287],[700,0.025,10.149,2.625,14834.337],[700,0.025,10.149,2.625,14835.387],[700,0.025,10.149,2.625,14836.437],[700,0.025,10.149,2.625,14837.487],[700,0.025,10.149,2.625,14838.537],[700,0.025,10.149,2.625,14839.587],[700,0.025,10.149,2.625,14840.637],[700,0.025,10.149,2.625,14841.687],[700,0.025,10.149,2.625,14842.737],[700,0.025,10.149,2.625,14843.787],[700,0.025,10.149,2.625,14844.837],[700,0.025,10.149,2.625,14845.887],[700,0.025,10.149,2.625,14846.937],[700,0.025,10.149,2.625,14847.987],[700,0.025,10.149,2.625,14849.037],[700,0.025,10.149,2.625,14850.087],[700,0.025,10.149,2.625,14851.137],[700,0.025,10.149,2.625,14852.187],[700,0.025,10.149,2.625,14853.237],[700,0.025,10.149,2.625,14854.287],[700,0.025,10.149,2.625,14855.337],[700,0.025,10.149,2.625,14856.387],[700,0.025,10.149,2.625,14857.437],[700,0.025,10.149,2.625,14858.487],[700,0.025,10.149,2.625,14859.537],[700,0.025,10.149,2.625,14860.587],[700,0.025,10.149,2.625,14861.637],[700,0.025,10.149,2.625,14862.687],[700,0.025,10.149,2.625,14863.737],[700,0.025,10.149,2.625,14864.787],[700,0.025,10.149,2.625,14865.837],[700,0.025,10.149,2.625,14866.887],[700,0.025,10.149,2.625,14867.937],[700,0.025,10.149,2.625,14868.987],[700,0.025,10.149,2.625,14870.037],[700,0.025,10.149,2.625,14871.087],[700,0.025,10.149,2.625,14872.137],[700,0.025,10.149,2.625,14873.187],[700,0.025,10.149,2.625,14874.237],[700,0.025,10.149,2.625,14875.287],[700,0.025,10.149,2.625,14876.337],[700,0.025,10.149,2.625,14877.387],[700,0.025,10.149,2.625,14878.437],[700,0.025,10.149,2.625,14879.487],[700,0.025,10.149,2.625,14880.537],[700,0.025,10.149,2.625,14881.587],[700,0.025,10.149,2.625,14882.637],[700,0.025,10.149,2.625,14883.687],[700,0.025,10.149,2.625,14884.737],[700,0.025,10.149,2.625,14885.787],[700,0.025,10.149,2.625,14886.837],[700,0.025,10.149,2.625,14887.887],[700,0.025,10.149,2.625,14888.937],[700,0.025,10.149,2.625,14889.987],[700,0.025,10.149,2.625,14891.037],[700,0.025,10.149,2.625,14892.087],[700,0.025,10.149,2.625,14893.137],[700,0.025,10.149,2.625,14894.187],[700,0.025,10.149,2.625,14895.237],[700,0.025,10.149,2.625,14896.287],[700,0.025,10.149,2.625,14897.337],[700,0.025,10.149,2.625,14898.387],[700,0.025,10.149,2.625,14899.437],[700,0.025,10.149,2.625,14900.487],[700,0.025,10.149,2.625,14901.537],[700,0.025,10.149,2.625,14902.587],[700,0.025,10.149,2.625,14903.637],[700,0.025,10.149,2.625,14904.687],[700,0.025,10.149,2.625,14905.737],[700,0.025,10.149,2.625,14906.787],[700,0.025,10.149,2.625,14907.837],[700,0.025,10.149,2.625,14908.887],[700,0.025,10.149,2.625,14909.937],[700,0.025,10.149,2.625,14910.987],[700,0.025,10.149,2.625,14912.037],[700,0.025,10.149,2.625,14913.087],[700,0.025,10.149,2.625,14914.137],[700,0.025,10.149,2.625,14915.187],[700,0.025,10.149,2.625,14916.237],[700,0.025,10.149,2.625,14917.287],[700,0.025,10.149,2.625,14918.337],[700,0.025,10.149,2.625,14919.387],[700,0.025,10.149,2.625,14920.437],[700,0.025,10.149,2.625,14921.487],[700,0.025,10.149,2.625,14922.537],[700,0.025,10.149,2.625,14923.587],[700,0.025,10.149,2.625,14924.637],[700,0.025,10.149,2.625,14925.687],[700,0.025,10.149,2.625,14926.737],[700,0.025,10.149,2.625,14927.787],[700,0.025,10.149,2.625,14928.837],[700,0.025,10.149,2.625,14929.887],[700,0.025,10.149,2.625,14930.937],[700,0.025,10.149,2.625,14931.987],[700,0.025,10.149,2.625,14933.037],[700,0.025,10.149,2.625,14934.087],[700,0.025,10.149,2.625,14935.137],[700,0.025,10.149,2.625,14936.187],[700,0.025,10.149,2.625,14937.237],[700,0.025,10.149,2.625,14938.287],[700,0.025,10.149,2.625,14939.337],[700,0.025,10.149,2.625,14940.387],[700,0.025,10.149,2.625,14941.437],[700,0.025,10.149,2.625,14942.487],[700,0.025,10.149,2.625,14943.537],[700,0.025,10.149,2.625,14944.587],[700,0.025,10.149,2.625,14945.637],[700,0.025,10.149,2.625,14946.687],[700,0.025,10.149,2.625,14947.737],[700,0.025,10.149,2.625,14948.787],[700,0.025,10.149,2.625,14949.837],[700,0.025,10.149,2.625,14950.887],[700,0.025,10.149,2.625,14951.937],[700,0.025,10.149,2.625,14952.987],[700,0.025,10.149,2.625,14954.037],[700,0.025,10.149,2.625,14955.087],[700,0.025,10.149,2.625,14956.137],[700,0.025,10.149,2.625,14957.187],[700,0.025,10.149,2.625,14958.237],[700,0.025,10.149,2.625,14959.287],[700,0.025,10.149,2.625,14960.337],[700,0.025,10.149,2.625,14961.387],[700,0.025,10.149,2.625,14962.437],[700,0.025,10.149,2.625,14963.487],[700,0.025,10.149,2.625,14964.537],[700,0.025,10.149,2.625,14965.587],[700,0.025,10.149,2.625,14966.637],[700,0.025,10.149,2.625,14967.687],[700,0.025,10.149,2.625,14968.737],[700,0.025,10.149,2.625,14969.787],[700,0.025,10.149,2.625,14970.837],[700,0.025,10.149,2.625,14971.887],[700,0.025,10.149,2.625,14972.937],[700,0.025,10.149,2.625,14973.987],[700,0.025,10.149,2.625,14975.037],[700,0.025,10.149,2.625,14976.087],[700,0.025,10.149,2.625,14977.137],[700,0.025,10.149,2.625,14978.187],[700,0.025,10.149,2.625,14979.237],[700,0.025,10.149,2.625,14980.287],[700,0.025,10.149,2.625,14981.337],[700,0.025,10.149,2.625,14982.387],[700,0.025,10.149,2.625,14983.437],[700,0.025,10.149,2.625,14984.487],[700,0.025,10.149,2.625,14985.537],[700,0.025,10.149,2.625,14986.587],[700,0.025,10.149,2.625,14987.637],[700,0.025,10.149,2.625,14988.687],[700,0.025,10.149,2.625,14989.737],[700,0.025,10.149,2.625,14990.787],[700,0.025,10.149,2.625,14991.837],[700,0.025,10.149,2.625,14992.887],[700,0.025,10.149,2.625,14993.937],[700,0.025,10.149,2.625,14994.987],[700,0.025,10.149,2.625,14996.037],[700,0.025,10.149,2.625,14997.087],[700,0.025,10.149,2.625,14998.137],[700,0.025,10.149,2.625,14999.187],[700,0.025,10.149,2.625,15000.237],[700,0.025,10.149,2.625,15001.287],[700,0.025,10.149,2.625,15002.337],[700,0.025,10.149,2.625,15003.387],[700,0.025,10.149,2.625,15004.437],[700,0.025,10.149,2.625,15005.487],[700,0.025,10.149,2.625,15006.537],[700,0.025,10.149,2.625,15007.587],[700,0.025,10.149,2.625,15008.637],[700,0.025,10.149,2.625,15009.687],[700,0.025,10.149,2.625,15010.737],[700,0.025,10.149,2.625,15011.787],[700,0.025,10.149,2.625,15012.837],[700,0.025,10.149,2.625,15013.887],[700,0.025,10.149,2.625,15014.937],[700,0.025,10.149,2.625,15015.987],[700,0.025,10.149,2.625,15017.037],[700,0.025,10.149,2.625,15018.087],[700,0.025,10.149,2.625,15019.137],[700,0.025,10.149,2.625,15020.187],[700,0.025,10.149,2.625,15021.237],[700,0.025,10.149,2.625,15022.287],[700,0.025,10.149,2.625,15023.337],[700,0.025,10.149,2.625,15024.387],[700,0.025,10.149,2.625,15025.437],[700,0.025,10.149,2.625,15026.487],[700,0.025,10.149,2.625,15027.537],[700,0.025,10.149,2.625,15028.587],[700,0.025,10.149,2.625,15029.637],[700,0.025,10.149,2.625,15030.687],[700,0.025,10.149,2.625,15031.737],[700,0.025,10.149,2.625,15032.787],[700,0.025,10.149,2.625,15033.837],[700,0.025,10.149,2.625,15034.887],[700,0.025,10.149,2.625,15035.937],[700,0.025,10.149,2.625,15036.987],[700,0.025,10.149,2.625,15038.037],[700,0.025,10.149,2.625,15039.087],[700,0.025,10.149,2.625,15040.137],[700,0.025,10.149,2.625,15041.187],[700,0.025,10.149,2.625,15042.237],[700,0.025,10.149,2.625,15043.287],[700,0.025,10.149,2.625,15044.337],[700,0.025,10.149,2.625,15045.387],[700,0.025,10.149,2.625,15046.437],[700,0.025,10.149,2.625,15047.487],[700,0.025,10.149,2.625,15048.537],[700,0.025,10.149,2.625,15049.587],[700,0.025,10.149,2.625,15050.637],[700,0.025,10.149,2.625,15051.687],[700,0.025,10.149,2.625,15052.737],[700,0.025,10.149,2.625,15053.787],[700,0.025,10.149,2.625,15054.837],[700,0.025,10.149,2.625,15055.887],[700,0.025,10.149,2.625,15056.937],[700,0.025,10.149,2.625,15057.987],[700,0.025,10.149,2.625,15059.037],[700,0.025,10.149,2.625,15060.087],[700,0.025,10.149,2.625,15061.137],[700,0.025,10.149,2.625,15062.187],[700,0.025,10.149,2.625,15063.237],[700,0.025,10.149,2.625,15064.287],[700,0.025,10.149,2.625,15065.337],[700,0.025,10.149,2.625,15066.387],[700,0.025,10.149,2.625,15067.437],[700,0.025,10.149,2.625,15068.487],[700,0.025,10.149,2.625,15069.537],[700,0.025,10.149,2.625,15070.587],[700,0.025,10.149,2.625,15071.637],[700,0.025,10.149,2.625,15072.687],[700,0.025,10.149,2.625,15073.737],[700,0.025,10.149,2.625,15074.787],[700,0.025,10.149,2.625,15075.837],[700,0.025,10.149,2.625,15076.887],[700,0.025,10.149,2.625,15077.937],[700,0.025,10.149,2.625,15078.987],[700,0.025,10.149,2.625,15080.037],[700,0.025,10.149,2.625,15081.087],[700,0.025,10.149,2.625,15082.137],[700,0.025,10.149,2.625,15083.187],[700,0.025,10.149,2.625,15084.237],[700,0.025,10.149,2.625,15085.287],[700,0.025,10.149,2.625,15086.337],[700,0.025,10.149,2.625,15087.387],[700,0.025,10.149,2.625,15088.437],[700,0.025,10.149,2.625,15089.487],[700,0.025,10.149,2.625,15090.537],[700,0.025,10.149,2.625,15091.587],[700,0.025,10.149,2.625,15092.637],[700,0.025,10.149,2.625,15093.687],[700,0.025,10.149,2.625,15094.737],[700,0.025,10.149,2.625,15095.787],[700,0.025,10.149,2.625,15096.837],[700,0.025,10.149,2.625,15097.887],[700,0.025,10.149,2.625,15098.937],[700,0.025,10.149,2.625,15099.987],[700,0.025,10.149,2.625,15101.037],[700,0.025,10.149,2.625,15102.087],[700,0.025,10.149,2.625,15103.137],[700,0.025,10.149,2.625,15104.187],[700,0.025,10.149,2.625,15105.237],[700,0.025,10.149,2.625,15106.287],[700,0.025,10.149,2.625,15107.337],[700,0.025,10.149,2.625,15108.387],[700,0.025,10.149,2.625,15109.437],[700,0.025,10.149,2.625,15110.487],[700,0.025,10.149,2.625,15111.537],[700,0.025,10.149,2.625,15112.587],[700,0.025,10.149,2.625,15113.637],[700,0.025,10.149,2.625,15114.687],[700,0.025,10.149,2.625,15115.737],[700,0.025,10.149,2.625,15116.787],[700,0.025,10.149,2.625,15117.837],[700,0.025,10.149,2.625,15118.887],[700,0.025,10.149,2.625,15119.937],[700,0.025,10.149,2.625,15120.987],[700,0.025,10.149,2.625,15122.037],[700,0.025,10.149,2.625,15123.087],[700,0.025,10.149,2.625,15124.137],[700,0.025,10.149,2.625,15125.187],[700,0.025,10.149,2.625,15126.237],[700,0.025,10.149,2.625,15127.287],[700,0.025,10.149,2.625,15128.337],[700,0.025,10.149,2.625,15129.387],[700,0.025,10.149,2.625,15130.437],[700,0.025,10.149,2.625,15131.487],[700,0.025,10.149,2.625,15132.537],[700,0.025,10.149,2.625,15133.587],[700,0.025,10.149,2.625,15134.637],[700,0.025,10.149,2.625,15135.687],[700,0.025,10.149,2.625,15136.737],[700,0.025,10.149,2.625,15137.787],[700,0.025,10.149,2.625,15138.837],[700,0.025,10.149,2.625,15139.887],[700,0.025,10.149,2.625,15140.937],[700,0.025,10.149,2.625,15141.987],[700,0.025,10.149,2.625,15143.037],[700,0.025,10.149,2.625,15144.087],[700,0.025,10.149,2.625,15145.137],[700,0.025,10.149,2.625,15146.187],[700,0.025,10.149,2.625,15147.237],[700,0.025,10.149,2.625,15148.287],[700,0.025,10.149,2.625,15149.337],[700,0.025,10.149,2.625,15150.387],[700,0.025,10.149,2.625,15151.437],[700,0.025,10.149,2.625,15152.487],[700,0.025,10.149,2.625,15153.537],[700,0.025,10.149,2.625,15154.587],[700,0.025,10.149,2.625,15155.637],[700,0.025,10.149,2.625,15156.687],[700,0.025,10.149,2.625,15157.737],[700,0.025,10.149,2.625,15158.787],[700,0.025,10.149,2.625,15159.837],[700,0.025,10.149,2.625,15160.887],[700,0.025,10.149,2.625,15161.937],[700,0.025,10.149,2.625,15162.987],[700,0.025,10.149,2.625,15164.037],[700,0.025,10.149,2.625,15165.087],[700,0.025,10.149,2.625,15166.137],[700,0.025,10.149,2.625,15167.187],[700,0.025,10.149,2.625,15168.237],[700,0.025,10.149,2.625,15169.287],[700,0.025,10.149,2.625,15170.337],[700,0.025,10.149,2.625,15171.387],[700,0.025,10.149,2.625,15172.437],[700,0.025,10.149,2.625,15173.487],[700,0.025,10.149,2.625,15174.537],[700,0.025,10.149,2.625,15175.587],[700,0.025,10.149,2.625,15176.637],[700,0.025,10.149,2.625,15177.687],[700,0.025,10.149,2.625,15178.737],[700,0.025,10.149,2.625,15179.787],[700,0.025,10.149,2.625,15180.837],[700,0.025,10.149,2.625,15181.887],[700,0.025,10.149,2.625,15182.937],[700,0.025,10.149,2.625,15183.987],[700,0.025,10.149,2.625,15185.037],[700,0.025,10.149,2.625,15186.087],[700,0.025,10.149,2.625,15187.137],[700,0.025,10.149,2.625,15188.187],[700,0.025,10.149,2.625,15189.237],[700,0.025,10.149,2.625,15190.287],[700,0.025,10.149,2.625,15191.337],[700,0.025,10.149,2.625,15192.387],[700,0.025,10.149,2.625,15193.437],[700,0.025,10.149,2.625,15194.487],[700,0.025,10.149,2.625,15195.537],[700,0.025,10.149,2.625,15196.587],[700,0.025,10.149,2.625,15197.637],[700,0.025,10.149,2.625,15198.687],[700,0.025,10.149,2.625,15199.737],[700,0.025,10.149,2.625,15200.787],[700,0.025,10.149,2.625,15201.837],[700,0.025,10.149,2.625,15202.887],[700,0.025,10.149,2.625,15203.937],[700,0.025,10.149,2.625,15204.987],[700,0.025,10.149,2.625,15206.037],[700,0.025,10.149,2.625,15207.087],[700,0.025,10.149,2.625,15208.137],[700,0.025,10.149,2.625,15209.187],[700,0.025,10.149,2.625,15210.237],[700,0.025,10.149,2.625,15211.287],[700,0.025,10.149,2.625,15212.337],[700,0.025,10.149,2.625,15213.387],[700,0.025,10.149,2.625,15214.437],[700,0.025,10.149,2.625,15215.487],[700,0.025,10.149,2.625,15216.537],[700,0.025,10.149,2.625,15217.587],[700,0.025,10.149,2.625,15218.637],[700,0.025,10.149,2.625,15219.687],[700,0.025,10.149,2.625,15220.737],[700,0.025,10.149,2.625,15221.787],[700,0.025,10.149,2.625,15222.837],[700,0.025,10.149,2.625,15223.887],[700,0.025,10.149,2.625,15224.937],[700,0.025,10.149,2.625,15225.987],[700,0.025,10.149,2.625,15227.037],[700,0.025,10.149,2.625,15228.087],[700,0.025,10.149,2.625,15229.137],[700,0.025,10.149,2.625,15230.187],[700,0.025,10.149,2.625,15231.237],[700,0.025,10.149,2.625,15232.287],[700,0.025,10.149,2.625,15233.337],[700,0.025,10.149,2.625,15234.387],[700,0.025,10.149,2.625,15235.437],[700,0.025,10.149,2.625,15236.487],[700,0.025,10.149,2.625,15237.537],[700,0.025,10.149,2.625,15238.587],[700,0.025,10.149,2.625,15239.637],[700,0.025,10.149,2.625,15240.687],[700,0.025,10.149,2.625,15241.737],[700,0.025,10.149,2.625,15242.787],[700,0.025,10.149,2.625,15243.837],[700,0.025,10.149,2.625,15244.887],[700,0.025,10.149,2.625,15245.937],[700,0.025,10.149,2.625,15246.987],[700,0.025,10.149,2.625,15248.037],[700,0.025,10.149,2.625,15249.087],[700,0.025,10.149,2.625,15250.137],[700,0.025,10.149,2.625,15251.187],[700,0.025,10.149,2.625,15252.237],[700,0.025,10.149,2.625,15253.287],[700,0.025,10.149,2.625,15254.337],[700,0.025,10.149,2.625,15255.387],[700,0.025,10.149,2.625,15256.437],[700,0.025,10.149,2.625,15257.487],[700,0.025,10.149,2.625,15258.537],[700,0.025,10.149,2.625,15259.587],[700,0.025,10.149,2.625,15260.637],[700,0.025,10.149,2.625,15261.687],[700,0.025,10.149,2.625,15262.737],[700,0.025,10.149,2.625,15263.787],[700,0.025,10.149,2.625,15264.837],[700,0.025,10.149,2.625,15265.887],[700,0.025,10.149,2.625,15266.937],[700,0.025,10.149,2.625,15267.987],[700,0.025,10.149,2.625,15269.037],[700,0.025,10.149,2.625,15270.087],[700,0.025,10.149,2.625,15271.137],[700,0.025,10.149,2.625,15272.187],[700,0.025,10.149,2.625,15273.237],[700,0.025,10.149,2.625,15274.287],[700,0.025,10.149,2.625,15275.337],[700,0.025,10.149,2.625,15276.387],[700,0.025,10.149,2.625,15277.437],[700,0.025,10.149,2.625,15278.487],[700,0.025,10.149,2.625,15279.537],[700,0.025,10.149,2.625,15280.587],[700,0.025,10.149,2.625,15281.637],[700,0.025,10.149,2.625,15282.687],[700,0.025,10.149,2.625,15283.737],[700,0.025,10.149,2.625,15284.787],[700,0.025,10.149,2.625,15285.837],[700,0.025,10.149,2.625,15286.887],[700,0.025,10.149,2.625,15287.937],[700,0.025,10.149,2.625,15288.987],[700,0.025,10.149,2.625,15290.037],[700,0.025,10.149,2.625,15291.087],[700,0.025,10.149,2.625,15292.137],[700,0.025,10.149,2.625,15293.187],[700,0.025,10.149,2.625,15294.237],[700,0.025,10.149,2.625,15295.287],[700,0.025,10.149,2.625,15296.337],[700,0.025,10.149,2.625,15297.387],[700,0.025,10.149,2.625,15298.437],[700,0.025,10.149,2.625,15299.487],[700,0.025,10.149,2.625,15300.537],[700,0.025,10.149,2.625,15301.587],[700,0.025,10.149,2.625,15302.637],[700,0.025,10.149,2.625,15303.687],[700,0.025,10.149,2.625,15304.737],[700,0.025,10.149,2.625,15305.787],[700,0.025,10.149,2.625,15306.837],[700,0.025,10.149,2.625,15307.887],[700,0.025,10.149,2.625,15308.937],[700,0.025,10.149,2.625,15309.987],[700,0.025,10.149,2.625,15311.037],[700,0.025,10.149,2.625,15312.087],[700,0.025,10.149,2.625,15313.137],[700,0.025,10.149,2.625,15314.187],[700,0.025,10.149,2.625,15315.237],[700,0.025,10.149,2.625,15316.287],[700,0.025,10.149,2.625,15317.337],[700,0.025,10.149,2.625,15318.387],[700,0.025,10.149,2.625,15319.437],[700,0.025,10.149,2.625,15320.487],[700,0.025,10.149,2.625,15321.537],[700,0.025,10.149,2.625,15322.587],[700,0.025,10.149,2.625,15323.637],[700,0.025,10.149,2.625,15324.687],[700,0.025,10.149,2.625,15325.737],[700,0.025,10.149,2.625,15326.787],[700,0.025,10.149,2.625,15327.837],[700,0.025,10.149,2.625,15328.887],[700,0.025,10.149,2.625,15329.937],[700,0.025,10.149,2.625,15330.987],[700,0.025,10.149,2.625,15332.037],[700,0.025,10.149,2.625,15333.087],[700,0.025,10.149,2.625,15334.137],[700,0.025,10.149,2.625,15335.187],[700,0.025,10.149,2.625,15336.237],[700,0.025,10.149,2.625,15337.287],[700,0.025,10.149,2.625,15338.337],[700,0.025,10.149,2.625,15339.387],[700,0.025,10.149,2.625,15340.437],[700,0.025,10.149,2.625,15341.487],[700,0.025,10.149,2.625,15342.537],[700,0.025,10.149,2.625,15343.587],[700,0.025,10.149,2.625,15344.637],[700,0.025,10.149,2.625,15345.687],[700,0.025,10.149,2.625,15346.737],[700,0.025,10.149,2.625,15347.787],[700,0.025,10.149,2.625,15348.837],[700,0.025,10.149,2.625,15349.887],[700,0.025,10.149,2.625,15350.937],[700,0.025,10.149,2.625,15351.987],[700,0.025,10.149,2.625,15353.037],[700,0.025,10.149,2.625,15354.087],[700,0.025,10.149,2.625,15355.137],[700,0.025,10.149,2.625,15356.187],[700,0.025,10.149,2.625,15357.237],[700,0.025,10.149,2.625,15358.287],[700,0.025,10.149,2.625,15359.337],[700,0.025,10.149,2.625,15360.387],[700,0.025,10.149,2.625,15361.437],[700,0.025,10.149,2.625,15362.487],[700,0.025,10.149,2.625,15363.537],[700,0.025,10.149,2.625,15364.587],[700,0.025,10.149,2.625,15365.637],[700,0.025,10.149,2.625,15366.687],[700,0.025,10.149,2.625,15367.737],[700,0.025,10.149,2.625,15368.787],[700,0.025,10.149,2.625,15369.837],[700,0.025,10.149,2.625,15370.887],[700,0.025,10.149,2.625,15371.937],[700,0.025,10.149,2.625,15372.987],[700,0.025,10.149,2.625,15374.037],[700,0.025,10.149,2.625,15375.087],[700,0.025,10.149,2.625,15376.137],[700,0.025,10.149,2.625,15377.187],[700,0.025,10.149,2.625,15378.237],[700,0.025,10.149,2.625,15379.287],[700,0.025,10.149,2.625,15380.337],[700,0.025,10.149,2.625,15381.387],[700,0.025,10.149,2.625,15382.437],[700,0.025,10.149,2.625,15383.487],[700,0.025,10.149,2.625,15384.537],[700,0.025,10.149,2.625,15385.587],[700,0.025,10.149,2.625,15386.637],[700,0.025,10.149,2.625,15387.687],[700,0.025,10.149,2.625,15388.737],[700,0.025,10.149,2.625,15389.787],[700,0.025,10.149,2.625,15390.837],[700,0.025,10.149,2.625,15391.887],[700,0.025,10.149,2.625,15392.937],[700,0.025,10.149,2.625,15393.987],[700,0.025,10.149,2.625,15395.037],[700,0.025,10.149,2.625,15396.087],[700,0.025,10.149,2.625,15397.137],[700,0.025,10.149,2.625,15398.187],[700,0.025,10.149,2.625,15399.237],[700,0.025,10.149,2.625,15400.287],[700,0.025,10.149,2.625,15401.337],[700,0.025,10.149,2.625,15402.387],[700,0.025,10.149,2.625,15403.437],[700,0.025,10.149,2.625,15404.487],[700,0.025,10.149,2.625,15405.537],[700,0.025,10.149,2.625,15406.587],[700,0.025,10.149,2.625,15407.637],[700,0.025,10.149,2.625,15408.687],[700,0.025,10.149,2.625,15409.737],[700,0.025,10.149,2.625,15410.787],[700,0.025,10.149,2.625,15411.837],[700,0.025,10.149,2.625,15412.887],[700,0.025,10.149,2.625,15413.937],[700,0.025,10.149,2.625,15414.987],[700,0.025,10.149,2.625,15416.037],[700,0.025,10.149,2.625,15417.087],[700,0.025,10.149,2.625,15418.137],[700,0.025,10.149,2.625,15419.187],[700,0.025,10.149,2.625,15420.237],[700,0.025,10.149,2.625,15421.287],[700,0.025,10.149,2.625,15422.337],[700,0.025,10.149,2.625,15423.387],[700,0.025,10.149,2.625,15424.437],[700,0.025,10.149,2.625,15425.487],[700,0.025,10.149,2.625,15426.537],[700,0.025,10.149,2.625,15427.587],[700,0.025,10.149,2.625,15428.637],[700,0.025,10.149,2.625,15429.687],[700,0.025,10.149,2.625,15430.737],[700,0.025,10.149,2.625,15431.787],[700,0.025,10.149,2.625,15432.837],[700,0.025,10.149,2.625,15433.887],[700,0.025,10.149,2.625,15434.937],[700,0.025,10.149,2.625,15435.987],[700,0.025,10.149,2.625,15437.037],[700,0.025,10.149,2.625,15438.087],[700,0.025,10.149,2.625,15439.137],[700,0.025,10.149,2.625,15440.187],[700,0.025,10.149,2.625,15441.237],[700,0.025,10.149,2.625,15442.287],[700,0.025,10.149,2.625,15443.337],[700,0.025,10.149,2.625,15444.387],[700,0.025,10.149,2.625,15445.437],[700,0.025,10.149,2.625,15446.487],[700,0.025,10.149,2.625,15447.537],[700,0.025,10.149,2.625,15448.587],[700,0.025,10.149,2.625,15449.637],[700,0.025,10.149,2.625,15450.687],[700,0.025,10.149,2.625,15451.737],[700,0.025,10.149,2.625,15452.787],[700,0.025,10.149,2.625,15453.837],[700,0.025,10.149,2.625,15454.887],[700,0.025,10.149,2.625,15455.937],[700,0.025,10.149,2.625,15456.987],[700,0.025,10.149,2.625,15458.037],[700,0.025,10.149,2.625,15459.087],[700,0.025,10.149,2.625,15460.137],[700,0.025,10.149,2.625,15461.187],[700,0.025,10.149,2.625,15462.237],[700,0.025,10.149,2.625,15463.287],[700,0.025,10.149,2.625,15464.337],[700,0.025,10.149,2.625,15465.387],[700,0.025,10.149,2.625,15466.437],[700,0.025,10.149,2.625,15467.487],[700,0.025,10.149,2.625,15468.537],[700,0.025,10.149,2.625,15469.587],[700,0.025,10.149,2.625,15470.637],[700,0.025,10.149,2.625,15471.687],[700,0.025,10.149,2.625,15472.737],[700,0.025,10.149,2.625,15473.787],[700,0.025,10.149,2.625,15474.837],[700,0.025,10.149,2.625,15475.887],[700,0.025,10.149,2.625,15476.937],[700,0.025,10.149,2.625,15477.987],[700,0.025,10.149,2.625,15479.037],[700,0.025,10.149,2.625,15480.087],[700,0.025,10.149,2.625,15481.137],[700,0.025,10.149,2.625,15482.187],[700,0.025,10.149,2.625,15483.237],[700,0.025,10.149,2.625,15484.287],[700,0.025,10.149,2.625,15485.337],[700,0.025,10.149,2.625,15486.387],[700,0.025,10.149,2.625,15487.437],[700,0.025,10.149,2.625,15488.487],[700,0.025,10.149,2.625,15489.537],[700,0.025,10.149,2.625,15490.587],[700,0.025,10.149,2.625,15491.637],[700,0.025,10.149,2.625,15492.687],[700,0.025,10.149,2.625,15493.737],[700,0.025,10.149,2.625,15494.787],[700,0.025,10.149,2.625,15495.837],[700,0.025,10.149,2.625,15496.887],[700,0.025,10.149,2.625,15497.937],[700,0.025,10.149,2.625,15498.987],[700,0.025,10.149,2.625,15500.037],[700,0.025,10.149,2.625,15501.087],[700,0.025,10.149,2.625,15502.137],[700,0.025,10.149,2.625,15503.187],[700,0.025,10.149,2.625,15504.237],[700,0.025,10.149,2.625,15505.287],[700,0.025,10.149,2.625,15506.337],[700,0.025,10.149,2.625,15507.387],[700,0.025,10.149,2.625,15508.437],[700,0.025,10.149,2.625,15509.487],[700,0.025,10.149,2.625,15510.537],[700,0.025,10.149,2.625,15511.587],[700,0.025,10.149,2.625,15512.637],[700,0.025,10.149,2.625,15513.687],[700,0.025,10.149,2.625,15514.737],[700,0.025,10.149,2.625,15515.787],[700,0.025,10.149,2.625,15516.837],[700,0.025,10.149,2.625,15517.887],[700,0.025,10.149,2.625,15518.937],[700,0.025,10.149,2.625,15519.987],[700,0.025,10.149,2.625,15521.037],[700,0.025,10.149,2.625,15522.087],[700,0.025,10.149,2.625,15523.137],[700,0.025,10.149,2.625,15524.187],[700,0.025,10.149,2.625,15525.237],[700,0.025,10.149,2.625,15526.287],[700,0.025,10.149,2.625,15527.337],[700,0.025,10.149,2.625,15528.387],[700,0.025,10.149,2.625,15529.437],[700,0.025,10.149,2.625,15530.487],[700,0.025,10.149,2.625,15531.537],[700,0.025,10.149,2.625,15532.587],[700,0.025,10.149,2.625,15533.637],[700,0.025,10.149,2.625,15534.687],[700,0.025,10.149,2.625,15535.737],[700,0.025,10.149,2.625,15536.787],[700,0.025,10.149,2.625,15537.837],[700,0.025,10.149,2.625,15538.887],[700,0.025,10.149,2.625,15539.937],[700,0.025,10.149,2.625,15540.987],[700,0.025,10.149,2.625,15542.037],[700,0.025,10.149,2.625,15543.087],[700,0.025,10.149,2.625,15544.137],[700,0.025,10.149,2.625,15545.187],[700,0.025,10.149,2.625,15546.237],[700,0.025,10.149,2.625,15547.287],[700,0.025,10.149,2.625,15548.337],[700,0.025,10.149,2.625,15549.387],[700,0.025,10.149,2.625,15550.437],[700,0.025,10.149,2.625,15551.487],[700,0.025,10.149,2.625,15552.537],[700,0.025,10.149,2.625,15553.587],[700,0.025,10.149,2.625,15554.637],[700,0.025,10.149,2.625,15555.687],[700,0.025,10.149,2.625,15556.737],[700,0.025,10.149,2.625,15557.787],[700,0.025,10.149,2.625,15558.837],[700,0.025,10.149,2.625,15559.887],[700,0.025,10.149,2.625,15560.937],[700,0.025,10.149,2.625,15561.987],[700,0.025,10.149,2.625,15563.037],[700,0.025,10.149,2.625,15564.087],[700,0.025,10.149,2.625,15565.137],[700,0.025,10.149,2.625,15566.187],[700,0.025,10.149,2.625,15567.237],[700,0.025,10.149,2.625,15568.287],[700,0.025,10.149,2.625,15569.337],[700,0.025,10.149,2.625,15570.387],[700,0.025,10.149,2.625,15571.437],[700,0.025,10.149,2.625,15572.487],[700,0.025,10.149,2.625,15573.537],[700,0.025,10.149,2.625,15574.587],[700,0.025,10.149,2.625,15575.637],[700,0.025,10.149,2.625,15576.687],[700,0.025,10.149,2.625,15577.737],[700,0.025,10.149,2.625,15578.787],[700,0.025,10.149,2.625,15579.837],[700,0.025,10.149,2.625,15580.887],[700,0.025,10.149,2.625,15581.937],[700,0.025,10.149,2.625,15582.987],[700,0.025,10.149,2.625,15584.037],[700,0.025,10.149,2.625,15585.087],[700,0.025,10.149,2.625,15586.137],[700,0.025,10.149,2.625,15587.187],[700,0.025,10.149,2.625,15588.237],[700,0.025,10.149,2.625,15589.287],[700,0.025,10.149,2.625,15590.337],[700,0.025,10.149,2.625,15591.387],[700,0.025,10.149,2.625,15592.437],[700,0.025,10.149,2.625,15593.487],[700,0.025,10.149,2.625,15594.537],[700,0.025,10.149,2.625,15595.587],[700,0.025,10.149,2.625,15596.637],[700,0.025,10.149,2.625,15597.687],[700,0.025,10.149,2.625,15598.737],[700,0.025,10.149,2.625,15599.787],[700,0.025,10.149,2.625,15600.837],[700,0.025,10.149,2.625,15601.887],[700,0.025,10.149,2.625,15602.937],[700,0.025,10.149,2.625,15603.987],[700,0.025,10.149,2.625,15605.037],[700,0.025,10.149,2.625,15606.087],[700,0.025,10.149,2.625,15607.137],[700,0.025,10.149,2.625,15608.187],[700,0.025,10.149,2.625,15609.237],[700,0.025,10.149,2.625,15610.287],[700,0.025,10.149,2.625,15611.337],[700,0.025,10.149,2.625,15612.387],[700,0.025,10.149,2.625,15613.437],[700,0.025,10.149,2.625,15614.487],[700,0.025,10.149,2.625,15615.537],[700,0.025,10.149,2.625,15616.587],[700,0.025,10.149,2.625,15617.637],[700,0.025,10.149,2.625,15618.687],[700,0.025,10.149,2.625,15619.737],[700,0.025,10.149,2.625,15620.787],[700,0.025,10.149,2.625,15621.837],[700,0.025,10.149,2.625,15622.887],[700,0.025,10.149,2.625,15623.937],[700,0.025,10.149,2.625,15624.987],[700,0.025,10.149,2.625,15626.037],[700,0.025,10.149,2.625,15627.087],[700,0.025,10.149,2.625,15628.137],[700,0.025,10.149,2.625,15629.187],[700,0.025,10.149,2.625,15630.237],[700,0.025,10.149,2.625,15631.287],[700,0.025,10.149,2.625,15632.337],[700,0.025,10.149,2.625,15633.387],[700,0.025,10.149,2.625,15634.437],[700,0.025,10.149,2.625,15635.487],[700,0.025,10.149,2.625,15636.537],[700,0.025,10.149,2.625,15637.587],[700,0.025,10.149,2.625,15638.637],[700,0.025,10.149,2.625,15639.687],[700,0.025,10.149,2.625,15640.737],[700,0.025,10.149,2.625,15641.787],[700,0.025,10.149,2.625,15642.837],[700,0.025,10.149,2.625,15643.887],[700,0.025,10.149,2.625,15644.937],[700,0.025,10.149,2.625,15645.987],[700,0.025,10.149,2.625,15647.037],[700,0.025,10.149,2.625,15648.087],[700,0.025,10.149,2.625,15649.137],[700,0.025,10.149,2.625,15650.187],[700,0.025,10.149,2.625,15651.237],[700,0.025,10.149,2.625,15652.287],[700,0.025,10.149,2.625,15653.337],[700,0.025,10.149,2.625,15654.387],[700,0.025,10.149,2.625,15655.437],[700,0.025,10.149,2.625,15656.487],[700,0.025,10.149,2.625,15657.537],[700,0.025,10.149,2.625,15658.587],[700,0.025,10.149,2.625,15659.637],[700,0.025,10.149,2.625,15660.687],[700,0.025,10.149,2.625,15661.737],[700,0.025,10.149,2.625,15662.787],[700,0.025,10.149,2.626,15663.837],[700,0.025,10.149,2.628,15664.888],[700,0.025,10.149,2.631,15665.94],[700,0.025,10.149,2.635,15666.993],[700,0.025,10.149,2.641,15668.049],[700,0.025,10.149,2.649,15669.107],[700,0.025,10.149,2.658,15670.168],[700,0.025,10.149,2.67,15671.233],[700,0.026,10.149,2.683,15672.304],[700,0.026,10.149,2.699,15673.38],[700,0.026,10.149,2.718,15674.464],[700,0.026,10.149,2.739,15675.555],[700,0.026,10.149,2.763,15676.655],[700,0.027,10.149,2.79,15677.766],[700,0.027,10.149,2.819,15678.887],[700,0.027,10.149,2.852,15680.021],[700,0.027,10.149,2.887,15681.169],[700,0.028,10.149,2.926,15682.332],[700,0.028,10.149,2.968,15683.511],[700,0.029,10.149,3.014,15684.707],[700,0.029,10.149,3.063,15685.922],[700,0.03,10.149,3.115,15687.158],[700,0.03,10.149,3.171,15688.415],[700,0.031,10.149,3.23,15689.695],[700,0.031,10.149,3.293,15691],[700,0.032,10.149,3.36,15692.33],[700,0.033,10.149,3.43,15693.688],[700,0.033,10.149,3.504,15695.075],[700,0.034,10.149,3.582,15696.492],[700,0.035,10.149,3.663,15697.941],[700,0.036,10.149,3.748,15699.423],[700,0.037,10.149,3.836,15700.94],[700,0.037,10.149,3.928,15702.493],[700,0.038,10.149,4.024,15704.083],[700,0.039,10.149,4.122,15705.712],[700,0.04,10.149,4.224,15707.382],[700,0.041,10.149,4.329,15709.092],[700,0.042,10.149,4.437,15710.845],[700,0.043,10.149,4.548,15712.642],[700,0.044,10.149,4.66,15714.484],[700,0.045,10.149,4.776,15716.371],[700,0.047,10.149,4.893,15718.305],[700,0.048,10.149,5.012,15720.285],[700,0.049,10.149,5.132,15722.314],[700,0.05,10.149,5.253,15724.391],[700,0.051,10.149,5.374,15726.516],[700,0.052,10.149,5.496,15728.69],[700,0.054,10.149,5.618,15730.913],[700,0.055,10.149,5.739,15733.185],[700,0.056,10.149,5.859,15735.504],[700,0.057,10.149,5.978,15737.872],[700,0.058,10.149,6.094,15740.286],[700,0.059,10.149,6.209,15742.747],[700,0.06,10.149,6.32,15745.253],[700,0.061,10.149,6.428,15747.803],[700,0.062,10.149,6.532,15750.395],[700,0.063,10.149,6.632,15753.028],[700,0.064,10.149,6.728,15755.7],[700,0.065,10.149,6.818,15758.409],[700,0.066,10.149,6.904,15761.154],[700,0.067,10.149,6.983,15763.931],[700,0.067,10.149,7.058,15766.74],[700,0.068,10.149,7.126,15769.577],[700,0.068,10.149,7.188,15772.439],[700,0.069,10.149,7.244,15775.326],[700,0.069,10.149,7.293,15778.234],[700,0.07,10.149,7.337,15781.16],[700,0.07,10.149,7.375,15784.102],[700,0.071,10.149,7.407,15787.059],[700,0.071,10.149,7.433,15790.027],[700,0.071,10.149,7.454,15793.004],[700,0.071,10.149,7.47,15795.989],[700,0.071,10.149,7.483,15798.98],[700,0.071,10.149,7.491,15801.975],[700,0.071,10.149,7.496,15804.972],[700,0.071,10.149,7.499,15807.972],[700,0.071,10.149,7.5,15810.971],[700,0.071,10.149,7.5,15813.971],[700,0.071,10.149,7.5,15816.971],[700,0.071,10.149,7.5,15819.971],[700,0.071,10.149,7.5,15822.971],[700,0.071,10.149,7.5,15825.971],[700,0.071,10.149,7.5,15828.971],[700,0.071,10.149,7.5,15831.971],[700,0.071,10.149,7.5,15834.971],[700,0.071,10.149,7.5,15837.971],[700,0.071,10.149,7.5,15840.971],[700,0.071,10.149,7.5,15843.971],[700,0.071,10.149,7.5,15846.971],[700,0.071,10.149,7.5,15849.971],[700,0.071,10.149,7.5,15852.971],[700,0.071,10.149,7.5,15855.971],[700,0.071,10.149,7.5,15858.971],[700,0.071,10.149,7.5,15861.971],[700,0.071,10.149,7.5,15864.971],[700,0.071,10.149,7.5,15867.971],[700,0.071,10.149,7.5,15870.971],[700,0.071,10.149,7.5,15873.971],[700,0.071,10.149,7.5,15876.971],[700,0.071,10.149,7.5,15879.971],[700,0.071,10.149,7.5,15882.971],[700,0.071,10.149,7.5,15885.971],[700,0.071,10.149,7.5,15888.971],[700,0.071,10.149,7.5,15891.971],[700,0.071,10.149,7.5,15894.971],[700,0.071,10.149,7.5,15897.971],[700,0.071,10.149,7.5,15900.971],[700,0.071,10.149,7.5,15903.971],[700,0.071,10.149,7.5,15906.971],[700,0.071,10.149,7.5,15909.971],[700,0.071,10.149,7.5,15912.971],[700,0.071,10.149,7.5,15915.971],[700,0.071,10.149,7.5,15918.971],[700,0.071,10.149,7.5,15921.971],[700,0.071,10.149,7.5,15924.971],[700,0.071,10.149,7.5,15927.971],[700,0.071,10.149,7.5,15930.971],[700,0.071,10.149,7.5,15933.971],[700,0.071,10.149,7.5,15936.971],[700,0.071,10.149,7.5,15939.971],[700,0.071,10.149,7.5,15942.971],[700,0.071,10.149,7.5,15945.971],[700,0.071,10.149,7.5,15948.971],[700,0.071,10.149,7.5,15951.971],[700,0.071,10.149,7.5,15954.971],[700,0.071,10.149,7.5,15957.971],[700,0.071,10.149,7.5,15960.971],[700,0.071,10.149,7.5,15963.971],[700,0.071,10.149,7.5,15966.971],[700,0.071,10.149,7.5,15969.971],[700,0.071,10.149,7.5,15972.971],[700,0.071,10.149,7.5,15975.971],[700,0.071,10.149,7.5,15978.971],[700,0.071,10.149,7.5,15981.971],[700,0.071,10.149,7.5,15984.971],[700,0.071,10.149,7.5,15987.971],[700,0.071,10.149,7.5,15990.971],[700,0.071,10.149,7.5,15993.971],[700,0.071,10.149,7.5,15996.971],[700,0.071,10.149,7.5,15999.971],[700,0.071,10.149,7.5,16002.971],[700,0.071,10.149,7.5,16005.971],[700,0.071,10.149,7.5,16008.971],[700,0.071,10.149,7.5,16011.971],[700,0.071,10.149,7.5,16014.971],[700,0.071,10.149,7.5,16017.971],[700,0.071,10.149,7.5,16020.971],[700,0.071,10.149,7.5,16023.971],[700,0.071,10.149,7.5,16026.971],[700,0.071,10.149,7.5,16029.971],[700,0.071,10.149,7.5,16032.971],[700,0.071,10.149,7.5,16035.971],[700,0.071,10.149,7.5,16038.971],[700,0.071,10.149,7.5,16041.971],[700,0.071,10.149,7.5,16044.971],[700,0.071,10.149,7.5,16047.971],[700,0.071,10.149,7.5,16050.971],[700,0.071,10.149,7.5,16053.971],[700,0.071,10.149,7.5,16056.971],[700,0.071,10.149,7.5,16059.971],[700,0.071,10.149,7.5,16062.971],[700,0.071,10.149,7.5,16065.971],[700,0.071,10.149,7.5,16068.971],[700,0.071,10.149,7.5,16071.971],[700,0.071,10.149,7.5,16074.971],[700,0.071,10.149,7.5,16077.971],[700,0.071,10.149,7.5,16080.971],[700,0.071,10.149,7.5,16083.971],[700,0.071,10.149,7.5,16086.971],[700,0.071,10.149,7.5,16089.971],[700,0.071,10.149,7.5,16092.971],[700,0.071,10.149,7.5,16095.971],[700,0.071,10.149,7.5,16098.971],[700,0.071,10.149,7.5,16101.971],[700,0.071,10.149,7.5,16104.971],[700,0.071,10.149,7.5,16107.971],[700,0.071,10.149,7.5,16110.971],[700,0.071,10.149,7.5,16113.971],[700,0.071,10.149,7.5,16116.971],[700,0.071,10.149,7.5,16119.971],[700,0.071,10.149,7.5,16122.971],[700,0.071,10.149,7.5,16125.971],[700,0.071,10.149,7.5,16128.971],[700,0.071,10.149,7.5,16131.971],[700,0.071,10.149,7.5,16134.971],[700,0.071,10.149,7.5,16137.971],[700,0.071,10.149,7.5,16140.971],[700,0.071,10.149,7.5,16143.971],[700,0.071,10.149,7.5,16146.971],[700,0.071,10.149,7.5,16149.971],[700,0.071,10.149,7.5,16152.971],[700,0.071,10.149,7.5,16155.971],[700,0.071,10.149,7.5,16158.971],[700,0.071,10.149,7.5,16161.971],[700,0.071,10.149,7.5,16164.971],[700,0.071,10.149,7.5,16167.971],[700,0.071,10.149,7.5,16170.971],[700,0.071,10.149,7.5,16173.971],[700,0.071,10.149,7.5,16176.971],[700,0.071,10.149,7.5,16179.971],[700,0.071,10.149,7.5,16182.971],[700,0.071,10.149,7.5,16185.971],[700,0.071,10.149,7.5,16188.971],[700,0.071,10.149,7.5,16191.971],[700,0.071,10.149,7.5,16194.971],[700,0.071,10.149,7.5,16197.971],[700,0.071,10.149,7.5,16200.971],[700,0.071,10.149,7.5,16203.971],[700,0.071,10.149,7.5,16206.971],[700,0.071,10.149,7.5,16209.971],[700,0.071,10.149,7.5,16212.971],[700,0.071,10.149,7.5,16215.971],[700,0.071,10.149,7.5,16218.971],[700,0.071,10.149,7.5,16221.971],[700,0.071,10.149,7.5,16224.971],[700,0.071,10.149,7.5,16227.971],[700,0.071,10.149,7.5,16230.971],[700,0.071,10.149,7.5,16233.971],[700,0.071,10.149,7.5,16236.971],[700,0.071,10.149,7.5,16239.971],[700,0.071,10.149,7.5,16242.971],[700,0.071,10.149,7.5,16245.971],[700,0.071,10.149,7.5,16248.971],[700,0.071,10.149,7.5,16251.971],[700,0.071,10.149,7.5,16254.971],[700,0.071,10.149,7.5,16257.971],[700,0.071,10.149,7.5,16260.971],[700,0.071,10.149,7.5,16263.971],[700,0.071,10.149,7.5,16266.971],[700,0.071,10.149,7.5,16269.971],[700,0.071,10.149,7.5,16272.971],[700,0.071,10.149,7.5,16275.971],[700,0.071,10.149,7.5,16278.971],[700,0.071,10.149,7.5,16281.971],[700,0.071,10.149,7.5,16284.971],[700,0.071,10.149,7.5,16287.971],[700,0.071,10.149,7.5,16290.971],[700,0.071,10.149,7.5,16293.971],[700,0.071,10.149,7.5,16296.971],[700,0.071,10.149,7.5,16299.971],[700,0.071,10.149,7.5,16302.971],[700,0.071,10.149,7.5,16305.971],[700,0.071,10.149,7.5,16308.971],[700,0.071,10.149,7.5,16311.971],[700,0.071,10.149,7.5,16314.971],[700,0.071,10.149,7.5,16317.971],[700,0.071,10.149,7.5,16320.971],[700,0.071,10.149,7.5,16323.971],[700,0.071,10.149,7.5,16326.971],[700,0.071,10.149,7.5,16329.971],[700,0.071,10.149,7.5,16332.971],[700,0.071,10.149,7.5,16335.971],[700,0.071,10.149,7.5,16338.971],[700,0.071,10.149,7.5,16341.971],[700,0.071,10.149,7.5,16344.971],[700,0.071,10.149,7.5,16347.971],[700,0.071,10.149,7.5,16350.971],[700,0.071,10.149,7.5,16353.971],[700,0.071,10.149,7.5,16356.971],[700,0.071,10.149,7.5,16359.971],[700,0.071,10.149,7.5,16362.971],[700,0.071,10.149,7.5,16365.971],[700,0.071,10.149,7.5,16368.971],[700,0.071,10.149,7.5,16371.971],[700,0.071,10.149,7.5,16374.971],[700,0.071,10.149,7.5,16377.971],[700,0.071,10.149,7.5,16380.971],[700,0.071,10.149,7.5,16383.971],[700,0.071,10.149,7.5,16386.971],[700,0.071,10.149,7.5,16389.971],[700,0.071,10.149,7.5,16392.971],[700,0.071,10.149,7.5,16395.971],[700,0.071,10.149,7.5,16398.971],[700,0.071,10.149,7.5,16401.971],[700,0.071,10.149,7.5,16404.971],[700,0.071,10.149,7.5,16407.971],[700,0.071,10.149,7.5,16410.971],[700,0.071,10.149,7.5,16413.971],[700,0.071,10.149,7.5,16416.971],[700,0.071,10.149,7.5,16419.971],[700,0.071,10.149,7.5,16422.971],[700,0.071,10.149,7.5,16425.971],[700,0.071,10.149,7.5,16428.971],[700,0.071,10.149,7.5,16431.971],[700,0.071,10.149,7.5,16434.971],[700,0.071,10.149,7.5,16437.971],[700,0.071,10.149,7.5,16440.971],[700,0.071,10.149,7.5,16443.971],[700,0.071,10.149,7.5,16446.971],[700,0.071,10.149,7.5,16449.971],[700,0.071,10.149,7.5,16452.971],[700,0.071,10.149,7.5,16455.971],[700,0.071,10.149,7.5,16458.971],[700,0.071,10.149,7.5,16461.971],[700,0.071,10.149,7.5,16464.971],[700,0.071,10.149,7.5,16467.971],[700,0.071,10.149,7.5,16470.971],[700,0.071,10.149,7.5,16473.971],[700,0.071,10.149,7.5,16476.971],[700,0.071,10.149,7.5,16479.971],[700,0.071,10.149,7.5,16482.971],[700,0.071,10.149,7.5,16485.971],[700,0.071,10.149,7.5,16488.971],[700,0.071,10.149,7.5,16491.971],[700,0.071,10.149,7.5,16494.971],[700,0.071,10.149,7.5,16497.971],[700,0.071,10.149,7.5,16500.971],[700,0.071,10.149,7.5,16503.971],[700,0.071,10.149,7.5,16506.971],[700,0.071,10.149,7.5,16509.971],[700,0.071,10.149,7.5,16512.971],[700,0.071,10.149,7.5,16515.971],[700,0.071,10.149,7.5,16518.971],[700,0.071,10.149,7.5,16521.971],[700,0.071,10.149,7.5,16524.971],[700,0.071,10.149,7.5,16527.971],[700,0.071,10.149,7.5,16530.971],[700,0.071,10.149,7.5,16533.971],[700,0.071,10.149,7.5,16536.971],[700,0.071,10.149,7.5,16539.971],[700,0.071,10.149,7.5,16542.971],[700,0.071,10.149,7.5,16545.971],[700,0.071,10.149,7.5,16548.971],[700,0.071,10.149,7.5,16551.971],[700,0.071,10.149,7.5,16554.971],[700,0.071,10.149,7.5,16557.971],[700,0.071,10.149,7.5,16560.971],[700,0.071,10.149,7.5,16563.971],[700,0.071,10.149,7.5,16566.971],[700,0.071,10.149,7.5,16569.971],[700,0.071,10.149,7.5,16572.971],[700,0.071,10.149,7.5,16575.971],[700,0.071,10.149,7.5,16578.971],[700,0.071,10.149,7.5,16581.971],[700,0.071,10.149,7.5,16584.971],[700,0.071,10.149,7.5,16587.971],[700,0.071,10.149,7.5,16590.971],[700,0.071,10.149,7.5,16593.971],[700,0.071,10.149,7.5,16596.971],[700,0.071,10.149,7.5,16599.971],[700,0.071,10.149,7.5,16602.971],[700,0.071,10.149,7.5,16605.971],[700,0.071,10.149,7.5,16608.971],[700,0.071,10.149,7.5,16611.971],[700,0.071,10.149,7.5,16614.971],[700,0.071,10.149,7.5,16617.971],[700,0.071,10.149,7.5,16620.971],[700,0.071,10.149,7.5,16623.971],[700,0.071,10.149,7.5,16626.971],[700,0.071,10.149,7.5,16629.971],[700,0.071,10.149,7.5,16632.971],[700,0.071,10.149,7.5,16635.971],[700,0.071,10.149,7.5,16638.971],[700,0.071,10.149,7.5,16641.971],[700,0.071,10.149,7.5,16644.971],[700,0.071,10.149,7.5,16647.971],[700,0.071,10.149,7.5,16650.971],[700,0.071,10.149,7.5,16653.971],[700,0.071,10.149,7.5,16656.971],[700,0.071,10.149,7.5,16659.971],[700,0.071,10.149,7.5,16662.971],[700,0.071,10.149,7.5,16665.971],[700,0.071,10.149,7.5,16668.971],[700,0.071,10.149,7.5,16671.971],[700,0.071,10.149,7.5,16674.971],[700,0.071,10.149,7.5,16677.971],[700,0.071,10.149,7.5,16680.971],[700,0.071,10.149,7.5,16683.971],[700,0.071,10.149,7.5,16686.971],[700,0.071,10.149,7.5,16689.971],[700,0.071,10.149,7.5,16692.971],[700,0.071,10.149,7.5,16695.971],[700,0.071,10.149,7.5,16698.971],[700,0.071,10.149,7.5,16701.971],[700,0.071,10.149,7.5,16704.971],[700,0.071,10.149,7.5,16707.971],[700,0.071,10.149,7.5,16710.971],[700,0.071,10.149,7.5,16713.971],[700,0.071,10.149,7.5,16716.971],[700,0.071,10.149,7.5,16719.971],[700,0.071,10.149,7.5,16722.971],[700,0.071,10.149,7.5,16725.971],[700,0.071,10.149,7.5,16728.971],[700,0.071,10.149,7.5,16731.971],[700,0.071,10.149,7.5,16734.971],[700,0.071,10.149,7.5,16737.971],[700,0.071,10.149,7.5,16740.971],[700,0.071,10.149,7.5,16743.971],[700,0.071,10.149,7.5,16746.971],[700,0.071,10.149,7.5,16749.971],[700,0.071,10.149,7.5,16752.971],[700,0.071,10.149,7.5,16755.971],[700,0.071,10.149,7.5,16758.971],[700,0.071,10.149,7.5,16761.971],[700,0.071,10.149,7.5,16764.971],[700,0.071,10.149,7.5,16767.971],[700,0.071,10.149,7.5,16770.971],[700,0.071,10.149,7.5,16773.971],[700,0.071,10.149,7.5,16776.971],[700,0.071,10.149,7.5,16779.971],[700,0.071,10.149,7.5,16782.971],[700,0.071,10.149,7.5,16785.971],[700,0.071,10.149,7.5,16788.971],[700,0.071,10.149,7.5,16791.971],[700,0.071,10.149,7.5,16794.971],[700,0.071,10.149,7.5,16797.971],[700,0.071,10.149,7.5,16800.971],[700,0.071,10.149,7.5,16803.971],[700,0.071,10.149,7.5,16806.971],[700,0.071,10.149,7.5,16809.971],[700,0.071,10.149,7.5,16812.971],[700,0.071,10.149,7.5,16815.971],[700,0.071,10.149,7.5,16818.971],[700,0.071,10.149,7.5,16821.971],[700,0.071,10.149,7.5,16824.971],[700,0.071,10.149,7.5,16827.971],[700,0.071,10.149,7.5,16830.971],[700,0.071,10.149,7.5,16833.971],[700,0.071,10.149,7.5,16836.971],[700,0.071,10.149,7.5,16839.971],[700,0.071,10.149,7.5,16842.971],[700,0.071,10.149,7.5,16845.971],[700,0.071,10.149,7.5,16848.971],[700,0.071,10.149,7.5,16851.971],[700,0.071,10.149,7.5,16854.971],[700,0.071,10.149,7.5,16857.971],[700,0.071,10.149,7.5,16860.971],[700,0.071,10.149,7.5,16863.971],[700,0.071,10.149,7.5,16866.971],[700,0.071,10.149,7.5,16869.971],[700,0.071,10.149,7.5,16872.971],[700,0.071,10.149,7.5,16875.971],[700,0.071,10.149,7.5,16878.971],[700,0.071,10.149,7.5,16881.971],[700,0.071,10.149,7.5,16884.971],[700,0.071,10.149,7.5,16887.971],[700,0.071,10.149,7.5,16890.971],[700,0.071,10.149,7.5,16893.971],[700,0.071,10.149,7.5,16896.971],[700,0.071,10.149,7.5,16899.971],[700,0.071,10.149,7.5,16902.971],[700,0.071,10.149,7.5,16905.971],[700,0.071,10.149,7.5,16908.971],[700,0.071,10.149,7.5,16911.971],[700,0.071,10.149,7.5,16914.971],[700,0.071,10.149,7.5,16917.971],[700,0.071,10.149,7.5,16920.971],[700,0.071,10.149,7.5,16923.971],[700,0.071,10.149,7.5,16926.971],[700,0.071,10.149,7.5,16929.971],[700,0.071,10.149,7.5,16932.971],[700,0.071,10.149,7.5,16935.971],[700,0.071,10.149,7.5,16938.971],[700,0.071,10.149,7.5,16941.971],[700,0.071,10.149,7.5,16944.971],[700,0.071,10.149,7.5,16947.971],[700,0.071,10.149,7.5,16950.971],[700,0.071,10.149,7.5,16953.971],[700,0.071,10.149,7.5,16956.971],[700,0.071,10.149,7.5,16959.971],[700,0.071,10.149,7.5,16962.971],[700,0.071,10.149,7.5,16965.971],[700,0.071,10.149,7.5,16968.971],[700,0.071,10.149,7.5,16971.971],[700,0.071,10.149,7.5,16974.971],[700,0.071,10.149,7.5,16977.971],[700,0.071,10.149,7.5,16980.971],[700,0.071,10.149,7.5,16983.971],[700,0.071,10.149,7.5,16986.971],[700,0.071,10.149,7.5,16989.971],[700,0.071,10.149,7.5,16992.971],[700,0.071,10.149,7.5,16995.971],[700,0.071,10.149,7.5,16998.971],[700,0.071,10.149,7.5,17001.971],[700,0.071,10.149,7.5,17004.971],[700,0.071,10.149,7.5,17007.971],[700,0.071,10.149,7.5,17010.971],[700,0.071,10.149,7.5,17013.971],[700,0.071,10.149,7.5,17016.971],[700,0.071,10.149,7.501,17019.971],[700,0.071,10.149,7.503,17022.972],[700,0.071,10.149,7.507,17025.974],[700,0.072,10.149,7.513,17028.978],[700,0.072,10.149,7.523,17031.985],[700,0.072,10.149,7.536,17034.997],[700,0.072,10.149,7.553,17038.015],[700,0.072,10.149,7.575,17041.04],[700,0.072,10.149,7.601,17044.075],[700,0.073,10.149,7.633,17047.122],[700,0.073,10.149,7.671,17050.183],[700,0.073,10.149,7.716,17053.26],[700,0.074,10.149,7.766,17056.356],[700,0.075,10.149,7.825,17059.474],[700,0.075,10.149,7.89,17062.617],[700,0.076,10.149,7.964,17065.787],[700,0.077,10.149,8.046,17068.989],[700,0.077,10.149,8.136,17072.225],[700,0.078,10.149,8.236,17075.499],[700,0.079,10.149,8.346,17078.815],[700,0.081,10.149,8.466,17082.177],[700,0.082,10.149,8.597,17085.59],[700,0.083,10.149,8.739,17089.056],[700,0.085,10.149,8.892,17092.582],[700,0.086,10.149,9.058,17096.172],[700,0.088,10.149,9.237,17099.83],[700,0.09,10.149,9.429,17103.563],[700,0.092,10.149,9.635,17107.375],[700,0.094,10.149,9.856,17111.273],[700,0.096,10.149,10.093,17115.262],[700,0.099,10.149,10.345,17119.349],[700,0.101,10.149,10.615,17123.541],[700,0.104,10.149,10.902,17127.843],[700,0.107,10.149,11.208,17132.265],[700,0.11,10.149,11.534,17136.813],[700,0.113,10.149,11.88,17141.495],[700,0.117,10.149,12.247,17146.319],[700,0.12,10.149,12.637,17151.295],[700,0.124,10.149,13.05,17156.432],[700,0.128,10.149,13.487,17161.738],[700,0.133,10.149,13.951,17167.225],[700,0.138,10.149,14.441,17172.902],[700,0.142,10.149,14.958,17178.781],[700,0.148,10.149,15.505,17184.873],[700,0.153,10.149,16.083,17191.19],[700,0.159,10.149,16.692,17197.744],[700,0.165,10.149,17.334,17204.547],[700,0.172,10.149,18.01,17211.615],[700,0.178,10.149,18.721,17218.96],[700,0.185,10.149,19.469,17226.597],[700,0.193,10.149,20.256,17234.54],[700,0.201,10.149,21.081,17242.807],[700,0.209,10.149,21.948,17251.411],[700,0.218,10.149,22.856,17260.37],[700,0.227,10.149,23.807,17269.701],[700,0.236,10.149,24.802,17279.422],[700,0.246,10.149,25.843,17289.549],[700,0.256,10.149,26.929,17300.102],[700,0.267,10.149,28.062,17311.099],[700,0.279,10.149,29.244,17322.558],[700,0.29,10.149,30.473,17334.5],[700,0.302,10.149,31.751,17346.943],[700,0.315,10.149,33.079,17359.907],[700,0.328,10.149,34.455,17373.413],[700,0.342,10.149,35.881,17387.478],[700,0.356,10.149,37.355,17402.124],[700,0.37,10.149,38.878,17417.369],[700,0.385,10.149,40.448,17433.232],[700,0.401,10.149,42.065,17449.733],[700,0.416,10.149,43.727,17466.89],[700,0.433,10.149,45.432,17484.72],[700,0.449,10.149,47.179,17503.241],[700,0.466,10.149,48.965,17522.469],[700,0.484,10.149,50.789,17542.418],[700,0.501,10.149,52.646,17563.104],[700,0.519,10.149,54.534,17584.539],[700,0.538,10.149,56.449,17606.735],[700,0.556,10.149,58.387,17629.701],[700,0.575,10.149,60.345,17653.447],[700,0.594,10.149,62.318,17677.98],[700,0.612,10.149,64.301,17703.303],[700,0.631,10.149,66.289,17729.421],[700,0.65,10.149,68.278,17756.335],[700,0.669,10.149,70.261,17784.043],[700,0.688,10.149,72.233,17812.542],[700,0.707,10.149,74.189,17841.827],[700,0.725,10.149,76.122,17871.89],[700,0.743,10.149,78.028,17902.721],[700,0.761,10.149,79.9,17934.308],[700,0.778,10.149,81.733,17966.636],[700,0.795,10.149,83.52,17999.688],[700,0.812,10.149,85.258,18033.445],[700,0.828,10.149,86.939,18067.887],[700,0.843,10.149,88.561,18102.989],[700,0.858,10.149,90.117,18138.727],[700,0.872,10.149,91.603,18175.073],[700,0.886,10.149,93.017,18212],[700,0.899,10.149,94.354,18249.476],[700,0.911,10.149,95.611,18287.472],[700,0.922,10.149,96.787,18325.955],[700,0.932,10.149,97.879,18364.89],[700,0.942,10.149,98.886,18404.246],[700,0.951,10.149,99.808,18443.988],[700,0.959,10.149,100.645,18484.081],[700,0.966,10.149,101.397,18524.493],[700,0.972,10.149,102.066,18565.188],[700,0.978,10.149,102.655,18606.135],[700,0.983,10.149,103.165,18647.301],[700,0.987,10.149,103.6,18688.657],[700,0.99,10.149,103.964,18730.172],[700,0.993,10.149,104.261,18771.819],[700,0.995,10.149,104.498,18813.573],[700,0.997,10.149,104.68,18855.41],[700,0.998,10.149,104.812,18897.31],[700,0.999,10.149,104.902,18939.254],[700,1,10.149,104.958,18981.227],[700,1,10.149,104.987,19023.217],[700,1,10.149,104.998,19065.215],[700,1,10.149,105,19107.215],[700,1,10.149,105,19149.215],[700,1,10.149,105,19191.215],[700,1,10.149,105,19233.215],[700,1,10.149,105,19275.215],[700,1,10.149,105,19317.215],[700,1,10.149,105,19359.215],[700,1,10.149,105,19401.215],[700,1,10.149,105,19443.215],[700,1,10.149,105,19485.215],[700,1,10.149,105,19527.215],[700.001,1,10.149,105,19569.215],[700.006,1,10.149,105.001,19611.215],[700.02,1,10.15,105.003,19653.215],[700.046,1,10.15,105.007,19695.217],[700.09,1,10.15,105.014,19737.221],[700.155,1,10.151,105.023,19779.229],[700.245,1,10.152,105.037,19821.24],[700.364,1,10.153,105.055,19863.259],[700.516,1,10.154,105.077,19905.285],[700.704,1,10.156,105.106,19947.321],[700.932,1,10.158,105.14,19989.37],[701.204,1,10.16,105.181,20031.434],[701.523,1,10.163,105.229,20073.516],[701.893,1,10.167,105.284,20115.618],[702.316,1,10.171,105.347,20157.744],[702.797,1,10.175,105.419,20199.897],[703.337,1,10.18,105.501,20242.081],[703.941,1,10.186,105.591,20284.299],[704.61,1,10.192,105.692,20326.555],[705.349,1,10.199,105.802,20368.853],[706.16,1,10.206,105.924,20411.198],[707.045,1,10.215,106.057,20453.594],[708.008,1,10.224,106.201,20496.045],[709.051,1,10.233,106.358,20538.556],[710.176,1,10.244,106.526,20581.133],[711.385,1,10.255,106.708,20623.779],[712.683,1,10.268,106.902,20666.501],[714.069,1,10.281,107.11,20709.303],[715.548,1,10.295,107.332,20752.191],[717.12,1,10.309,107.568,20795.17],[718.788,1,10.325,107.818,20838.247],[720.555,1,10.342,108.083,20881.427],[722.421,1,10.36,108.363,20924.716],[724.389,1,10.378,108.658,20968.12],[726.461,1,10.398,108.969,21011.645],[728.638,1,10.419,109.296,21055.297],[730.922,1,10.441,109.638,21099.083],[733.314,1,10.464,109.997,21143.01],[735.817,1,10.489,110.373,21187.083],[738.432,1,10.514,110.765,21231.31],[741.159,1,10.541,111.174,21275.697],[744.001,1,10.568,111.6,21320.251],[746.958,1,10.598,112.044,21364.98],[750.032,1,10.628,112.505,21409.889],[753.224,1,10.66,112.984,21454.986],[756.535,1,10.692,113.48,21500.278],[759.966,1,10.727,113.995,21545.772],[763.517,1,10.762,114.528,21591.476],[767.191,1,10.8,115.079,21637.397],[770.988,1,10.838,115.648,21683.542],[774.908,1,10.878,116.236,21729.918],[778.952,1,10.92,116.843,21776.533],[783.12,1,10.962,117.468,21823.395],[787.415,1,11.007,118.112,21870.51],[791.835,1,11.053,118.775,21917.887],[796.382,1,11.101,119.457,21965.533],[801.055,1,11.15,120.158,22013.455],[805.856,1,11.201,120.878,22061.662],[810.784,1,11.253,121.618,22110.16],[815.84,1,11.308,122.376,22158.958],[821.024,1,11.363,123.154,22208.064],[826.336,1,11.421,123.95,22257.484],[831.777,1,11.481,124.766,22307.227],[837.345,1,11.542,125.602,22357.3],[843.042,1,11.605,126.456,22407.711],[848.868,1,11.67,127.33,22458.467],[854.821,1,11.737,128.223,22509.577],[860.902,1,11.806,129.135,22561.048],[867.111,1,11.877,130.067,22612.888],[873.448,1,11.949,131.017,22665.104],[879.912,1,12.024,131.987,22717.704],[886.502,1,12.101,132.975,22770.696],[893.219,1,12.18,133.983,22824.087],[900.063,1,12.261,135.009,22877.885],[907.031,1,12.344,136.055,22932.097],[914.125,1,12.429,137.119,22986.731],[921.343,1,12.516,138.201,23041.795],[928.685,1,12.606,139.303,23097.295],[936.15,1,12.698,140.422,23153.239],[943.737,1,12.792,141.561,23209.635],[951.446,1,12.888,142.717,23266.49],[959.276,1,12.987,143.891,23323.811],[967.226,1,13.088,145.084,23381.606],[975.296,1,13.191,146.294,23439.881],[983.483,1,13.297,147.522,23498.644],[991.788,1,13.405,148.768,23557.901],[1000.209,1,13.516,150.031,23617.66],[1008.746,1,13.629,151.312,23677.929],[1017.396,1,13.745,152.609,23738.712],[1026.16,1,13.863,153.924,23800.018],[1035.036,1,13.983,155.255,23861.854],[1044.022,1,14.107,156.603,23924.225],[1053.119,1,14.232,157.968,23987.139],[1062.323,1,14.361,159.348,24050.601],[1071.635,1,14.492,160.745,24114.619],[1081.053,1,14.626,162.158,24179.2],[1090.574,1,14.762,163.586,24244.348],[1100.199,1,14.901,165.03,24310.071],[1109.926,1,15.043,166.489,24376.374],[1119.753,1,15.187,167.963,24443.264],[1129.679,1,15.335,169.452,24510.746],[1139.702,1,15.485,170.955,24578.827],[1149.82,1,15.638,172.473,24647.512],[1160.033,1,15.793,174.005,24716.807],[1170.339,1,15.952,175.551,24786.718],[1180.735,1,16.113,177.11,24857.25],[1191.221,1,16.277,178.683,24928.408],[1201.795,1,16.444,180.269,25000.198],[1212.454,1,16.613,181.868,25072.625],[1223.198,1,16.786,183.48,25145.694],[1234.024,1,16.961,185.104,25219.411],[1244.932,1,17.14,186.74,25293.779],[1255.918,1,17.321,188.388,25368.804],[1266.981,1,17.505,190.047,25444.491],[1278.12,1,17.692,191.718,25520.843],[1289.333,1,17.882,193.4,25597.867],[1300.617,1,18.075,195.093,25675.565],[1311.971,1,18.27,196.796,25753.942],[1323.392,1,18.469,198.509,25833.002],[1334.88,1,18.67,200.232,25912.75],[1346.432,1,18.875,201.965,25993.189],[1358.045,1,19.082,203.707,26074.323],[1369.719,1,19.292,205.458,26156.156],[1381.451,1,19.505,207.218,26238.691],[1393.239,1,19.72,208.986,26321.931],[1405.081,1,19.939,210.762,26405.881],[1416.975,1,20.16,212.546,26490.542],[1428.918,1,20.385,214.338,26575.918],[1440.91,1,20.612,216.136,26662.013],[1452.947,1,20.841,217.942,26748.829],[1465.028,1,21.074,219.754,26836.368],[1477.151,1,21.309,221.573,26924.633],[1489.313,1,21.547,223.397,27013.626],[1501.512,1,21.788,225.227,27103.351],[1513.746,1,22.031,227.062,27193.809],[1526.014,1,22.277,228.902,27285.001],[1538.312,1,22.526,230.747,27376.931],[1550.639,1,22.777,232.596,27469.599],[1562.992,1,23.03,234.449,27563.008],[1575.37,1,23.287,236.305,27657.159],[1587.77,1,23.545,238.165,27752.053],[1600.189,1,23.806,240.028,27847.692],[1612.627,1,24.07,241.894,27944.076],[1625.08,1,24.336,243.762,28041.207],[1637.546,1,24.604,245.632,28139.086],[1650.024,1,24.875,247.504,28237.713],[1662.51,1,25.148,249.376,28337.089],[1675.003,1,25.423,251.25,28437.214],[1687.5,1,25.7,253.125,28538.089],[1700,1,25.979,255,28639.714],[1712.5,1,26.26,256.875,28742.089],[1724.997,1,26.544,258.75,28845.214],[1737.49,1,26.829,260.624,28949.089],[1749.976,1,27.116,262.496,29053.713],[1762.454,1,27.405,264.368,29159.086],[1774.92,1,27.696,266.238,29265.207],[1787.373,1,27.989,268.106,29372.076],[1799.811,1,28.283,269.972,29479.692],[1812.23,1,28.579,271.835,29588.053],[1824.63,1,28.876,273.695,29697.159],[1837.008,1,29.175,275.551,29807.008],[1849.361,1,29.476,277.404,29917.599],[1861.688,1,29.777,279.253,30028.931],[1873.986,1,30.08,281.098,30141.001],[1886.254,1,30.385,282.938,30253.809],[1898.488,1,30.69,284.773,30367.351],[1910.687,1,30.997,286.603,30481.626],[1922.849,1,31.304,288.427,30596.633],[1934.972,1,31.612,290.246,30712.368],[1947.053,1,31.922,292.058,30828.829],[1959.09,1,32.232,293.864,30946.013],[1971.082,1,32.543,295.662,31063.918],[1983.025,1,32.854,297.454,31182.542],[1994.919,1,33.166,299.238,31301.881],[2006.761,1,33.479,301.014,31421.931],[2018.549,1,33.792,302.782,31542.691],[2030.281,1,34.105,304.542,31664.156],[2041.955,1,34.419,306.293,31786.323],[2053.568,1,34.732,308.035,31909.189],[2065.12,1,35.046,309.768,32032.75],[2076.608,1,35.36,311.491,32157.002],[2088.029,1,35.674,313.204,32281.942],[2099.383,1,35.987,314.907,32407.565],[2110.667,1,36.301,316.6,32533.867],[2121.88,1,36.614,318.282,32660.843],[2133.019,1,36.926,319.953,32788.491],[2144.082,1,37.238,321.612,32916.804],[2155.068,1,37.55,323.26,33045.779],[2165.976,1,37.861,324.896,33175.411],[2176.802,1,38.171,326.52,33305.694],[2187.546,1,38.48,328.132,33436.625],[2198.205,1,38.789,329.731,33568.198],[2208.779,1,39.096,331.317,33700.408],[2219.265,1,39.402,332.89,33833.25],[2229.661,1,39.707,334.449,33966.718],[2239.967,1,40.011,335.995,34100.807],[2250.18,1,40.313,337.527,34235.512],[2260.298,1,40.614,339.045,34370.827],[2270.321,1,40.914,340.548,34506.746],[2280.247,1,41.212,342.037,34643.264],[2290.074,1,41.508,343.511,34780.374],[2299.801,1,41.803,344.97,34918.071],[2309.426,1,42.095,346.414,35056.348],[2318.947,1,42.386,347.842,35195.2],[2328.365,1,42.675,349.255,35334.619],[2337.677,1,42.961,350.652,35474.601],[2346.881,1,43.246,352.032,35615.139],[2355.978,1,43.528,353.397,35756.225],[2364.964,1,43.807,354.745,35897.854],[2373.84,1,44.085,356.076,36040.018],[2382.604,1,44.36,357.391,36182.712],[2391.254,1,44.632,358.688,36325.929],[2399.791,1,44.902,359.969,36469.66],[2408.212,1,45.169,361.232,36613.901],[2416.517,1,45.433,362.478,36758.644],[2424.704,1,45.695,363.706,36903.881],[2432.774,1,45.953,364.916,37049.606],[2440.724,1,46.209,366.109,37195.811],[2448.554,1,46.461,367.283,37342.49],[2456.263,1,46.711,368.439,37489.635],[2463.85,1,46.957,369.578,37637.239],[2471.315,1,47.2,370.697,37785.295],[2478.657,1,47.44,371.799,37933.795],[2485.875,1,47.676,372.881,38082.731],[2492.969,1,47.909,373.945,38232.097],[2499.937,1,48.138,374.991,38381.885],[2506.781,1,48.364,376.017,38532.087],[2513.498,1,48.587,377.025,38682.696],[2520.088,1,48.806,378.013,38833.704],[2526.552,1,49.021,378.983,38985.104],[2532.889,1,49.232,379.933,39136.888],[2539.098,1,49.44,380.865,39289.048],[2545.179,1,49.644,381.777,39441.577],[2551.132,1,49.844,382.67,39594.467],[2556.958,1,50.04,383.544,39747.711],[2562.655,1,50.233,384.398,39901.3],[2568.223,1,50.421,385.234,40055.227],[2573.664,1,50.606,386.05,40209.484],[2578.976,1,50.786,386.846,40364.064],[2584.16,1,50.963,387.624,40518.958],[2589.216,1,51.135,388.382,40674.16],[2594.144,1,51.304,389.122,40829.662],[2598.945,1,51.468,389.842,40985.455],[2603.618,1,51.628,390.543,41141.533],[2608.165,1,51.785,391.225,41297.887],[2612.585,1,51.937,391.888,41454.51],[2616.88,1,52.085,392.532,41611.395],[2621.048,1,52.229,393.157,41768.533],[2625.092,1,52.369,393.764,41925.918],[2629.012,1,52.505,394.352,42083.542],[2632.809,1,52.637,394.921,42241.397],[2636.483,1,52.764,395.472,42399.476],[2640.034,1,52.888,396.005,42557.772],[2643.465,1,53.008,396.52,42716.278],[2646.776,1,53.123,397.016,42874.986],[2649.968,1,53.235,397.495,43033.889],[2653.042,1,53.342,397.956,43192.98],[2655.999,1,53.446,398.4,43352.251],[2658.841,1,53.545,398.826,43511.697],[2661.568,1,53.641,399.235,43671.31],[2664.183,1,53.733,399.627,43831.083],[2666.686,1,53.821,400.003,43991.01],[2669.078,1,53.905,400.362,44151.083],[2671.362,1,53.985,400.704,44311.297],[2673.539,1,54.062,401.031,44471.645],[2675.611,1,54.135,401.342,44632.12],[2677.579,1,54.205,401.637,44792.716],[2679.445,1,54.271,401.917,44953.427],[2681.212,1,54.333,402.182,45114.247],[2682.88,1,54.392,402.432,45275.17],[2684.452,1,54.448,402.668,45436.191],[2685.931,1,54.5,402.89,45597.303],[2687.317,1,54.549,403.098,45758.501],[2688.615,1,54.595,403.292,45919.779],[2689.824,1,54.638,403.474,46081.133],[2690.949,1,54.678,403.642,46242.556],[2691.992,1,54.715,403.799,46404.045],[2692.955,1,54.749,403.943,46565.594],[2693.84,1,54.781,404.076,46727.198],[2694.651,1,54.81,404.198,46888.853],[2695.39,1,54.836,404.308,47050.555],[2696.059,1,54.86,404.409,47212.299],[2696.663,1,54.881,404.499,47374.081],[2697.203,1,54.9,404.581,47535.897],[2697.684,1,54.918,404.653,47697.744],[2698.107,1,54.933,404.716,47859.618],[2698.477,1,54.946,404.771,48021.516],[2698.796,1,54.957,404.819,48183.434],[2699.068,1,54.967,404.86,48345.37],[2699.296,1,54.975,404.894,48507.321],[2699.484,1,54.982,404.923,48669.285],[2699.636,1,54.987,404.945,48831.259],[2699.755,1,54.991,404.963,48993.24],[2699.845,1,54.994,404.977,49155.229],[2699.91,1,54.997,404.986,49317.221],[2699.954,1,54.998,404.993,49479.217],[2699.98,1,54.999,404.997,49641.215],[2699.994,1,55,404.999,49803.215],[2699.999,1,55,405,49965.215],[2700,1,55,405,50127.215],[2700,1,55,405,50289.215],[2700,1,55,405,50451.215],[2700,1,55,405,50613.215],[2700,1,55,405,50775.215],[2700,1,55,405,50937.215],[2700,1,55,405,51099.215],[2700,1,55,405,51261.215],[2700,1,55,405,51423.215],[2700,1,55,405,51585.215],[2700,1,55,405,51747.215],[2700,1,55,405,51909.215],[2700,1,55,405,52071.215],[2700,1,55,405,52233.215],[2700,1,55,405,52395.215],[2700,1,55,405,52557.215],[2700,1,55,405,52719.215],[2700,1,55,405,52881.215],[2700,1,55,405,53043.215],[2700,1,55,405,53205.215],[2700,1,55,405,53367.215],[2700,1,55,405,53529.215],[2700,1,55,405,53691.215],[2700,1,55,405,53853.215],[2700,1,55,405,54015.215],[2700,1,55,405,54177.215],[2700,1,55,405,54339.215],[2700,1,55,405,54501.215],[2700,1,55,405,54663.215],[2700,1,55,405,54825.215],[2700,1,55,405,54987.215],[2700,1,55,405,55149.215],[2700,1,55,405,55311.215],[2700,1,55,405,55473.215],[2700,1,55,405,55635.215],[2700,1,55,405,55797.215],[2700,1,55,405,55959.215],[2700,1,55,405,56121.215],[2700,1,55,405,56283.215],[2700,1,55,405,56445.215],[2700,1,55,405,56607.215],[2700,1,55,405,56769.215],[2700,1,55,405,56931.215],[2700,1,55,405,57093.215],[2700,1,55,405,57255.215],[2700,1,55,405,57417.215],[2700,1,55,405,57579.215],[2700,1,55,405,57741.215],[2700,1,55,405,57903.215],[2700,1,55,405,58065.215],[2700,1,55,405,58227.215],[2700,1,55,405,58389.215],[2700,1,55,405,58551.215],[2700,1,55,405,58713.215],[2700,1,55,405,58875.215],[2700,1,55,405,59037.215],[2700,1,55,405,59199.215],[2700,1,55,405,59361.215],[2700,1,55,405,59523.215],[2700,1,55,405,59685.215],[2700,1,55,405,59847.215],[2700,1,55,405,60009.215],[2700,1,55,405,60171.215],[2700,1,55,405,60333.215],[2700,1,55,405,60495.215],[2700,1,55,405,60657.215],[2700,1,55,405,60819.215],[2700,1,55,405,60981.215],[2700,1,55,405,61143.215],[2700,1,55,405,61305.215],[2700,1,55,405,61467.215],[2700,1,55,405,61629.215],[2700,1,55,405,61791.215],[2700,1,55,405,61953.215],[2700,1,55,405,62115.215],[2700,1,55,405,62277.215],[2700,1,55,405,62439.215],[2700,1,55,405,62601.215],[2700,1,55,405,62763.215],[2700,1,55,405,62925.215],[2700,1,55,405,63087.215],[2700,1,55,405,63249.215],[2700,1,55,405,63411.215],[2700,1,55,405,63573.215],[2700,1,55,405,63735.215],[2700,1,55,405,63897.215],[2700,1,55,405,64059.215],[2700,1,55,405,64221.215],[2700,1,55,405,64383.215],[2700,1,55,405,64545.215],[2700,1,55,405,64707.215],[2700,1,55,405,64869.215],[2700,1,55,405,65031.215],[2700,1,55,405,65193.215],[2700,1,55,405,65355.215],[2700,1,55,405,65517.215],[2700,1,55,405,65679.215],[2700,1,55,405,65841.215],[2700,1,55,405,66003.215],[2700,1,55,405,66165.215],[2700,1,55,405,66327.215],[2700,1,55,405,66489.215],[2700,1,55,405,66651.215],[2700,1,55,405,66813.215],[2700,1,55,405,66975.215],[2700,1,55,405,67137.215],[2700,1,55,405,67299.215],[2700,1,55,405,67461.215],[2700,1,55,405,67623.215],[2700,1,55,405,67785.215],[2700,1,55,405,67947.215],[2700,1,55,405,68109.215],[2700,1,55,405,68271.215],[2700,1,55,405,68433.215],[2700,1,55,405,68595.215],[2700,1,55,405,68757.215],[2700,1,55,405,68919.215],[2700,1,55,405,69081.215],[2700,1,55,405,69243.215],[2700,1,55,405,69405.215],[2700,1,55,405,69567.215],[2700,1,55,405,69729.215],[2700,1,55,405,69891.215],[2700,1,55,405,70053.215],[2700,1,55,405,70215.215],[2700,1,55,405,70377.215],[2700,1,55,405,70539.215],[2700,1,55,405,70701.215],[2700,1,55,405,70863.215],[2700,1,55,405,71025.215],[2700,1,55,405,71187.215],[2700,1,55,405,71349.215],[2700,1,55,405,71511.215],[2700,1,55,405,71673.215],[2700,1,55,405,71835.215],[2700,1,55,405,71997.215],[2700,1,55,405,72159.215],[2700,1,55,405,72321.215],[2700,1,55,405,72483.215],[2700,1,55,405,72645.215],[2700,1,55,405,72807.215],[2700,1,55,405,72969.215],[2700,1,55,405,73131.215],[2700,1,55,405,73293.215],[2700,1,55,405,73455.215],[2700,1,55,405,73617.215],[2700,1,55,405,73779.215],[2700,1,55,405,73941.215],[2700,1,55,405,74103.215],[2700,1,55,405,74265.215],[2700,1,55,405,74427.215],[2700,1,55,405,74589.215],[2700,1,55,405,74751.215],[2700,1,55,405,74913.215],[2700,1,55,405,75075.215],[2700,1,55,405,75237.215],[2700,1,55,405,75399.215],[2700,1,55,405,75561.215],[2700,1,55,405,75723.215],[2700,1,55,405,75885.215],[2700,1,55,405,76047.215],[2700,1,55,405,76209.215],[2700,1,55,405,76371.215],[2700,1,55,405,76533.215],[2700,1,55,405,76695.215],[2700,1,55,405,76857.215],[2700,1,55,405,77019.215],[2700,1,55,405,77181.215],[2700,1,55,405,77343.215],[2700,1,55,405,77505.215],[2700,1,55,405,77667.215],[2700,1,55,405,77829.215],[2700,1,55,405,77991.215],[2700,1,55,405,78153.215],[2700,1,55,405,78315.215],[2700,1,55,405,78477.215],[2700,1,55,405,78639.215],[2700,1,55,405,78801.215],[2700,1,55,405,78963.215],[2700,1,55,405,79125.215],[2700,1,55,405,79287.215],[2700,1,55,405,79449.215],[2700,1,55,405,79611.215],[2700,1,55,405,79773.215],[2700,1,55,405,79935.215],[2700,1,55,405,80097.215],[2700,1,55,405,80259.215],[2700,1,55,405,80421.215],[2700,1,55,405,80583.215],[2700,1,55,405,80745.215],[2700,1,55,405,80907.215],[2700,1,55,405,81069.215],[2700,1,55,405,81231.215],[2700,1,55,405,81393.215],[2700,1,55,405,81555.215],[2700,1,55,405,81717.215],[2700,1,55,405,81879.215],[2700,1,55,405,82041.215],[2700,1,55,405,82203.215],[2700,1,55,405,82365.215],[2700,1,55,405,82527.215],[2700,1,55,405,82689.215],[2700,1,55,405,82851.215],[2700,1,55,405,83013.215],[2700,1,55,405,83175.215],[2700,1,55,405,83337.215],[2700,1,55,405,83499.215],[2700,1,55,405,83661.215],[2700,1,55,405,83823.215],[2700,1,55,405,83985.215],[2700,1,55,405,84147.215],[2700,1,55,405,84309.215],[2700,1,55,405,84471.215],[2700,1,55,405,84633.215],[2700,1,55,405,84795.215],[2700,1,55,405,84957.215],[2700,1,55,405,85119.215],[2700,1,55,405,85281.215],[2700,1,55,405,85443.215],[2700,1,55,405,85605.215],[2700,1,55,405,85767.215],[2700,1,55,405,85929.215],[2700,1,55,405,86091.215],[2700,1,55,405,86253.215],[2700,1,55,405,86415.215],[2700,1,55,405,86577.215],[2700,1,55,405,86739.215],[2700,1,55,405,86901.215],[2700,1,55,405,87063.215],[2700,1,55,405,87225.215],[2700,1,55,405,87387.215],[2700,1,55,405,87549.215],[2700,1,55,405,87711.215],[2700,1,55,405,87873.215],[2700,1,55,405,88035.215],[2700,1,55,405,88197.215],[2700,1,55,405,88359.215],[2700,1,55,405,88521.215],[2700,1,55,405,88683.215],[2700,1,55,405,88845.215],[2700,1,55,405,89007.215],[2700,1,55,405,89169.215],[2700,1,55,405,89331.215],[2700,1,55,405,89493.215],[2700,1,55,405,89655.215],[2700,1,55,405,89817.215],[2700,1,55,405,89979.215],[2700,1,55,405,90141.215],[2700,1,55,405,90303.215],[2700,1,55,405,90465.215],[2700,1,55,405,90627.215],[2700,1,55,405,90789.215],[2700,1,55,405,90951.215],[2700,1,55,405,91113.215],[2700,1,55,405,91275.215],[2700,1,55,405,91437.215],[2700,1,55,405,91599.215],[2700,1,55,405,91761.215],[2700,1,55,405,91923.215],[2700,1,55,405,92085.215],[2700,1,55,405,92247.215],[2700,1,55,405,92409.215],[2700,1,55,405,92571.215],[2700,1,55,405,92733.215],[2700,1,55,405,92895.215],[2700,1,55,405,93057.215],[2700,1,55,405,93219.215],[2700,1,55,405,93381.215],[2700,1,55,405,93543.215],[2700,1,55,405,93705.215],[2700,1,55,405,93867.215],[2700,1,55,405,94029.215],[2700,1,55,405,94191.215],[2700,1,55,405,94353.215],[2700,1,55,405,94515.215],[2700,1,55,405,94677.215],[2700,1,55,405,94839.215],[2700,1,55,405,95001.215],[2700,1,55,405,95163.215],[2700,1,55,405,95325.215],[2700,1,55,405,95487.215],[2700,1,55,405,95649.215],[2700,1,55,405,95811.215],[2700,1,55,405,95973.215],[2700,1,55,405,96135.215],[2700,1,55,405,96297.215],[2700,1,55,405,96459.215],[2700,1,55,405,96621.215],[2700,1,55,405,96783.215],[2700,1,55,405,96945.215],[2700,1,55,405,97107.215],[2699.999,1,55,405,97269.214],[2699.994,1,55,404.999,97431.214],[2699.98,1,54.999,404.997,97593.214],[2699.954,1,54.998,404.993,97755.212],[2699.91,1,54.997,404.986,97917.208],[2699.845,1,54.994,404.977,98079.2],[2699.755,1,54.991,404.963,98241.189],[2699.636,1,54.987,404.945,98403.17],[2699.484,1,54.982,404.923,98565.144],[2699.296,1,54.975,404.894,98727.108],[2699.068,1,54.967,404.86,98889.059],[2698.796,1,54.957,404.819,99050.995],[2698.477,1,54.946,404.771,99212.913],[2698.107,1,54.933,404.716,99374.811],[2697.684,1,54.918,404.653,99536.685],[2697.203,1,54.9,404.581,99698.532],[2696.663,1,54.881,404.499,99860.348],[2696.059,1,54.86,404.409,100022.13],[2695.39,1,54.836,404.308,100183.874],[2694.651,1,54.81,404.198,100345.576],[2693.84,1,54.781,404.076,100507.231],[2692.955,1,54.749,403.943,100668.835],[2691.992,1,54.715,403.799,100830.384],[2690.949,1,54.678,403.642,100991.873],[2689.824,1,54.638,403.474,101153.296],[2688.615,1,54.595,403.292,101314.65],[2687.317,1,54.549,403.098,101475.928],[2685.931,1,54.5,402.89,101637.126],[2684.452,1,54.448,402.668,101798.238],[2682.88,1,54.392,402.432,101959.259],[2681.212,1,54.333,402.182,102120.182],[2679.445,1,54.271,401.917,102281.002],[2677.579,1,54.205,401.637,102441.713],[2675.611,1,54.135,401.342,102602.309],[2673.539,1,54.062,401.031,102762.784],[2671.362,1,53.985,400.704,102923.132],[2669.078,1,53.905,400.362,103083.346],[2666.686,1,53.821,400.003,103243.419],[2664.183,1,53.733,399.627,103403.346],[2661.568,1,53.641,399.235,103563.119],[2658.841,1,53.545,398.826,103722.732],[2655.999,1,53.446,398.4,103882.178],[2653.042,1,53.342,397.956,104041.449],[2649.968,1,53.235,397.495,104200.54],[2646.776,1,53.123,397.016,104359.443],[2643.465,1,53.008,396.52,104518.151],[2640.034,1,52.888,396.005,104676.657],[2636.483,1,52.764,395.472,104834.953],[2632.809,1,52.637,394.921,104993.032],[2629.012,1,52.505,394.352,105150.887],[2625.092,1,52.369,393.764,105308.511],[2621.048,1,52.229,393.157,105465.896],[2616.88,1,52.085,392.532,105623.034],[2612.585,1,51.937,391.888,105779.919],[2608.165,1,51.785,391.225,105936.542],[2603.618,1,51.628,390.543,106092.896],[2598.945,1,51.468,389.842,106248.974],[2594.144,1,51.304,389.122,106404.767],[2589.216,1,51.135,388.382,106560.269],[2584.16,1,50.963,387.624,106715.471],[2578.976,1,50.786,386.846,106870.365],[2573.664,1,50.606,386.05,107024.945],[2568.223,1,50.421,385.234,107179.202],[2562.655,1,50.233,384.398,107333.129],[2556.958,1,50.04,383.544,107486.718],[2551.132,1,49.844,382.67,107639.962],[2545.179,1,49.644,381.777,107792.852],[2539.098,1,49.44,380.865,107945.381],[2532.889,1,49.232,379.933,108097.541],[2526.552,1,49.021,378.983,108249.325],[2520.088,1,48.806,378.013,108400.725],[2513.498,1,48.587,377.025,108551.733],[2506.781,1,48.364,376.017,108702.342],[2499.937,1,48.138,374.991,108852.544],[2492.969,1,47.909,373.945,109002.332],[2485.875,1,47.676,372.881,109151.698],[2478.657,1,47.44,371.799,109300.634],[2471.315,1,47.2,370.697,109449.134],[2463.85,1,46.957,369.578,109597.19],[2456.263,1,46.711,368.439,109744.794],[2448.554,1,46.461,367.283,109891.939],[2440.724,1,46.209,366.109,110038.618],[2432.774,1,45.953,364.916,110184.823],[2424.704,1,45.695,363.706,110330.548],[2416.517,1,45.433,362.478,110475.785],[2408.212,1,45.169,361.232,110620.528],[2399.791,1,44.902,359.969,110764.769],[2391.254,1,44.632,358.688,110908.5],[2382.604,1,44.36,357.391,111051.717],[2373.84,1,44.085,356.076,111194.411],[2364.964,1,43.807,354.745,111336.575],[2355.978,1,43.528,353.397,111478.204],[2346.881,1,43.246,352.032,111619.29],[2337.677,1,42.961,350.652,111759.828],[2328.365,1,42.675,349.255,111899.81],[2318.947,1,42.386,347.842,112039.229],[2309.426,1,42.095,346.414,112178.081],[2299.801,1,41.803,344.97,112316.358],[2290.074,1,41.508,343.511,112454.055],[2280.247,1,41.212,342.037,112591.165],[2270.321,1,40.914,340.548,112727.683],[2260.298,1,40.614,339.045,112863.602],[2250.18,1,40.313,337.527,112998.917],[2239.967,1,40.011,335.995,113133.622],[2229.661,1,39.707,334.449,113267.711],[2219.265,1,39.402,332.89,113401.179],[2208.779,1,39.096,331.317,113534.021],[2198.205,1,38.789,329.731,113666.231],[2187.546,1,38.48,328.132,113797.804],[2176.802,1,38.171,326.52,113928.735],[2165.976,1,37.861,324.896,114059.018],[2155.068,1,37.55,323.26,114188.65],[2144.082,1,37.238,321.612,114317.625],[2133.019,1,36.926,319.953,114445.938],[2121.88,1,36.614,318.282,114573.586],[2110.667,1,36.301,316.6,114700.563],[2099.383,1,35.987,314.907,114826.864],[2088.029,1,35.674,313.204,114952.487],[2076.608,1,35.36,311.491,115077.427],[2065.12,1,35.046,309.768,115201.679],[2053.568,1,34.732,308.035,115325.24],[2041.955,1,34.419,306.293,115448.106],[2030.281,1,34.105,304.542,115570.273],[2018.549,1,33.792,302.782,115691.738],[2006.761,1,33.479,301.014,115812.498],[1994.919,1,33.166,299.238,115932.548],[1983.025,1,32.854,297.454,116051.887],[1971.082,1,32.543,295.662,116170.511],[1959.09,1,32.232,293.864,116288.416],[1947.053,1,31.922,292.058,116405.6],[1934.972,1,31.612,290.246,116522.061],[1922.849,1,31.304,288.427,116637.796],[1910.687,1,30.997,286.603,116752.803],[1898.488,1,30.69,284.773,116867.078],[1886.254,1,30.385,282.938,116980.62],[1873.986,1,30.08,281.098,117093.428],[1861.688,1,29.777,279.253,117205.498],[1849.361,1,29.476,277.404,117316.83],[1837.008,1,29.175,275.551,117427.421],[1824.63,1,28.876,273.695,117537.27],[1812.23,1,28.579,271.835,117646.376],[1799.811,1,28.283,269.972,117754.737],[1787.373,1,27.989,268.106,117862.353],[1774.92,1,27.696,266.238,117969.222],[1762.454,1,27.405,264.368,118075.343],[1749.976,1,27.116,262.496,118180.716],[1737.49,1,26.829,260.624,118285.34],[1724.997,1,26.544,258.75,118389.215],[1712.5,1,26.26,256.875,118492.34],[1700,1,25.979,255,118594.715],[1687.5,1,25.7,253.125,118696.34],[1675.003,1,25.423,251.25,118797.215],[1662.51,1,25.148,249.376,118897.34],[1650.024,1,24.875,247.504,118996.716],[1637.546,1,24.604,245.632,119095.343],[1625.08,1,24.336,243.762,119193.222],[1612.627,1,24.07,241.894,119290.353],[1600.189,1,23.806,240.028,119386.737],[1587.77,1,23.545,238.165,119482.376],[1575.37,1,23.287,236.305,119577.27],[1562.992,1,23.03,234.449,119671.421],[1550.639,1,22.777,232.596,119764.83],[1538.312,1,22.526,230.747,119857.498],[1526.014,1,22.277,228.902,119949.428],[1513.746,1,22.031,227.062,120040.62],[1501.512,1,21.788,225.227,120131.078],[1489.313,1,21.547,223.397,120220.803],[1477.151,1,21.309,221.573,120309.796],[1465.028,1,21.074,219.754,120398.061],[1452.947,1,20.841,217.942,120485.6],[1440.91,1,20.612,216.136,120572.416],[1428.918,1,20.385,214.338,120658.511],[1416.975,1,20.16,212.546,120743.887],[1405.081,1,19.939,210.762,120828.548],[1393.239,1,19.72,208.986,120912.498],[1381.451,1,19.505,207.218,120995.738],[1369.719,1,19.292,205.458,121078.273],[1358.045,1,19.082,203.707,121160.106],[1346.432,1,18.875,201.965,121241.24],[1334.88,1,18.67,200.232,121321.679],[1323.392,1,18.469,198.509,121401.427],[1311.971,1,18.27,196.796,121480.487],[1300.617,1,18.075,195.093,121558.864],[1289.333,1,17.882,193.4,121636.563],[1278.12,1,17.692,191.718,121713.586],[1266.981,1,17.505,190.047,121789.938],[1255.918,1,17.321,188.388,121865.625],[1244.932,1,17.14,186.74,121940.65],[1234.024,1,16.961,185.104,122015.018],[1223.198,1,16.786,183.48,122088.735],[1212.454,1,16.613,181.868,122161.804],[1201.795,1,16.444,180.269,122234.231],[1191.221,1,16.277,178.683,122306.021],[1180.735,1,16.113,177.11,122377.179],[1170.339,1,15.952,175.551,122447.711],[1160.033,1,15.793,174.005,122517.622],[1149.82,1,15.638,172.473,122586.917],[1139.702,1,15.485,170.955,122655.602],[1129.679,1,15.335,169.452,122723.683],[1119.753,1,15.187,167.963,122791.165],[1109.926,1,15.043,166.489,122858.055],[1100.199,1,14.901,165.03,122924.358],[1090.574,1,14.762,163.586,122990.081],[1081.053,1,14.626,162.158,123055.229],[1071.635,1,14.492,160.745,123119.81],[1062.323,1,14.361,159.348,123183.828],[1053.119,1,14.232,157.968,123247.29],[1044.022,1,14.107,156.603,123310.204],[1035.036,1,13.983,155.255,123372.575],[1026.16,1,13.863,153.924,123434.411],[1017.396,1,13.745,152.609,123495.717],[1008.746,1,13.629,151.312,123556.5],[1000.209,1,13.516,150.031,123616.769],[991.788,1,13.405,148.768,123676.528],[983.483,1,13.297,147.522,123735.785],[975.296,1,13.191,146.294,123794.548],[967.226,1,13.088,145.084,123852.823],[959.276,1,12.987,143.891,123910.618],[951.446,1,12.888,142.717,123967.939],[943.737,1,12.792,141.561,124024.794],[936.15,1,12.698,140.422,124081.19],[928.685,1,12.606,139.303,124137.134],[921.343,1,12.516,138.201,124192.634],[914.125,1,12.429,137.119,124247.698],[907.031,1,12.344,136.055,124302.332],[900.063,1,12.261,135.009,124356.544],[893.219,1,12.18,133.983,124410.342],[886.502,1,12.101,132.975,124463.733],[879.912,1,12.024,131.987,124516.725],[873.448,1,11.949,131.017,124569.325],[867.111,1,11.877,130.067,124621.541],[860.902,1,11.806,129.135,124673.381],[854.821,1,11.737,128.223,124724.852],[848.868,1,11.67,127.33,124775.962],[843.042,1,11.605,126.456,124826.718],[837.345,1,11.542,125.602,124877.129],[831.777,1,11.481,124.766,124927.202],[826.336,1,11.421,123.95,124976.945],[821.024,1,11.363,123.154,125026.365],[815.84,1,11.308,122.376,125075.471],[810.784,1,11.253,121.618,125124.269],[805.856,1,11.201,120.878,125172.767],[801.055,1,11.15,120.158,125220.974],[796.382,1,11.101,119.457,125268.896],[791.835,1,11.053,118.775,125316.542],[787.415,1,11.007,118.112,125363.919],[783.12,1,10.962,117.468,125411.034],[778.952,1,10.92,116.843,125457.896],[774.908,1,10.878,116.236,125504.511],[770.988,1,10.838,115.648,125550.887],[767.191,1,10.8,115.079,125597.032],[763.517,1,10.762,114.528,125642.953],[759.966,1,10.727,113.995,125688.657],[756.535,1,10.692,113.48,125734.151],[753.224,1,10.66,112.984,125779.443],[750.032,1,10.628,112.505,125824.54],[746.958,1,10.598,112.044,125869.449],[744.001,1,10.568,111.6,125914.178],[741.159,1,10.541,111.174,125958.732],[738.432,1,10.514,110.765,126003.119],[735.817,1,10.489,110.373,126047.346],[733.314,1,10.464,109.997,126091.419],[730.922,1,10.441,109.638,126135.346],[728.638,1,10.419,109.296,126179.132],[726.461,1,10.398,108.969,126222.784],[724.389,1,10.378,108.658,126266.309],[722.421,1,10.36,108.363,126309.713],[720.555,1,10.342,108.083,126353.002],[718.788,1,10.325,107.818,126396.182],[717.12,1,10.309,107.568,126439.259],[715.548,1,10.295,107.332,126482.238],[714.069,1,10.281,107.11,126525.126],[712.683,1,10.268,106.902,126567.928],[711.385,1,10.255,106.708,126610.65],[710.176,1,10.244,106.526,126653.296],[709.051,1,10.233,106.358,126695.873],[708.008,1,10.224,106.201,126738.384],[707.045,1,10.215,106.057,126780.835],[706.16,1,10.206,105.924,126823.231],[705.349,1,10.199,105.802,126865.576],[704.61,1,10.192,105.692,126907.874],[703.941,1,10.186,105.591,126950.13],[703.337,1,10.18,105.501,126992.348],[702.797,1,10.175,105.419,127034.532],[702.316,1,10.171,105.347,127076.685],[701.893,1,10.167,105.284,127118.811],[701.523,1,10.163,105.229,127160.913],[701.204,1,10.16,105.181,127202.995],[700.932,1,10.158,105.14,127245.059],[700.704,1,10.156,105.106,127287.108],[700.516,1,10.154,105.077,127329.144],[700.364,1,10.153,105.055,127371.17],[700.245,1,10.152,105.037,127413.189],[700.155,1,10.151,105.023,127455.2],[700.09,1,10.15,105.014,127497.208],[700.046,1,10.15,105.007,127539.212],[700.02,1,10.15,105.003,127581.214],[700.006,1,10.149,105.001,127623.214],[700.001,1,10.149,105,127665.214],[700,1,10.149,105,127707.215],[700,1,10.149,105,127749.215],[700,1,10.149,105,127791.215],[700,1,10.149,105,127833.215],[700,1,10.149,105,127875.215],[700,1,10.149,105,127917.215],[700,1,10.149,105,127959.215],[700,1,10.149,105,128001.215],[700,1,10.149,105,128043.215],[700,1,10.149,105,128085.215],[700,1,10.149,105,128127.215],[700,1,10.149,105,128169.215],[700,1,10.149,105,128211.215],[700,1,10.149,105,128253.215],[700,1,10.149,105,128295.215],[700,1,10.149,105,128337.215],[700,1,10.149,105,128379.215],[700,1,10.149,105,128421.215],[700,1,10.149,105,128463.215],[700,1,10.149,105,128505.215],[700,1,10.149,105,128547.215],[700,1,10.149,105,128589.215],[700,1,10.149,105,128631.215],[700,1,10.149,105,128673.215],[700,1,10.149,105,128715.215],[700,1,10.149,105,128757.215],[700,1,10.149,105,128799.215],[700,1,10.149,105,128841.215],[700,1,10.149,105,128883.215],[700,1,10.149,105,128925.215],[700,1,10.149,105,128967.215],[700,1,10.149,105,129009.215],[700,1,10.149,105,129051.215],[700,1,10.149,105,129093.215],[700,1,10.149,105,129135.215],[700,1,10.149,105,129177.215],[700,1,10.149,105,129219.215],[700,1,10.149,105,129261.215],[700,1,10.149,105,129303.215],[700,1,10.149,105,129345.215],[700,1,10.149,105,129387.215],[700,1,10.149,105,129429.215],[700,1,10.149,105,129471.215],[700,1,10.149,105,129513.215],[700,1,10.149,105,129555.215],[700,1,10.149,105,129597.215],[700,1,10.149,105,129639.215],[700,1,10.149,105,129681.215],[700,1,10.149,105,129723.215],[700,1,10.149,105,129765.215],[700,1,10.149,105,129807.215],[700,1,10.149,105,129849.215],[700,1,10.149,105,129891.215],[700,1,10.149,105,129933.215],[700,1,10.149,105,129975.215],[700,1,10.149,105,130017.215],[700,1,10.149,105,130059.215],[700,1,10.149,105,130101.215],[700,1,10.149,105,130143.215],[700,1,10.149,105,130185.215],[700,1,10.149,105,130227.215],[700,1,10.149,105,130269.215],[700,1,10.149,105,130311.215],[700,1,10.149,105,130353.215],[700,1,10.149,105,130395.215],[700,1,10.149,105,130437.215],[700,1,10.149,105,130479.215],[700,1,10.149,105,130521.215],[700,1,10.149,105,130563.215],[700,1,10.149,105,130605.215],[700,1,10.149,105,130647.215],[700,1,10.149,105,130689.215],[700,1,10.149,105,130731.215],[700,1,10.149,105,130773.215],[700,1,10.149,105,130815.215],[700,1,10.149,105,130857.215],[700,1,10.149,105,130899.215],[700,1,10.149,105,130941.215],[700,1,10.149,105,130983.215],[700,1,10.149,105,131025.215],[700,1,10.149,105,131067.215],[700,1,10.149,105,131109.215],[700,1,10.149,105,131151.215],[700,1,10.149,105,131193.215],[700,1,10.149,105,131235.215],[700,1,10.149,105,131277.215],[700,1,10.149,105,131319.215],[700,1,10.149,105,131361.215],[700,1,10.149,105,131403.215],[700,1,10.149,105,131445.215],[700,1,10.149,105,131487.215],[700,1,10.149,105,131529.215],[700,1,10.149,105,131571.215],[700,1,10.149,105,131613.215],[700,1,10.149,105,131655.215],[700,1,10.149,105,131697.215],[700,1,10.149,105,131739.215],[700,1,10.149,105,131781.215],[700,1,10.149,105,131823.215],[700,1,10.149,105,131865.215],[700,1,10.149,105,131907.215],[700,1,10.149,105,131949.215],[700,1,10.149,105,131991.215],[700,1,10.149,105,132033.215],[700,1,10.149,105,132075.215],[700,1,10.149,105,132117.215],[700,1,10.149,105,132159.215],[700,1,10.149,105,132201.215],[700,1,10.149,105,132243.215],[700,1,10.149,105,132285.215],[700,1,10.149,105,132327.215],[700,1,10.149,105,132369.215],[700,1,10.149,105,132411.215],[700,1,10.149,105,132453.215],[700,1,10.149,105,132495.215],[700,1,10.149,105,132537.215],[700,1,10.149,105,132579.215],[700,1,10.149,105,132621.215],[700,1,10.149,105,132663.215],[700,1,10.149,105,132705.215],[700,1,10.149,105,132747.215],[700,1,10.149,105,132789.215],[700,1,10.149,105,132831.215],[700,1,10.149,105,132873.215],[700,1,10.149,105,132915.215],[700,1,10.149,105,132957.215],[700,1,10.149,105,132999.215],[700,1,10.149,105,133041.215],[700,1,10.149,105,133083.215],[700,1,10.149,105,133125.215],[700,1,10.149,105,133167.215],[700,1,10.149,105,133209.215],[700,1,10.149,105,133251.215],[700,1,10.149,105,133293.215],[700,1,10.149,105,133335.215],[700,1,10.149,105,133377.215],[700,1,10.149,105,133419.215],[700,1,10.149,105,133461.215],[700,1,10.149,105,133503.215],[700,1,10.149,105,133545.215],[700,1,10.149,105,133587.215]],"sparks":[[3.3012,17,0],[3.3488,10,0],[3.3964,3,1],[3.444,14,0],[3.4917,7,1],[3.5393,18,0],[3.5869,11,0],[3.6345,4,0],[3.6821,15,0],[3.7298,8,0],[3.7774,1,0],[3.825,12,0],[3.8726,5,0],[3.9202,16,0],[3.9679,9,1],[4.0155,2,1],[4.0631,13,1],[4.1107,6,1],[4.1583,17,1],[4.2058,10,1],[4.252,3,1],[4.2952,14,1],[4.334,7,1],[4.3684,18,1],[4.3988,11,1],[4.4259,4,1],[4.4501,15,1],[4.4722,8,1],[4.4924,1,1],[4.5111,12,1],[4.5285,5,1],[4.5449,16,1],[4.5603,9,1],[4.5749,2,1],[4.5887,13,1],[4.602,6,1],[4.6147,17,1],[4.627,10,1],[4.6387,3,1],[4.6501,14,1],[4.6611,7,1],[4.6718,18,1],[4.6822,11,1],[4.6924,4,1],[4.7022,15,1],[4.7119,8,1],[4.7213,1,1],[4.7305,12,1],[4.7396,5,1],[4.7484,16,1],[4.7572,9,1],[4.7657,2,1],[4.7741,13,1],[4.7824,6,1],[4.7906,17,1],[4.7986,10,1],[4.8066,3,1],[4.8144,14,1],[4.8222,7,1],[4.8298,18,1],[4.8374,11,1],[4.8449,4,1],[4.8523,15,1],[4.8596,8,1],[4.8669,1,1],[4.8741,12,1],[4.8812,5,1],[4.8883,16,1],[4.8953,9,1],[4.9023,2,1],[4.9093,13,1],[4.9161,6,1],[4.923,17,1],[4.9298,10,1],[4.9366,3,1],[4.9433,14,1],[4.95,7,1],[4.9567,18,1],[4.9633,11,1],[4.9699,4,1],[4.9765,15,1],[4.9831,8,1],[4.9896,1,1],[4.9961,12,1],[5.0026,5,1],[5.0091,16,1],[5.0156,9,1],[5.022,2,1],[5.0285,13,1],[5.0349,6,1],[5.0413,17,1],[5.0477,10,1],[5.0541,3,1],[5.0605,14,1],[5.0669,7,1],[5.0733,18,1],[5.0796,11,1],[5.086,4,1],[5.0924,15,1],[5.0987,8,1],[5.1051,1,1],[5.1114,12,1],[5.1178,5,1],[5.1241,16,1],[5.1305,9,1],[5.1368,2,1],[5.1432,13,1],[5.1495,6,1],[5.1559,17,1],[5.1622,10,1],[5.1686,3,1],[5.1749,14,1],[5.1813,7,1],[5.1876,18,1],[5.194,11,1],[5.2003,4,1],[5.2067,15,1],[5.213,8,1],[5.2194,1,1],[5.2257,12,1],[5.2321,5,1],[5.2384,16,1],[5.2448,9,1],[5.2512,2,1],[5.2575,13,1],[5.2639,6,1],[5.2703,17,1],[5.2766,10,1],[5.283,3,1],[5.2894,14,1],[5.2958,7,1],[5.3022,18,1],[5.3085,11,1],[5.3149,4,1],[5.3213,15,1],[5.3278,8,1],[5.3342,1,1],[5.3406,12,1],[5.347,5,1],[5.3534,16,1],[5.3599,9,1],[5.3663,2,1],[5.3728,13,1],[5.3793,6,1],[5.3857,17,1],[5.3922,10,1],[5.3987,3,1],[5.4052,14,1],[5.4118,7,1],[5.4183,18,1],[5.4248,11,1],[5.4314,4,1],[5.438,15,1],[5.4446,8,1],[5.4512,1,1],[5.4578,12,1],[5.4644,5,1],[5.471,16,1],[5.4777,9,1],[5.4844,2,1],[5.4911,13,1],[5.4978,6,1],[5.5045,17,1],[5.5113,10,1],[5.518,3,1],[5.5248,14,1],[5.5316,7,1],[5.5385,18,1],[5.5453,11,1],[5.5522,4,1],[5.5591,15,1],[5.566,8,1],[5.573,1,1],[5.5799,12,1],[5.5869,5,1],[5.594,16,1],[5.601,9,1],[5.6081,2,1],[5.6152,13,1],[5.6223,6,1],[5.6295,17,1],[5.6367,10,1],[5.6439,3,1],[5.6512,14,1],[5.6585,7,1],[5.6658,18,1],[5.6731,11,1],[5.6805,4,1],[5.688,15,1],[5.6954,8,1],[5.7029,1,1],[5.7104,12,1],[5.718,5,1],[5.7256,16,1],[5.7332,9,1],[5.7409,2,1],[5.7486,13,1],[5.7564,6,1],[5.7642,17,1],[5.772,10,1],[5.7799,3,1],[5.7878,14,1],[5.7958,7,1],[5.8038,18,1],[5.8119,11,1],[5.8199,4,1],[5.8281,15,1],[5.8363,8,1],[5.8445,1,1],[5.8527,12,1],[5.8611,5,1],[5.8694,16,1],[5.8778,9,1],[5.8862,2,1],[5.8947,13,1],[5.9033,6,1],[5.9118,17,1],[5.9204,10,1],[5.9291,3,1],[5.9378,14,1],[5.9466,7,1],[5.9553,18,1],[5.9642,11,1],[5.973,4,1],[5.9819,15,1],[5.9909,8,1],[5.9999,1,1],[6.0089,12,1],[6.018,5,1],[6.0271,16,1],[6.0362,9,1],[6.0453,2,1],[6.0545,13,1],[6.0638,6,1],[6.073,17,1],[6.0823,10,1],[6.0916,3,1],[6.1009,14,1],[6.1103,7,1],[6.1196,18,1],[6.129,11,1],[6.1384,4,1],[6.1479,15,1],[6.1573,8,1],[6.1668,1,1],[6.1762,12,1],[6.1857,5,1],[6.1952,16,1],[6.2047,9,1],[6.2142,2,1],[6.2237,13,1],[6.2332,6,1],[6.2427,17,1],[6.2523,10,1],[6.2618,3,1],[6.2713,14,1],[6.2808,7,1],[6.2904,18,1],[6.2999,11,1],[6.3094,4,1],[6.3189,15,1],[6.3285,8,1],[6.338,1,1],[6.3475,12,1],[6.357,5,1],[6.3665,16,1],[6.3761,9,1],[6.3856,2,1],[6.3951,13,1],[6.4046,6,1],[6.4142,17,1],[6.4237,10,1],[6.4332,3,1],[6.4428,14,1],[6.4523,7,1],[6.4618,18,1],[6.4714,11,1],[6.481,4,1],[6.4906,15,1],[6.5003,8,1],[6.51,1,1],[6.5198,12,1],[6.5296,5,1],[6.5395,16,1],[6.5495,9,1],[6.5595,2,1],[6.5697,13,1],[6.58,6,1],[6.5905,17,1],[6.6011,10,1],[6.6119,3,1],[6.6229,14,1],[6.6341,7,1],[6.6456,18,1],[6.6573,11,1],[6.6694,4,1],[6.6818,15,1],[6.6947,8,1],[6.7081,1,1],[6.7219,12,1],[6.7365,5,1],[6.7517,16,1],[6.7678,9,1],[6.7849,2,1],[6.8031,13,1],[6.8229,6,1],[6.8444,17,1],[6.8681,10,1],[6.8947,3,1],[6.9252,14,1],[6.9609,7,1],[7.0045,18,1],[7.0604,11,1],[7.1375,4,1],[7.2551,15,1],[7.4431,8,1],[7.6788,1,1],[7.9169,12,1],[8.155,5,1],[8.3931,16,1],[8.6312,9,1],[8.8693,2,1],[9.1074,13,1],[9.3454,6,1],[9.5835,17,1],[9.8216,10,1],[10.0597,3,1],[10.2978,14,1],[10.5359,7,1],[10.774,18,1],[11.0121,11,1],[11.2502,4,1],[11.4883,15,1],[11.7264,8,1],[11.9645,1,1],[12.2026,12,1],[12.4407,5,1],[12.6789,16,1],[12.9321,9,1],[13.2608,2,1],[13.6411,13,1],[14.022,6,1],[14.403,17,1],[14.7839,10,1],[15.1649,3,1],[15.5458,14,1],[15.9268,7,1],[16.3077,18,1],[16.6887,11,1],[17.0696,4,1],[17.4506,15,1],[17.8316,8,1],[18.2125,1,1],[18.5935,12,1],[18.9744,5,1],[19.3554,16,1],[19.7363,9,1],[20.1173,2,1],[20.4982,13,1],[20.8792,6,1],[21.2601,17,1],[21.6411,10,1],[22.022,3,1],[22.403,14,1],[22.7839,7,1],[23.1649,18,1],[23.5458,11,1],[23.8995,4,1],[24.1178,15,1],[24.2689,8,1],[24.4034,1,1],[24.5368,12,1],[24.6701,5,1],[24.8034,16,1],[24.9368,9,1],[25.0701,2,1],[25.2034,13,1],[25.3368,6,1],[25.4701,17,1],[25.6034,10,1],[25.7368,3,1],[25.8701,14,1],[26.0034,7,1],[26.1368,18,1],[26.2701,11,1],[26.4034,4,1],[26.5368,15,1],[26.6701,8,1],[26.8034,1,1],[26.9368,12,1],[27.0701,5,1],[27.2034,16,1],[27.3368,9,1],[27.4701,2,1],[27.6034,13,1],[27.7368,6,1],[27.8701,17,1],[28.0034,10,1],[28.1368,3,1],[28.2701,14,1],[28.4034,7,1],[28.5356,18,1],[28.6568,11,1],[28.756,4,1],[28.8336,15,1],[28.8947,8,1],[28.9441,1,1],[28.9852,12,1],[29.0204,5,1],[29.0511,16,1],[29.0785,9,1],[29.1031,2,1],[29.1256,13,1],[29.1463,6,1],[29.1656,17,1],[29.1836,10,1],[29.2006,3,1],[29.2168,14,1],[29.2321,7,1],[29.2468,18,1],[29.2608,11,1],[29.2744,4,1],[29.2875,15,1],[29.3002,8,1],[29.3126,1,1],[29.3246,12,1],[29.3363,5,1],[29.3477,16,1],[29.359,9,1],[29.37,2,1],[29.3808,13,1],[29.3915,6,1],[29.402,17,1],[29.4123,10,1],[29.4226,3,1],[29.4327,14,1],[29.4428,7,1],[29.4527,18,1],[29.4626,11,1],[29.4724,4,1],[29.4822,15,1],[29.4919,8,1],[29.5015,1,1],[29.5112,12,1],[29.5208,5,1],[29.5303,16,1],[29.5399,9,1],[29.5494,2,1],[29.559,13,1],[29.5685,6,1],[29.578,17,1],[29.5876,10,1],[29.5971,3,1],[29.6066,14,1],[29.6161,7,1],[29.6257,18,1],[29.6352,11,1],[29.6447,4,1],[29.6542,15,1],[29.6638,8,1],[29.6733,1,1],[29.6828,12,1],[29.6923,5,1],[29.7019,16,1],[29.7114,9,1],[29.7209,2,1],[29.7304,13,1],[29.7399,6,1],[29.7495,17,1],[29.759,10,1],[29.7685,3,1],[29.778,14,1],[29.7876,7,1],[29.7971,18,1],[29.8066,11,1],[29.8161,4,1],[29.8256,15,1],[29.8351,8,1],[29.8446,1,1],[29.8541,12,1],[29.8636,5,1],[29.8731,16,1],[29.8825,9,1],[29.892,2,1],[29.9015,13,1],[29.9109,6,1],[29.9203,17,1],[29.9298,10,1],[29.9392,3,1],[29.9486,14,1],[29.9579,7,1],[29.9673,18,1],[29.9767,11,1],[29.986,4,1],[29.9953,15,1],[30.0046,8,1],[30.0139,1,1],[30.0231,12,1],[30.0324,5,1],[30.0416,16,1],[30.0508,9,1],[30.0599,2,1],[30.0691,13,1],[30.0782,6,1],[30.0873,17,1],[30.0963,10,1],[30.1053,3,1],[30.1143,14,1],[30.1233,7,1],[30.1322,18,1],[30.1411,11,1],[30.15,4,1],[30.1588,15,1],[30.1676,8,1],[30.1764,1,1],[30.1851,12,1],[30.1938,5,1],[30.2025,16,1],[30.2111,9,1],[30.2197,2,1],[30.2282,13,1],[30.2367,6,1],[30.2452,17,1],[30.2536,10,1],[30.262,3,1],[30.2703,14,1],[30.2786,7,1],[30.2869,18,1],[30.2951,11,1],[30.3033,4,1],[30.3114,15,1],[30.3195,8,1],[30.3275,1,1],[30.3356,12,1],[30.3435,5,1],[30.3514,16,1],[30.3593,9,1],[30.3672,2,1],[30.3749,13,1],[30.3827,6,1],[30.3904,17,1],[30.3981,10,1],[30.4057,3,1],[30.4133,14,1],[30.4208,7,1],[30.4283,18,1],[30.4358,11,1],[30.4432,4,1],[30.4505,15,1],[30.4579,8,1],[30.4651,1,1],[30.4724,12,1],[30.4796,5,1],[30.4867,16,1],[30.4939,9,1],[30.5009,2,1],[30.508,13,1],[30.515,6,1],[30.5219,17,1],[30.5289,10,1],[30.5357,3,1],[30.5426,14,1],[30.5494,7,1],[30.5561,18,1],[30.5629,11,1],[30.5696,4,1],[30.5762,15,1],[30.5828,8,1],[30.5894,1,1],[30.5959,12,1],[30.6024,5,1],[30.6089,16,1],[30.6153,9,1],[30.6217,2,1],[30.6281,13,1],[30.6344,6,1],[30.6407,17,1],[30.6469,10,1],[30.6532,3,1],[30.6594,14,1],[30.6655,7,1],[30.6716,18,1],[30.6777,11,1],[30.6838,4,1],[30.6898,15,1],[30.6958,8,1],[30.7017,1,1],[30.7077,12,1],[30.7136,5,1],[30.7194,16,1],[30.7253,9,1],[30.7311,2,1],[30.7369,13,1],[30.7426,6,1],[30.7483,17,1],[30.754,10,1],[30.7597,3,1],[30.7653,14,1],[30.7709,7,1],[30.7765,18,1],[30.7821,11,1],[30.7876,4,1],[30.7931,15,1],[30.7985,8,1],[30.804,1,1],[30.8094,12,1],[30.8148,5,1],[30.8202,16,1],[30.8255,9,1],[30.8308,2,1],[30.8361,13,1],[30.8414,6,1],[30.8466,17,1],[30.8518,10,1],[30.857,3,1],[30.8622,14,1],[30.8674,7,1],[30.8725,18,1],[30.8776,11,1],[30.8827,4,1],[30.8877,15,1],[30.8928,8,1],[30.8978,1,1],[30.9028,12,1],[30.9077,5,1],[30.9127,16,1],[30.9176,9,1],[30.9225,2,1],[30.9274,13,1],[30.9323,6,1],[30.9371,17,1],[30.942,10,1],[30.9468,3,1],[30.9516,14,1],[30.9563,7,1],[30.9611,18,1],[30.9658,11,1],[30.9705,4,1],[30.9752,15,1],[30.9799,8,1],[30.9845,1,1],[30.9892,12,1],[30.9938,5,1],[30.9984,16,1],[31.003,9,1],[31.0076,2,1],[31.0121,13,1],[31.0167,6,1],[31.0212,17,1],[31.0257,10,1],[31.0302,3,1],[31.0346,14,1],[31.0391,7,1],[31.0435,18,1],[31.0479,11,1],[31.0523,4,1],[31.0567,15,1],[31.0611,8,1],[31.0654,1,1],[31.0698,12,1],[31.0741,5,1],[31.0784,16,1],[31.0827,9,1],[31.087,2,1],[31.0913,13,1],[31.0955,6,1],[31.0998,17,1],[31.104,10,1],[31.1082,3,1],[31.1124,14,1],[31.1166,7,1],[31.1208,18,1],[31.1249,11,1],[31.1291,4,1],[31.1332,15,1],[31.1373,8,1],[31.1414,1,1],[31.1455,12,1],[31.1496,5,1],[31.1537,16,1],[31.1577,9,1],[31.1617,2,1],[31.1658,13,1],[31.1698,6,1],[31.1738,17,1],[31.1778,10,1],[31.1818,3,1],[31.1857,14,1],[31.1897,7,1],[31.1936,18,1],[31.1976,11,1],[31.2015,4,1],[31.2054,15,1],[31.2093,8,1],[31.2132,1,1],[31.2171,12,1],[31.2209,5,1],[31.2248,16,1],[31.2286,9,1],[31.2325,2,1],[31.2363,13,1],[31.2401,6,1],[31.2439,17,1],[31.2477,10,1],[31.2515,3,1],[31.2553,14,1],[31.259,7,1],[31.2628,18,1],[31.2665,11,1],[31.2703,4,1],[31.274,15,1],[31.2777,8,1],[31.2814,1,1],[31.2851,12,1],[31.2888,5,1],[31.2925,16,1],[31.2961,9,1],[31.2998,2,1],[31.3035,13,1],[31.3071,6,1],[31.3107,17,1],[31.3143,10,1],[31.318,3,1],[31.3216,14,1],[31.3252,7,1],[31.3288,18,1],[31.3323,11,1],[31.3359,4,1],[31.3395,15,1],[31.343,8,1],[31.3466,1,1],[31.3501,12,1],[31.3536,5,1],[31.3572,16,1],[31.3607,9,1],[31.3642,2,1],[31.3677,13,1],[31.3712,6,1],[31.3746,17,1],[31.3781,10,1],[31.3816,3,1],[31.3851,14,1],[31.3885,7,1],[31.3919,18,1],[31.3954,11,1],[31.3988,4,1],[31.4022,15,1],[31.4057,8,1],[31.4091,1,1],[31.4125,12,1],[31.4159,5,1],[31.4192,16,1],[31.4226,9,1],[31.426,2,1],[31.4294,13,1],[31.4327,6,1],[31.4361,17,1],[31.4394,10,1],[31.4428,3,1],[31.4461,14,1],[31.4494,7,1],[31.4527,18,1],[31.4561,11,1],[31.4594,4,1],[31.4627,15,1],[31.466,8,1],[31.4692,1,1],[31.4725,12,1],[31.4758,5,1],[31.4791,16,1],[31.4823,9,1],[31.4856,2,1],[31.4888,13,1],[31.4921,6,1],[31.4953,17,1],[31.4986,10,1],[31.5018,3,1],[31.505,14,1],[31.5082,7,1],[31.5114,18,1],[31.5146,11,1],[31.5178,4,1],[31.521,15,1],[31.5242,8,1],[31.5274,1,1],[31.5306,12,1],[31.5338,5,1],[31.5369,16,1],[31.5401,9,1],[31.5432,2,1],[31.5464,13,1],[31.5495,6,1],[31.5527,17,1],[31.5558,10,1],[31.5589,3,1],[31.5621,14,1],[31.5652,7,1],[31.5683,18,1],[31.5714,11,1],[31.5745,4,1],[31.5776,15,1],[31.5807,8,1],[31.5838,1,1],[31.5869,12,1],[31.59,5,1],[31.593,16,1],[31.5961,9,1],[31.5992,2,1],[31.6022,13,1],[31.6053,6,1],[31.6084,17,1],[31.6114,10,1],[31.6144,3,1],[31.6175,14,1],[31.6205,7,1],[31.6235,18,1],[31.6266,11,1],[31.6296,4,1],[31.6326,15,1],[31.6356,8,1],[31.6386,1,1],[31.6416,12,1],[31.6446,5,1],[31.6476,16,1],[31.6506,9,1],[31.6536,2,1],[31.6566,13,1],[31.6596,6,1],[31.6625,17,1],[31.6655,10,1],[31.6685,3,1],[31.6714,14,1],[31.6744,7,1],[31.6774,18,1],[31.6803,11,1],[31.6833,4,1],[31.6862,15,1],[31.6891,8,1],[31.6921,1,1],[31.695,12,1],[31.6979,5,1],[31.7009,16,1],[31.7038,9,1],[31.7067,2,1],[31.7096,13,1],[31.7125,6,1],[31.7154,17,1],[31.7183,10,1],[31.7212,3,1],[31.7241,14,1],[31.727,7,1],[31.7299,18,1],[31.7328,11,1],[31.7357,4,1],[31.7385,15,1],[31.7414,8,1],[31.7443,1,1],[31.7472,12,1],[31.75,5,1],[31.7529,16,1],[31.7557,9,1],[31.7586,2,1],[31.7615,13,1],[31.7643,6,1],[31.7671,17,1],[31.77,10,1],[31.7728,3,1],[31.7757,14,1],[31.7785,7,1],[31.7813,18,1],[31.7842,11,1],[31.787,4,1],[31.7898,15,1],[31.7926,8,1],[31.7954,1,1],[31.7982,12,1],[31.8011,5,1],[31.8039,16,1],[31.8067,9,1],[31.8095,2,1],[31.8123,13,1],[31.8151,6,1],[31.8178,17,1],[31.8206,10,1],[31.8234,3,1],[31.8262,14,1],[31.829,7,1],[31.8318,18,1],[31.8345,11,1],[31.8373,4,1],[31.8401,15,1],[31.8428,8,1],[31.8456,1,1],[31.8484,12,1],[31.8511,5,1],[31.8539,16,1],[31.8566,9,1],[31.8594,2,1],[31.8621,13,1],[31.8649,6,1],[31.8676,17,1],[31.8704,10,1],[31.8731,3,1],[31.8758,14,1],[31.8786,7,1],[31.8813,18,1],[31.884,11,1],[31.8868,4,1],[31.8895,15,1],[31.8922,8,1],[31.8949,1,1],[31.8976,12,1],[31.9004,5,1],[31.9031,16,1],[31.9058,9,1],[31.9085,2,1],[31.9112,13,1],[31.9139,6,1],[31.9166,17,1],[31.9193,10,1],[31.922,3,1],[31.9247,14,1],[31.9274,7,1],[31.9301,18,1],[31.9328,11,1],[31.9355,4,1],[31.9381,15,1],[31.9408,8,1],[31.9435,1,1],[31.9462,12,1],[31.9489,5,1],[31.9515,16,1],[31.9542,9,1],[31.9569,2,1],[31.9595,13,1],[31.9622,6,1],[31.9649,17,1],[31.9675,10,1],[31.9702,3,1],[31.9729,14,1],[31.9755,7,1],[31.9782,18,1],[31.9808,11,1],[31.9835,4,1],[31.9861,15,1],[31.9888,8,1],[31.9914,1,1],[31.9941,12,1],[31.9967,5,1],[31.9993,16,1],[32.002,9,1],[32.0046,2,1],[32.0072,13,1],[32.0099,6,1],[32.0125,17,1],[32.0151,10,1],[32.0178,3,1],[32.0204,14,1],[32.023,7,1],[32.0256,18,1],[32.0283,11,1],[32.0309,4,1],[32.0335,15,1],[32.0361,8,1],[32.0387,1,1],[32.0413,12,1],[32.044,5,1],[32.0466,16,1],[32.0492,9,1],[32.0518,2,1],[32.0544,13,1],[32.057,6,1],[32.0596,17,1],[32.0622,10,1],[32.0648,3,1],[32.0674,14,1],[32.07,7,1],[32.0726,18,1],[32.0752,11,1],[32.0778,4,1],[32.0804,15,1],[32.0829,8,1],[32.0855,1,1],[32.0881,12,1],[32.0907,5,1],[32.0933,16,1],[32.0959,9,1],[32.0985,2,1],[32.101,13,1],[32.1036,6,1],[32.1062,17,1],[32.1088,10,1],[32.1113,3,1],[32.1139,14,1],[32.1165,7,1],[32.1191,18,1],[32.1216,11,1],[32.1242,4,1],[32.1268,15,1],[32.1293,8,1],[32.1319,1,1],[32.1345,12,1],[32.137,5,1],[32.1396,16,1],[32.1421,9,1],[32.1447,2,1],[32.1473,13,1],[32.1498,6,1],[32.1524,17,1],[32.1549,10,1],[32.1575,3,1],[32.16,14,1],[32.1626,7,1],[32.1651,18,1],[32.1677,11,1],[32.1702,4,1],[32.1728,15,1],[32.1753,8,1],[32.1779,1,1],[32.1804,12,1],[32.183,5,1],[32.1855,16,1],[32.188,9,1],[32.1906,2,1],[32.1931,13,1],[32.1957,6,1],[32.1982,17,1],[32.2007,10,1],[32.2033,3,1],[32.2058,14,1],[32.2083,7,1],[32.2109,18,1],[32.2134,11,1],[32.2159,4,1],[32.2185,15,1],[32.221,8,1],[32.2235,1,1],[32.226,12,1],[32.2286,5,1],[32.2311,16,1],[32.2336,9,1],[32.2361,2,1],[32.2387,13,1],[32.2412,6,1],[32.2437,17,1],[32.2462,10,1],[32.2487,3,1],[32.2513,14,1],[32.2538,7,1],[32.2563,18,1],[32.2588,11,1],[32.2613,4,1],[32.2638,15,1],[32.2664,8,1],[32.2689,1,1],[32.2714,12,1],[32.2739,5,1],[32.2764,16,1],[32.2789,9,1],[32.2814,2,1],[32.2839,13,1],[32.2864,6,1],[32.289,17,1],[32.2915,10,1],[32.294,3,1],[32.2965,14,1],[32.299,7,1],[32.3015,18,1],[32.304,11,1],[32.3065,4,1],[32.309,15,1],[32.3115,8,1],[32.314,1,1],[32.3165,12,1],[32.319,5,1],[32.3215,16,1],[32.324,9,1],[32.3265,2,1],[32.329,13,1],[32.3315,6,1],[32.334,17,1],[32.3365,10,1],[32.339,3,1],[32.3415,14,1],[32.344,7,1],[32.3465,18,1],[32.349,11,1],[32.3515,4,1],[32.3539,15,1],[32.3564,8,1],[32.3589,1,1],[32.3614,12,1],[32.3639,5,1],[32.3664,16,1],[32.3689,9,1],[32.3714,2,1],[32.3739,13,1],[32.3764,6,1],[32.3789,17,1],[32.3813,10,1],[32.3838,3,1],[32.3863,14,1],[32.3888,7,1],[32.3913,18,1],[32.3938,11,1],[32.3963,4,1],[32.3987,15,1],[32.4012,8,1],[32.4037,1,1],[32.4062,12,1],[32.4087,5,1],[32.4112,16,1],[32.4137,9,1],[32.4161,2,1],[32.4186,13,1],[32.4211,6,1],[32.4236,17,1],[32.4261,10,1],[32.4285,3,1],[32.431,14,1],[32.4335,7,1],[32.436,18,1],[32.4385,11,1],[32.4409,4,1],[32.4434,15,1],[32.4459,8,1],[32.4484,1,1],[32.4509,12,1],[32.4533,5,1],[32.4558,16,1],[32.4583,9,1],[32.4608,2,1],[32.4632,13,1],[32.4657,6,1],[32.4682,17,1],[32.4707,10,1],[32.4732,3,1],[32.4756,14,1],[32.4781,7,1],[32.4806,18,1],[32.4831,11,1],[32.4855,4,1],[32.488,15,1],[32.4905,8,1],[32.493,1,1],[32.4954,12,1],[32.4979,5,1],[32.5004,16,1],[32.5029,9,1],[32.5053,2,1],[32.5078,13,1],[32.5103,6,1],[32.5127,17,1],[32.5152,10,1],[32.5177,3,1],[32.5202,14,1],[32.5226,7,1],[32.5251,18,1],[32.5276,11,1],[32.5301,4,1],[32.5325,15,1],[32.535,8,1],[32.5375,1,1],[32.5399,12,1],[32.5424,5,1],[32.5449,16,1],[32.5474,9,1],[32.5498,2,1],[32.5523,13,1],[32.5548,6,1],[32.5572,17,1],[32.5597,10,1],[32.5622,3,1],[32.5647,14,1],[32.5671,7,1],[32.5696,18,1],[32.5721,11,1],[32.5745,4,1],[32.577,15,1],[32.5795,8,1],[32.5819,1,1],[32.5844,12,1],[32.5869,5,1],[32.5894,16,1],[32.5918,9,1],[32.5943,2,1],[32.5968,13,1],[32.5992,6,1],[32.6017,17,1],[32.6042,10,1],[32.6066,3,1],[32.6091,14,1],[32.6116,7,1],[32.6141,18,1],[32.6165,11,1],[32.619,4,1],[32.6215,15,1],[32.6239,8,1],[32.6264,1,1],[32.6289,12,1],[32.6313,5,1],[32.6338,16,1],[32.6363,9,1],[32.6388,2,1],[32.6412,13,1],[32.6437,6,1],[32.6462,17,1],[32.6486,10,1],[32.6511,3,1],[32.6536,14,1],[32.656,7,1],[32.6585,18,1],[32.661,11,1],[32.6634,4,1],[32.6659,15,1],[32.6684,8,1],[32.6709,1,1],[32.6733,12,1],[32.6758,5,1],[32.6783,16,1],[32.6807,9,1],[32.6832,2,1],[32.6857,13,1],[32.6881,6,1],[32.6906,17,1],[32.6931,10,1],[32.6955,3,1],[32.698,14,1],[32.7005,7,1],[32.7029,18,1],[32.7054,11,1],[32.7079,4,1],[32.7104,15,1],[32.7128,8,1],[32.7153,1,1],[32.7178,12,1],[32.7202,5,1],[32.7227,16,1],[32.7252,9,1],[32.7276,2,1],[32.7301,13,1],[32.7326,6,1],[32.735,17,1],[32.7375,10,1],[32.74,3,1],[32.7425,14,1],[32.7449,7,1],[32.7474,18,1],[32.7499,11,1],[32.7523,4,1],[32.7548,15,1],[32.7573,8,1],[32.7597,1,1],[32.7622,12,1],[32.7647,5,1],[32.7671,16,1],[32.7696,9,1],[32.7721,2,1],[32.7746,13,1],[32.777,6,1],[32.7795,17,1],[32.782,10,1],[32.7844,3,1],[32.7869,14,1],[32.7894,7,1],[32.7918,18,1],[32.7943,11,1],[32.7968,4,1],[32.7992,15,1],[32.8017,8,1],[32.8042,1,1],[32.8067,12,1],[32.8091,5,1],[32.8116,16,1],[32.8141,9,1],[32.8165,2,1],[32.819,13,1],[32.8215,6,1],[32.8239,17,1],[32.8264,10,1],[32.8289,3,1],[32.8313,14,1],[32.8338,7,1],[32.8363,18,1],[32.8388,11,1],[32.8412,4,1],[32.8437,15,1],[32.8462,8,1],[32.8486,1,1],[32.8511,12,1],[32.8536,5,1],[32.856,16,1],[32.8585,9,1],[32.861,2,1],[32.8634,13,1],[32.8659,6,1],[32.8684,17,1],[32.8709,10,1],[32.8733,3,1],[32.8758,14,1],[32.8783,7,1],[32.8807,18,1],[32.8832,11,1],[32.8857,4,1],[32.8881,15,1],[32.8906,8,1],[32.8931,1,1],[32.8955,12,1],[32.898,5,1],[32.9005,16,1],[32.9029,9,1],[32.9054,2,1],[32.9079,13,1],[32.9104,6,1],[32.9128,17,1],[32.9153,10,1],[32.9178,3,1],[32.9202,14,1],[32.9227,7,1],[32.9252,18,1],[32.9276,11,1],[32.9301,4,1],[32.9326,15,1],[32.935,8,1],[32.9375,1,1],[32.94,12,1],[32.9425,5,1],[32.9449,16,1],[32.9474,9,1],[32.9499,2,1],[32.9523,13,1],[32.9548,6,1],[32.9573,17,1],[32.9597,10,1],[32.9622,3,1],[32.9647,14,1],[32.9671,7,1],[32.9696,18,1],[32.9721,11,1],[32.9746,4,1],[32.977,15,1],[32.9795,8,1],[32.982,1,1],[32.9844,12,1],[32.9869,5,1],[32.9894,16,1],[32.9918,9,1],[32.9943,2,1],[32.9968,13,1],[32.9992,6,1],[33.0017,17,1],[33.0042,10,1],[33.0067,3,1],[33.0091,14,1],[33.0116,7,1],[33.0141,18,1],[33.0165,11,1],[33.019,4,1],[33.0215,15,1],[33.0239,8,1],[33.0264,1,1],[33.0289,12,1],[33.0313,5,1],[33.0338,16,1],[33.0363,9,1],[33.0388,2,1],[33.0412,13,1],[33.0437,6,1],[33.0462,17,1],[33.0486,10,1],[33.0511,3,1],[33.0536,14,1],[33.056,7,1],[33.0585,18,1],[33.061,11,1],[33.0634,4,1],[33.0659,15,1],[33.0684,8,1],[33.0709,1,1],[33.0733,12,1],[33.0758,5,1],[33.0783,16,1],[33.0807,9,1],[33.0832,2,1],[33.0857,13,1],[33.0881,6,1],[33.0906,17,1],[33.0931,10,1],[33.0955,3,1],[33.098,14,1],[33.1005,7,1],[33.1029,18,1],[33.1054,11,1],[33.1079,4,1],[33.1104,15,1],[33.1128,8,1],[33.1153,1,1],[33.1178,12,1],[33.1202,5,1],[33.1227,16,1],[33.1252,9,1],[33.1276,2,1],[33.1301,13,1],[33.1326,6,1],[33.135,17,1],[33.1375,10,1],[33.14,3,1],[33.1425,14,1],[33.1449,7,1],[33.1474,18,1],[33.1499,11,1],[33.1523,4,1],[33.1548,15,1],[33.1573,8,1],[33.1597,1,1],[33.1622,12,1],[33.1647,5,1],[33.1671,16,1],[33.1696,9,1],[33.1721,2,1],[33.1746,13,1],[33.177,6,1],[33.1795,17,1],[33.182,10,1],[33.1844,3,1],[33.1869,14,1],[33.1894,7,1],[33.1918,18,1],[33.1943,11,1],[33.1968,4,1],[33.1992,15,1],[33.2017,8,1],[33.2042,1,1],[33.2067,12,1],[33.2091,5,1],[33.2116,16,1],[33.2141,9,1],[33.2165,2,1],[33.219,13,1],[33.2215,6,1],[33.2239,17,1],[33.2264,10,1],[33.2289,3,1],[33.2313,14,1],[33.2338,7,1],[33.2363,18,1],[33.2388,11,1],[33.2412,4,1],[33.2437,15,1],[33.2462,8,1],[33.2486,1,1],[33.2511,12,1],[33.2536,5,1],[33.256,16,1],[33.2585,9,1],[33.261,2,1],[33.2634,13,1],[33.2659,6,1],[33.2684,17,1],[33.2709,10,1],[33.2733,3,1],[33.2758,14,1],[33.2783,7,1],[33.2807,18,1],[33.2832,11,1],[33.2857,4,1],[33.2881,15,1],[33.2906,8,1],[33.2931,1,1],[33.2955,12,1],[33.298,5,1],[33.3005,16,1],[33.3029,9,1],[33.3054,2,1],[33.3079,13,1],[33.3104,6,1],[33.3128,17,1],[33.3153,10,1],[33.3178,3,1],[33.3202,14,1],[33.3227,7,1],[33.3252,18,1],[33.3276,11,1],[33.3301,4,1],[33.3326,15,1],[33.335,8,1],[33.3375,1,1],[33.34,12,1],[33.3425,5,1],[33.3449,16,1],[33.3474,9,1],[33.3499,2,1],[33.3523,13,1],[33.3548,6,1],[33.3573,17,1],[33.3597,10,1],[33.3622,3,1],[33.3647,14,1],[33.3671,7,1],[33.3696,18,1],[33.3721,11,1],[33.3746,4,1],[33.377,15,1],[33.3795,8,1],[33.382,1,1],[33.3844,12,1],[33.3869,5,1],[33.3894,16,1],[33.3918,9,1],[33.3943,2,1],[33.3968,13,1],[33.3992,6,1],[33.4017,17,1],[33.4042,10,1],[33.4067,3,1],[33.4091,14,1],[33.4116,7,1],[33.4141,18,1],[33.4165,11,1],[33.419,4,1],[33.4215,15,1],[33.4239,8,1],[33.4264,1,1],[33.4289,12,1],[33.4313,5,1],[33.4338,16,1],[33.4363,9,1],[33.4388,2,1],[33.4412,13,1],[33.4437,6,1],[33.4462,17,1],[33.4486,10,1],[33.4511,3,1],[33.4536,14,1],[33.456,7,1],[33.4585,18,1],[33.461,11,1],[33.4634,4,1],[33.4659,15,1],[33.4684,8,1],[33.4709,1,1],[33.4733,12,1],[33.4758,5,1],[33.4783,16,1],[33.4807,9,1],[33.4832,2,1],[33.4857,13,1],[33.4881,6,1],[33.4906,17,1],[33.4931,10,1],[33.4955,3,1],[33.498,14,1],[33.5005,7,1],[33.5029,18,1],[33.5054,11,1],[33.5079,4,1],[33.5104,15,1],[33.5128,8,1],[33.5153,1,1],[33.5178,12,1],[33.5202,5,1],[33.5227,16,1],[33.5252,9,1],[33.5276,2,1],[33.5301,13,1],[33.5326,6,1],[33.535,17,1],[33.5375,10,1],[33.54,3,1],[33.5425,14,1],[33.5449,7,1],[33.5474,18,1],[33.5499,11,1],[33.5523,4,1],[33.5548,15,1],[33.5573,8,1],[33.5597,1,1],[33.5622,12,1],[33.5647,5,1],[33.5671,16,1],[33.5696,9,1],[33.5721,2,1],[33.5746,13,1],[33.577,6,1],[33.5795,17,1],[33.582,10,1],[33.5844,3,1],[33.5869,14,1],[33.5894,7,1],[33.5918,18,1],[33.5943,11,1],[33.5968,4,1],[33.5992,15,1],[33.6017,8,1],[33.6042,1,1],[33.6067,12,1],[33.6091,5,1],[33.6116,16,1],[33.6141,9,1],[33.6165,2,1],[33.619,13,1],[33.6215,6,1],[33.6239,17,1],[33.6264,10,1],[33.6289,3,1],[33.6313,14,1],[33.6338,7,1],[33.6363,18,1],[33.6388,11,1],[33.6412,4,1],[33.6437,15,1],[33.6462,8,1],[33.6486,1,1],[33.6511,12,1],[33.6536,5,1],[33.656,16,1],[33.6585,9,1],[33.661,2,1],[33.6634,13,1],[33.6659,6,1],[33.6684,17,1],[33.6709,10,1],[33.6733,3,1],[33.6758,14,1],[33.6783,7,1],[33.6807,18,1],[33.6832,11,1],[33.6857,4,1],[33.6881,15,1],[33.6906,8,1],[33.6931,1,1],[33.6955,12,1],[33.698,5,1],[33.7005,16,1],[33.7029,9,1],[33.7054,2,1],[33.7079,13,1],[33.7104,6,1],[33.7128,17,1],[33.7153,10,1],[33.7178,3,1],[33.7202,14,1],[33.7227,7,1],[33.7252,18,1],[33.7276,11,1],[33.7301,4,1],[33.7326,15,1],[33.735,8,1],[33.7375,1,1],[33.74,12,1],[33.7425,5,1],[33.7449,16,1],[33.7474,9,1],[33.7499,2,1],[33.7523,13,1],[33.7548,6,1],[33.7573,17,1],[33.7597,10,1],[33.7622,3,1],[33.7647,14,1],[33.7671,7,1],[33.7696,18,1],[33.7721,11,1],[33.7746,4,1],[33.777,15,1],[33.7795,8,1],[33.782,1,1],[33.7844,12,1],[33.7869,5,1],[33.7894,16,1],[33.7918,9,1],[33.7943,2,1],[33.7968,13,1],[33.7992,6,1],[33.8017,17,1],[33.8042,10,1],[33.8067,3,1],[33.8091,14,1],[33.8116,7,1],[33.8141,18,1],[33.8165,11,1],[33.819,4,1],[33.8215,15,1],[33.8239,8,1],[33.8264,1,1],[33.8289,12,1],[33.8313,5,1],[33.8338,16,1],[33.8363,9,1],[33.8388,2,1],[33.8412,13,1],[33.8437,6,1],[33.8462,17,1],[33.8486,10,1],[33.8511,3,1],[33.8536,14,1],[33.856,7,1],[33.8585,18,1],[33.861,11,1],[33.8634,4,1],[33.8659,15,1],[33.8684,8,1],[33.8709,1,1],[33.8733,12,1],[33.8758,5,1],[33.8783,16,1],[33.8807,9,1],[33.8832,2,1],[33.8857,13,1],[33.8881,6,1],[33.8906,17,1],[33.8931,10,1],[33.8955,3,1],[33.898,14,1],[33.9005,7,1],[33.9029,18,1],[33.9054,11,1],[33.9079,4,1],[33.9104,15,1],[33.9128,8,1],[33.9153,1,1],[33.9178,12,1],[33.9202,5,1],[33.9227,16,1],[33.9252,9,1],[33.9276,2,1],[33.9301,13,1],[33.9326,6,1],[33.935,17,1],[33.9375,10,1],[33.94,3,1],[33.9425,14,1],[33.9449,7,1],[33.9474,18,1],[33.9499,11,1],[33.9523,4,1],[33.9548,15,1],[33.9573,8,1],[33.9597,1,1],[33.9622,12,1],[33.9647,5,1],[33.9671,16,1],[33.9696,9,1],[33.9721,2,1],[33.9746,13,1],[33.977,6,1],[33.9795,17,1],[33.982,10,1],[33.9844,3,1],[33.9869,14,1],[33.9894,7,1],[33.9918,18,1],[33.9943,11,1],[33.9968,4,1],[33.9992,15,1],[34.0017,8,1],[34.0042,1,1],[34.0067,12,1],[34.0091,5,1],[34.0116,16,1],[34.0141,9,1],[34.0165,2,1],[34.019,13,1],[34.0215,6,1],[34.0239,17,1],[34.0264,10,1],[34.0289,3,1],[34.0313,14,1],[34.0338,7,1],[34.0363,18,1],[34.0388,11,1],[34.0412,4,1],[34.0437,15,1],[34.0462,8,1],[34.0486,1,1],[34.0511,12,1],[34.0536,5,1],[34.056,16,1],[34.0585,9,1],[34.061,2,1],[34.0634,13,1],[34.0659,6,1],[34.0684,17,1],[34.0709,10,1],[34.0733,3,1],[34.0758,14,1],[34.0783,7,1],[34.0807,18,1],[34.0832,11,1],[34.0857,4,1],[34.0881,15,1],[34.0906,8,1],[34.0931,1,1],[34.0955,12,1],[34.098,5,1],[34.1005,16,1],[34.1029,9,1],[34.1054,2,1],[34.1079,13,1],[34.1104,6,1],[34.1128,17,1],[34.1153,10,1],[34.1178,3,1],[34.1202,14,1],[34.1227,7,1],[34.1252,18,1],[34.1276,11,1],[34.1301,4,1],[34.1326,15,1],[34.135,8,1],[34.1375,1,1],[34.14,12,1],[34.1425,5,1],[34.1449,16,1],[34.1474,9,1],[34.1499,2,1],[34.1523,13,1],[34.1548,6,1],[34.1573,17,1],[34.1597,10,1],[34.1622,3,1],[34.1647,14,1],[34.1671,7,1],[34.1696,18,1],[34.1721,11,1],[34.1746,4,1],[34.177,15,1],[34.1795,8,1],[34.182,1,1],[34.1844,12,1],[34.1869,5,1],[34.1894,16,1],[34.1918,9,1],[34.1943,2,1],[34.1968,13,1],[34.1992,6,1],[34.2017,17,1],[34.2042,10,1],[34.2067,3,1],[34.2091,14,1],[34.2116,7,1],[34.2141,18,1],[34.2165,11,1],[34.219,4,1],[34.2215,15,1],[34.2239,8,1],[34.2264,1,1],[34.2289,12,1],[34.2313,5,1],[34.2338,16,1],[34.2363,9,1],[34.2388,2,1],[34.2412,13,1],[34.2437,6,1],[34.2462,17,1],[34.2486,10,1],[34.2511,3,1],[34.2536,14,1],[34.256,7,1],[34.2585,18,1],[34.261,11,1],[34.2634,4,1],[34.2659,15,1],[34.2684,8,1],[34.2709,1,1],[34.2733,12,1],[34.2758,5,1],[34.2783,16,1],[34.2807,9,1],[34.2832,2,1],[34.2857,13,1],[34.2881,6,1],[34.2906,17,1],[34.2931,10,1],[34.2955,3,1],[34.298,14,1],[34.3005,7,1],[34.3029,18,1],[34.3054,11,1],[34.3079,4,1],[34.3104,15,1],[34.3128,8,1],[34.3153,1,1],[34.3178,12,1],[34.3202,5,1],[34.3227,16,1],[34.3252,9,1],[34.3276,2,1],[34.3301,13,1],[34.3326,6,1],[34.335,17,1],[34.3375,10,1],[34.34,3,1],[34.3425,14,1],[34.3449,7,1],[34.3474,18,1],[34.3499,11,1],[34.3523,4,1],[34.3548,15,1],[34.3573,8,1],[34.3597,1,1],[34.3622,12,1],[34.3647,5,1],[34.3671,16,1],[34.3696,9,1],[34.3721,2,1],[34.3746,13,1],[34.377,6,1],[34.3795,17,1],[34.382,10,1],[34.3844,3,1],[34.3869,14,1],[34.3894,7,1],[34.3918,18,1],[34.3943,11,1],[34.3968,4,1],[34.3992,15,1],[34.4017,8,1],[34.4042,1,1],[34.4067,12,1],[34.4091,5,1],[34.4116,16,1],[34.4141,9,1],[34.4165,2,1],[34.419,13,1],[34.4215,6,1],[34.4239,17,1],[34.4264,10,1],[34.4289,3,1],[34.4313,14,1],[34.4338,7,1],[34.4363,18,1],[34.4388,11,1],[34.4412,4,1],[34.4437,15,1],[34.4462,8,1],[34.4486,1,1],[34.4511,12,1],[34.4536,5,1],[34.456,16,1],[34.4585,9,1],[34.461,2,1],[34.4634,13,1],[34.4659,6,1],[34.4684,17,1],[34.4709,10,1],[34.4733,3,1],[34.4758,14,1],[34.4783,7,1],[34.4807,18,1],[34.4832,11,1],[34.4857,4,1],[34.4881,15,1],[34.4906,8,1],[34.4931,1,1],[34.4955,12,1],[34.498,5,1],[34.5005,16,1],[34.5029,9,1],[34.5054,2,1],[34.5079,13,1],[34.5104,6,1],[34.5128,17,1],[34.5153,10,1],[34.5178,3,1],[34.5202,14,1],[34.5227,7,1],[34.5252,18,1],[34.5276,11,1],[34.5301,4,1],[34.5326,15,1],[34.535,8,1],[34.5375,1,1],[34.54,12,1],[34.5425,5,1],[34.5449,16,1],[34.5474,9,1],[34.5499,2,1],[34.5523,13,1],[34.5548,6,1],[34.5573,17,1],[34.5597,10,1],[34.5622,3,1],[34.5647,14,1],[34.5671,7,1],[34.5696,18,1],[34.5721,11,1],[34.5746,4,1],[34.577,15,1],[34.5795,8,1],[34.582,1,1],[34.5844,12,1],[34.5869,5,1],[34.5894,16,1],[34.5918,9,1],[34.5943,2,1],[34.5968,13,1],[34.5992,6,1],[34.6017,17,1],[34.6042,10,1],[34.6067,3,1],[34.6091,14,1],[34.6116,7,1],[34.6141,18,1],[34.6165,11,1],[34.619,4,1],[34.6215,15,1],[34.6239,8,1],[34.6264,1,1],[34.6289,12,1],[34.6313,5,1],[34.6338,16,1],[34.6363,9,1],[34.6388,2,1],[34.6412,13,1],[34.6437,6,1],[34.6462,17,1],[34.6486,10,1],[34.6511,3,1],[34.6536,14,1],[34.656,7,1],[34.6585,18,1],[34.661,11,1],[34.6634,4,1],[34.6659,15,1],[34.6684,8,1],[34.6709,1,1],[34.6733,12,1],[34.6758,5,1],[34.6783,16,1],[34.6807,9,1],[34.6832,2,1],[34.6857,13,1],[34.6881,6,1],[34.6906,17,1],[34.6931,10,1],[34.6955,3,1],[34.698,14,1],[34.7005,7,1],[34.7029,18,1],[34.7054,11,1],[34.7079,4,1],[34.7104,15,1],[34.7128,8,1],[34.7153,1,1],[34.7178,12,1],[34.7202,5,1],[34.7227,16,1],[34.7252,9,1],[34.7276,2,1],[34.7301,13,1],[34.7326,6,1],[34.735,17,1],[34.7375,10,1],[34.74,3,1],[34.7425,14,1],[34.7449,7,1],[34.7474,18,1],[34.7499,11,1],[34.7523,4,1],[34.7548,15,1],[34.7573,8,1],[34.7597,1,1],[34.7622,12,1],[34.7647,5,1],[34.7671,16,1],[34.7696,9,1],[34.7721,2,1],[34.7746,13,1],[34.777,6,1],[34.7795,17,1],[34.782,10,1],[34.7844,3,1],[34.7869,14,1],[34.7894,7,1],[34.7918,18,1],[34.7943,11,1],[34.7968,4,1],[34.7992,15,1],[34.8017,8,1],[34.8042,1,1],[34.8067,12,1],[34.8091,5,1],[34.8116,16,1],[34.8141,9,1],[34.8165,2,1],[34.819,13,1],[34.8215,6,1],[34.8239,17,1],[34.8264,10,1],[34.8289,3,1],[34.8313,14,1],[34.8338,7,1],[34.8363,18,1],[34.8388,11,1],[34.8412,4,1],[34.8437,15,1],[34.8462,8,1],[34.8486,1,1],[34.8511,12,1],[34.8536,5,1],[34.856,16,1],[34.8585,9,1],[34.861,2,1],[34.8634,13,1],[34.8659,6,1],[34.8684,17,1],[34.8709,10,1],[34.8733,3,1],[34.8758,14,1],[34.8783,7,1],[34.8807,18,1],[34.8832,11,1],[34.8857,4,1],[34.8881,15,1],[34.8906,8,1],[34.8931,1,1],[34.8955,12,1],[34.898,5,1],[34.9005,16,1],[34.9029,9,1],[34.9054,2,1],[34.9079,13,1],[34.9104,6,1],[34.9128,17,1],[34.9153,10,1],[34.9178,3,1],[34.9202,14,1],[34.9227,7,1],[34.9252,18,1],[34.9276,11,1],[34.9301,4,1],[34.9326,15,1],[34.935,8,1],[34.9375,1,1],[34.94,12,1],[34.9425,5,1],[34.9449,16,1],[34.9474,9,1],[34.9499,2,1],[34.9523,13,1],[34.9548,6,1],[34.9573,17,1],[34.9597,10,1],[34.9622,3,1],[34.9647,14,1],[34.9671,7,1],[34.9696,18,1],[34.9721,11,1],[34.9746,4,1],[34.977,15,1],[34.9795,8,1],[34.982,1,1],[34.9844,12,1],[34.9869,5,1],[34.9894,16,1],[34.9918,9,1],[34.9943,2,1],[34.9968,13,1],[34.9992,6,1],[35.0017,17,1],[35.0042,10,1],[35.0067,3,1],[35.0091,14,1],[35.0116,7,1],[35.0141,18,1],[35.0165,11,1],[35.019,4,1],[35.0215,15,1],[35.0239,8,1],[35.0264,1,1],[35.0289,12,1],[35.0313,5,1],[35.0338,16,1],[35.0363,9,1],[35.0388,2,1],[35.0412,13,1],[35.0437,6,1],[35.0462,17,1],[35.0486,10,1],[35.0511,3,1],[35.0536,14,1],[35.056,7,1],[35.0585,18,1],[35.061,11,1],[35.0634,4,1],[35.0659,15,1],[35.0684,8,1],[35.0709,1,1],[35.0733,12,1],[35.0758,5,1],[35.0783,16,1],[35.0807,9,1],[35.0832,2,1],[35.0857,13,1],[35.0881,6,1],[35.0906,17,1],[35.0931,10,1],[35.0955,3,1],[35.098,14,1],[35.1005,7,1],[35.1029,18,1],[35.1054,11,1],[35.1079,4,1],[35.1104,15,1],[35.1128,8,1],[35.1153,1,1],[35.1178,12,1],[35.1202,5,1],[35.1227,16,1],[35.1252,9,1],[35.1276,2,1],[35.1301,13,1],[35.1326,6,1],[35.135,17,1],[35.1375,10,1],[35.14,3,1],[35.1425,14,1],[35.1449,7,1],[35.1474,18,1],[35.1499,11,1],[35.1523,4,1],[35.1548,15,1],[35.1573,8,1],[35.1597,1,1],[35.1622,12,1],[35.1647,5,1],[35.1671,16,1],[35.1696,9,1],[35.1721,2,1],[35.1746,13,1],[35.177,6,1],[35.1795,17,1],[35.182,10,1],[35.1844,3,1],[35.1869,14,1],[35.1894,7,1],[35.1918,18,1],[35.1943,11,1],[35.1968,4,1],[35.1992,15,1],[35.2017,8,1],[35.2042,1,1],[35.2067,12,1],[35.2091,5,1],[35.2116,16,1],[35.2141,9,1],[35.2165,2,1],[35.219,13,1],[35.2215,6,1],[35.2239,17,1],[35.2264,10,1],[35.2289,3,1],[35.2313,14,1],[35.2338,7,1],[35.2363,18,1],[35.2388,11,1],[35.2412,4,1],[35.2437,15,1],[35.2462,8,1],[35.2486,1,1],[35.2511,12,1],[35.2536,5,1],[35.256,16,1],[35.2585,9,1],[35.261,2,1],[35.2634,13,1],[35.2659,6,1],[35.2684,17,1],[35.2709,10,1],[35.2733,3,1],[35.2758,14,1],[35.2783,7,1],[35.2807,18,1],[35.2832,11,1],[35.2857,4,1],[35.2881,15,1],[35.2906,8,1],[35.2931,1,1],[35.2955,12,1],[35.298,5,1],[35.3005,16,1],[35.3029,9,1],[35.3054,2,1],[35.3079,13,1],[35.3104,6,1],[35.3128,17,1],[35.3153,10,1],[35.3178,3,1],[35.3202,14,1],[35.3227,7,1],[35.3252,18,1],[35.3276,11,1],[35.3301,4,1],[35.3326,15,1],[35.335,8,1],[35.3375,1,1],[35.34,12,1],[35.3425,5,1],[35.3449,16,1],[35.3474,9,1],[35.3499,2,1],[35.3523,13,1],[35.3548,6,1],[35.3573,17,1],[35.3597,10,1],[35.3622,3,1],[35.3647,14,1],[35.3671,7,1],[35.3696,18,1],[35.3721,11,1],[35.3746,4,1],[35.377,15,1],[35.3795,8,1],[35.382,1,1],[35.3844,12,1],[35.3869,5,1],[35.3894,16,1],[35.3918,9,1],[35.3943,2,1],[35.3968,13,1],[35.3992,6,1],[35.4017,17,1],[35.4042,10,1],[35.4067,3,1],[35.4091,14,1],[35.4116,7,1],[35.4141,18,1],[35.4165,11,1],[35.419,4,1],[35.4215,15,1],[35.4239,8,1],[35.4264,1,1],[35.4289,12,1],[35.4313,5,1],[35.4338,16,1],[35.4363,9,1],[35.4388,2,1],[35.4412,13,1],[35.4437,6,1],[35.4462,17,1],[35.4486,10,1],[35.4511,3,1],[35.4536,14,1],[35.456,7,1],[35.4585,18,1],[35.461,11,1],[35.4634,4,1],[35.4659,15,1],[35.4684,8,1],[35.4709,1,1],[35.4733,12,1],[35.4758,5,1],[35.4783,16,1],[35.4807,9,1],[35.4832,2,1],[35.4857,13,1],[35.4881,6,1],[35.4906,17,1],[35.4931,10,1],[35.4955,3,1],[35.498,14,1],[35.5005,7,1],[35.5029,18,1],[35.5054,11,1],[35.5079,4,1],[35.5104,15,1],[35.5128,8,1],[35.5153,1,1],[35.5178,12,1],[35.5202,5,1],[35.5227,16,1],[35.5252,9,1],[35.5276,2,1],[35.5301,13,1],[35.5326,6,1],[35.535,17,1],[35.5375,10,1],[35.54,3,1],[35.5425,14,1],[35.5449,7,1],[35.5474,18,1],[35.5499,11,1],[35.5523,4,1],[35.5548,15,1],[35.5573,8,1],[35.5597,1,1],[35.5622,12,1],[35.5647,5,1],[35.5671,16,1],[35.5696,9,1],[35.5721,2,1],[35.5746,13,1],[35.577,6,1],[35.5795,17,1],[35.582,10,1],[35.5844,3,1],[35.5869,14,1],[35.5894,7,1],[35.5918,18,1],[35.5943,11,1],[35.5968,4,1],[35.5992,15,1],[35.6017,8,1],[35.6042,1,1],[35.6067,12,1],[35.6091,5,1],[35.6116,16,1],[35.6141,9,1],[35.6165,2,1],[35.619,13,1],[35.6215,6,1],[35.6239,17,1],[35.6264,10,1],[35.6289,3,1],[35.6313,14,1],[35.6338,7,1],[35.6363,18,1],[35.6388,11,1],[35.6412,4,1],[35.6437,15,1],[35.6462,8,1],[35.6486,1,1],[35.6511,12,1],[35.6536,5,1],[35.656,16,1],[35.6585,9,1],[35.661,2,1],[35.6634,13,1],[35.6659,6,1],[35.6684,17,1],[35.6709,10,1],[35.6733,3,1],[35.6758,14,1],[35.6783,7,1],[35.6807,18,1],[35.6832,11,1],[35.6857,4,1],[35.6881,15,1],[35.6906,8,1],[35.6931,1,1],[35.6955,12,1],[35.698,5,1],[35.7005,16,1],[35.703,9,1],[35.7054,2,1],[35.7079,13,1],[35.7104,6,1],[35.7128,17,1],[35.7153,10,1],[35.7178,3,1],[35.7202,14,1],[35.7227,7,1],[35.7252,18,1],[35.7277,11,1],[35.7301,4,1],[35.7326,15,1],[35.7351,8,1],[35.7375,1,1],[35.74,12,1],[35.7425,5,1],[35.745,16,1],[35.7474,9,1],[35.7499,2,1],[35.7524,13,1],[35.7548,6,1],[35.7573,17,1],[35.7598,10,1],[35.7623,3,1],[35.7647,14,1],[35.7672,7,1],[35.7697,18,1],[35.7721,11,1],[35.7746,4,1],[35.7771,15,1],[35.7796,8,1],[35.782,1,1],[35.7845,12,1],[35.787,5,1],[35.7895,16,1],[35.7919,9,1],[35.7944,2,1],[35.7969,13,1],[35.7993,6,1],[35.8018,17,1],[35.8043,10,1],[35.8068,3,1],[35.8092,14,1],[35.8117,7,1],[35.8142,18,1],[35.8167,11,1],[35.8191,4,1],[35.8216,15,1],[35.8241,8,1],[35.8266,1,1],[35.829,12,1],[35.8315,5,1],[35.834,16,1],[35.8365,9,1],[35.839,2,1],[35.8414,13,1],[35.8439,6,1],[35.8464,17,1],[35.8489,10,1],[35.8513,3,1],[35.8538,14,1],[35.8563,7,1],[35.8588,18,1],[35.8613,11,1],[35.8637,4,1],[35.8662,15,1],[35.8687,8,1],[35.8712,1,1],[35.8737,12,1],[35.8761,5,1],[35.8786,16,1],[35.8811,9,1],[35.8836,2,1],[35.8861,13,1],[35.8886,6,1],[35.891,17,1],[35.8935,10,1],[35.896,3,1],[35.8985,14,1],[35.901,7,1],[35.9035,18,1],[35.9059,11,1],[35.9084,4,1],[35.9109,15,1],[35.9134,8,1],[35.9159,1,1],[35.9184,12,1],[35.9209,5,1],[35.9234,16,1],[35.9258,9,1],[35.9283,2,1],[35.9308,13,1],[35.9333,6,1],[35.9358,17,1],[35.9383,10,1],[35.9408,3,1],[35.9433,14,1],[35.9458,7,1],[35.9483,18,1],[35.9508,11,1],[35.9533,4,1],[35.9557,15,1],[35.9582,8,1],[35.9607,1,1],[35.9632,12,1],[35.9657,5,1],[35.9682,16,1],[35.9707,9,1],[35.9732,2,1],[35.9757,13,1],[35.9782,6,1],[35.9807,17,1],[35.9832,10,1],[35.9857,3,1],[35.9882,14,1],[35.9907,7,1],[35.9932,18,1],[35.9957,11,1],[35.9982,4,1],[36.0007,15,1],[36.0032,8,1],[36.0058,1,1],[36.0083,12,1],[36.0108,5,1],[36.0133,16,1],[36.0158,9,1],[36.0183,2,1],[36.0208,13,1],[36.0233,6,1],[36.0258,17,1],[36.0283,10,1],[36.0309,3,1],[36.0334,14,1],[36.0359,7,1],[36.0384,18,1],[36.0409,11,1],[36.0434,4,1],[36.0459,15,1],[36.0485,8,1],[36.051,1,1],[36.0535,12,1],[36.056,5,1],[36.0585,16,1],[36.0611,9,1],[36.0636,2,1],[36.0661,13,1],[36.0686,6,1],[36.0712,17,1],[36.0737,10,1],[36.0762,3,1],[36.0787,14,1],[36.0813,7,1],[36.0838,18,1],[36.0863,11,1],[36.0889,4,1],[36.0914,15,1],[36.0939,8,1],[36.0965,1,1],[36.099,12,1],[36.1015,5,1],[36.1041,16,1],[36.1066,9,1],[36.1091,2,1],[36.1117,13,1],[36.1142,6,1],[36.1168,17,1],[36.1193,10,1],[36.1219,3,1],[36.1244,14,1],[36.1269,7,1],[36.1295,18,1],[36.132,11,1],[36.1346,4,1],[36.1371,15,1],[36.1397,8,1],[36.1422,1,1],[36.1448,12,1],[36.1473,5,1],[36.1499,16,1],[36.1525,9,1],[36.155,2,1],[36.1576,13,1],[36.1601,6,1],[36.1627,17,1],[36.1653,10,1],[36.1678,3,1],[36.1704,14,1],[36.173,7,1],[36.1755,18,1],[36.1781,11,1],[36.1807,4,1],[36.1832,15,1],[36.1858,8,1],[36.1884,1,1],[36.1909,12,1],[36.1935,5,1],[36.1961,16,1],[36.1987,9,1],[36.2013,2,1],[36.2038,13,1],[36.2064,6,1],[36.209,17,1],[36.2116,10,1],[36.2142,3,1],[36.2168,14,1],[36.2194,7,1],[36.2219,18,1],[36.2245,11,1],[36.2271,4,1],[36.2297,15,1],[36.2323,8,1],[36.2349,1,1],[36.2375,12,1],[36.2401,5,1],[36.2427,16,1],[36.2453,9,1],[36.2479,2,1],[36.2505,13,1],[36.2531,6,1],[36.2558,17,1],[36.2584,10,1],[36.261,3,1],[36.2636,14,1],[36.2662,7,1],[36.2688,18,1],[36.2715,11,1],[36.2741,4,1],[36.2767,15,1],[36.2793,8,1],[36.2819,1,1],[36.2846,12,1],[36.2872,5,1],[36.2898,16,1],[36.2925,9,1],[36.2951,2,1],[36.2977,13,1],[36.3004,6,1],[36.303,17,1],[36.3057,10,1],[36.3083,3,1],[36.3109,14,1],[36.3136,7,1],[36.3162,18,1],[36.3189,11,1],[36.3215,4,1],[36.3242,15,1],[36.3269,8,1],[36.3295,1,1],[36.3322,12,1],[36.3348,5,1],[36.3375,16,1],[36.3402,9,1],[36.3428,2,1],[36.3455,13,1],[36.3482,6,1],[36.3508,17,1],[36.3535,10,1],[36.3562,3,1],[36.3589,14,1],[36.3616,7,1],[36.3642,18,1],[36.3669,11,1],[36.3696,4,1],[36.3723,15,1],[36.375,8,1],[36.3777,1,1],[36.3804,12,1],[36.3831,5,1],[36.3858,16,1],[36.3885,9,1],[36.3912,2,1],[36.3939,13,1],[36.3966,6,1],[36.3993,17,1],[36.4021,10,1],[36.4048,3,1],[36.4075,14,1],[36.4102,7,1],[36.4129,18,1],[36.4157,11,1],[36.4184,4,1],[36.4211,15,1],[36.4239,8,1],[36.4266,1,1],[36.4293,12,1],[36.4321,5,1],[36.4348,16,1],[36.4376,9,1],[36.4403,2,1],[36.4431,13,1],[36.4458,6,1],[36.4486,17,1],[36.4513,10,1],[36.4541,3,1],[36.4569,14,1],[36.4596,7,1],[36.4624,18,1],[36.4652,11,1],[36.4679,4,1],[36.4707,15,1],[36.4735,8,1],[36.4763,1,1],[36.4791,12,1],[36.4818,5,1],[36.4846,16,1],[36.4874,9,1],[36.4902,2,1],[36.493,13,1],[36.4958,6,1],[36.4986,17,1],[36.5014,10,1],[36.5043,3,1],[36.5071,14,1],[36.5099,7,1],[36.5127,18,1],[36.5155,11,1],[36.5184,4,1],[36.5212,15,1],[36.524,8,1],[36.5269,1,1],[36.5297,12,1],[36.5325,5,1],[36.5354,16,1],[36.5382,9,1],[36.5411,2,1],[36.5439,13,1],[36.5468,6,1],[36.5497,17,1],[36.5525,10,1],[36.5554,3,1],[36.5583,14,1],[36.5611,7,1],[36.564,18,1],[36.5669,11,1],[36.5698,4,1],[36.5727,15,1],[36.5756,8,1],[36.5785,1,1],[36.5814,12,1],[36.5843,5,1],[36.5872,16,1],[36.5901,9,1],[36.593,2,1],[36.5959,13,1],[36.5988,6,1],[36.6017,17,1],[36.6047,10,1],[36.6076,3,1],[36.6105,14,1],[36.6135,7,1],[36.6164,18,1],[36.6194,11,1],[36.6223,4,1],[36.6253,15,1],[36.6282,8,1],[36.6312,1,1],[36.6342,12,1],[36.6371,5,1],[36.6401,16,1],[36.6431,9,1],[36.6461,2,1],[36.6491,13,1],[36.652,6,1],[36.655,17,1],[36.658,10,1],[36.661,3,1],[36.664,14,1],[36.6671,7,1],[36.6701,18,1],[36.6731,11,1],[36.6761,4,1],[36.6791,15,1],[36.6822,8,1],[36.6852,1,1],[36.6883,12,1],[36.6913,5,1],[36.6944,16,1],[36.6974,9,1],[36.7005,2,1],[36.7035,13,1],[36.7066,6,1],[36.7097,17,1],[36.7128,10,1],[36.7159,3,1],[36.7189,14,1],[36.722,7,1],[36.7251,18,1],[36.7282,11,1],[36.7314,4,1],[36.7345,15,1],[36.7376,8,1],[36.7407,1,1],[36.7438,12,1],[36.747,5,1],[36.7501,16,1],[36.7533,9,1],[36.7564,2,1],[36.7596,13,1],[36.7627,6,1],[36.7659,17,1],[36.7691,10,1],[36.7722,3,1],[36.7754,14,1],[36.7786,7,1],[36.7818,18,1],[36.785,11,1],[36.7882,4,1],[36.7914,15,1],[36.7946,8,1],[36.7978,1,1],[36.8011,12,1],[36.8043,5,1],[36.8075,16,1],[36.8108,9,1],[36.814,2,1],[36.8173,13,1],[36.8206,6,1],[36.8238,17,1],[36.8271,10,1],[36.8304,3,1],[36.8337,14,1],[36.837,7,1],[36.8403,18,1],[36.8436,11,1],[36.8469,4,1],[36.8502,15,1],[36.8535,8,1],[36.8569,1,1],[36.8602,12,1],[36.8636,5,1],[36.8669,16,1],[36.8703,9,1],[36.8736,2,1],[36.877,13,1],[36.8804,6,1],[36.8838,17,1],[36.8872,10,1],[36.8906,3,1],[36.894,14,1],[36.8974,7,1],[36.9008,18,1],[36.9042,11,1],[36.9077,4,1],[36.9111,15,1],[36.9146,8,1],[36.918,1,1],[36.9215,12,1],[36.925,5,1],[36.9284,16,1],[36.9319,9,1],[36.9354,2,1],[36.9389,13,1],[36.9425,6,1],[36.946,17,1],[36.9495,10,1],[36.953,3,1],[36.9566,14,1],[36.9601,7,1],[36.9637,18,1],[36.9673,11,1],[36.9709,4,1],[36.9744,15,1],[36.978,8,1],[36.9816,1,1],[36.9853,12,1],[36.9889,5,1],[36.9925,16,1],[36.9961,9,1],[36.9998,2,1],[37.0035,13,1],[37.0071,6,1],[37.0108,17,1],[37.0145,10,1],[37.0182,3,1],[37.0219,14,1],[37.0256,7,1],[37.0293,18,1],[37.033,11,1],[37.0368,4,1],[37.0405,15,1],[37.0443,8,1],[37.0481,1,1],[37.0519,12,1],[37.0557,5,1],[37.0595,16,1],[37.0633,9,1],[37.0671,2,1],[37.0709,13,1],[37.0748,6,1],[37.0786,17,1],[37.0825,10,1],[37.0864,3,1],[37.0903,14,1],[37.0942,7,1],[37.0981,18,1],[37.102,11,1],[37.1059,4,1],[37.1099,15,1],[37.1138,8,1],[37.1178,1,1],[37.1218,12,1],[37.1258,5,1],[37.1298,16,1],[37.1338,9,1],[37.1378,2,1],[37.1418,13,1],[37.1459,6,1],[37.15,17,1],[37.154,10,1],[37.1581,3,1],[37.1622,14,1],[37.1664,7,1],[37.1705,18,1],[37.1746,11,1],[37.1788,4,1],[37.1829,15,1],[37.1871,8,1],[37.1913,1,1],[37.1955,12,1],[37.1998,5,1],[37.204,16,1],[37.2083,9,1],[37.2125,2,1],[37.2168,13,1],[37.2211,6,1],[37.2254,17,1],[37.2297,10,1],[37.2341,3,1],[37.2384,14,1],[37.2428,7,1],[37.2472,18,1],[37.2516,11,1],[37.256,4,1],[37.2604,15,1],[37.2649,8,1],[37.2694,1,1],[37.2738,12,1],[37.2783,5,1],[37.2828,16,1],[37.2874,9,1],[37.2919,2,1],[37.2965,13,1],[37.3011,6,1],[37.3057,17,1],[37.3103,10,1],[37.3149,3,1],[37.3196,14,1],[37.3243,7,1],[37.329,18,1],[37.3337,11,1],[37.3384,4,1],[37.3431,15,1],[37.3479,8,1],[37.3527,1,1],[37.3575,12,1],[37.3623,5,1],[37.3672,16,1],[37.372,9,1],[37.3769,2,1],[37.3818,13,1],[37.3868,6,1],[37.3917,17,1],[37.3967,10,1],[37.4017,3,1],[37.4067,14,1],[37.4117,7,1],[37.4168,18,1],[37.4218,11,1],[37.4269,4,1],[37.4321,15,1],[37.4372,8,1],[37.4424,1,1],[37.4476,12,1],[37.4528,5,1],[37.458,16,1],[37.4633,9,1],[37.4686,2,1],[37.4739,13,1],[37.4792,6,1],[37.4846,17,1],[37.49,10,1],[37.4954,3,1],[37.5009,14,1],[37.5063,7,1],[37.5118,18,1],[37.5173,11,1],[37.5229,4,1],[37.5285,15,1],[37.5341,8,1],[37.5397,1,1],[37.5454,12,1],[37.551,5,1],[37.5568,16,1],[37.5625,9,1],[37.5683,2,1],[37.5741,13,1],[37.5799,6,1],[37.5858,17,1],[37.5917,10,1],[37.5976,3,1],[37.6036,14,1],[37.6095,7,1],[37.6156,18,1],[37.6216,11,1],[37.6277,4,1],[37.6338,15,1],[37.64,8,1],[37.6461,1,1],[37.6524,12,1],[37.6586,5,1],[37.6649,16,1],[37.6712,9,1],[37.6776,2,1],[37.684,13,1],[37.6904,6,1],[37.6969,17,1],[37.7033,10,1],[37.7099,3,1],[37.7165,14,1],[37.7231,7,1],[37.7297,18,1],[37.7364,11,1],[37.7431,4,1],[37.7499,15,1],[37.7567,8,1],[37.7635,1,1],[37.7704,12,1],[37.7773,5,1],[37.7842,16,1],[37.7912,9,1],[37.7983,2,1],[37.8053,13,1],[37.8125,6,1],[37.8196,17,1],[37.8268,10,1],[37.8341,3,1],[37.8413,14,1],[37.8487,7,1],[37.856,18,1],[37.8634,11,1],[37.8709,4,1],[37.8784,15,1],[37.8859,8,1],[37.8935,1,1],[37.9011,12,1],[37.9087,5,1],[37.9164,16,1],[37.9242,9,1],[37.932,2,1],[37.9398,13,1],[37.9477,6,1],[37.9556,17,1],[37.9636,10,1],[37.9716,3,1],[37.9796,14,1],[37.9877,7,1],[37.9958,18,1],[38.004,11,1],[38.0122,4,1],[38.0205,15,1],[38.0288,8,1],[38.0371,1,1],[38.0455,12,1],[38.0539,5,1],[38.0623,16,1],[38.0708,9,1],[38.0794,2,1],[38.088,13,1],[38.0966,6,1],[38.1052,17,1],[38.1139,10,1],[38.1226,3,1],[38.1314,14,1],[38.1402,7,1],[38.149,18,1],[38.1579,11,1],[38.1668,4,1],[38.1757,15,1],[38.1847,8,1],[38.1937,1,1],[38.2027,12,1],[38.2117,5,1],[38.2208,16,1],[38.2299,9,1],[38.2391,2,1],[38.2482,13,1],[38.2574,6,1],[38.2666,17,1],[38.2758,10,1],[38.2851,3,1],[38.2944,14,1],[38.3037,7,1],[38.313,18,1],[38.3223,11,1],[38.3317,4,1],[38.341,15,1],[38.3504,8,1],[38.3598,1,1],[38.3692,12,1],[38.3786,5,1],[38.3881,16,1],[38.3975,9,1],[38.407,2,1],[38.4164,13,1],[38.4259,6,1],[38.4354,17,1],[38.4449,10,1],[38.4543,3,1],[38.4638,14,1],[38.4733,7,1],[38.4829,18,1],[38.4924,11,1],[38.5019,4,1],[38.5114,15,1],[38.5209,8,1],[38.5304,1,1],[38.54,12,1],[38.5495,5,1],[38.559,16,1],[38.5685,9,1],[38.578,2,1],[38.5876,13,1],[38.5971,6,1],[38.6066,17,1],[38.6161,10,1],[38.6257,3,1],[38.6352,14,1],[38.6447,7,1],[38.6542,18,1],[38.6638,11,1],[38.6733,4,1],[38.6828,15,1],[38.6923,8,1],[38.7019,1,1],[38.7114,12,1],[38.7209,5,1],[38.7304,16,1],[38.7399,9,1],[38.7495,2,1],[38.759,13,1],[38.7685,6,1],[38.778,17,1],[38.7876,10,1],[38.7971,3,1],[38.8066,14,1],[38.8161,7,1],[38.8257,18,1],[38.8352,11,1],[38.8447,4,1],[38.8542,15,1],[38.8638,8,1],[38.8733,1,1],[38.8828,12,1],[38.8923,5,1],[38.9019,16,1],[38.9114,9,1],[38.9209,2,1],[38.9304,13,1],[38.9399,6,1],[38.9495,17,1],[38.959,10,1],[38.9685,3,1],[38.978,14,1],[38.9876,7,1],[38.9971,18,1],[39.0066,11,1],[39.0161,4,1],[39.0257,15,1],[39.0352,8,1],[39.0447,1,1],[39.0542,12,1],[39.0638,5,1],[39.0733,16,1],[39.0828,9,1],[39.0923,2,1],[39.1019,13,1],[39.1114,6,1],[39.1209,17,1],[39.1304,10,1],[39.1399,3,1],[39.1495,14,1],[39.159,7,1],[39.1685,18,1],[39.178,11,1],[39.1876,4,1],[39.1971,15,1],[39.2066,8,1],[39.2161,1,1],[39.2257,12,1],[39.2352,5,1],[39.2447,16,1],[39.2542,9,1],[39.2638,2,1],[39.2733,13,1],[39.2828,6,1],[39.2923,17,1],[39.3019,10,1],[39.3114,3,1],[39.3209,14,1],[39.3304,7,1],[39.3399,18,1],[39.3495,11,1],[39.359,4,1],[39.3685,15,1],[39.378,8,1],[39.3876,1,1],[39.3971,12,1],[39.4066,5,1],[39.4161,16,1],[39.4257,9,1],[39.4352,2,1],[39.4447,13,1],[39.4542,6,1],[39.4638,17,1],[39.4733,10,1],[39.4828,3,1],[39.4923,14,1],[39.5019,7,1],[39.5114,18,1],[39.5209,11,1],[39.5304,4,1],[39.5399,15,1],[39.5495,8,1],[39.559,1,1],[39.5685,12,1],[39.578,5,1],[39.5876,16,1],[39.5971,9,1],[39.6066,2,1],[39.6161,13,1],[39.6257,6,1],[39.6352,17,1],[39.6447,10,1],[39.6542,3,1],[39.6638,14,1],[39.6733,7,1],[39.6828,18,1],[39.6923,11,1],[39.7019,4,1],[39.7114,15,1],[39.7209,8,1],[39.7304,1,1],[39.7399,12,1],[39.7495,5,1],[39.759,16,1],[39.7685,9,1],[39.778,2,1],[39.7876,13,1],[39.7971,6,1],[39.8066,17,1],[39.8161,10,1],[39.8257,3,1],[39.8352,14,1],[39.8447,7,1],[39.8542,18,1],[39.8638,11,1],[39.8733,4,1],[39.8828,15,1],[39.8923,8,1],[39.9019,1,1],[39.9114,12,1],[39.9209,5,1],[39.9304,16,1],[39.9399,9,1],[39.9495,2,1],[39.959,13,1],[39.9685,6,1],[39.978,17,1],[39.9876,10,1],[39.9971,3,1]]} \ No newline at end of file diff --git a/machines/radial/r2800-film/main.js b/machines/radial/r2800-film/main.js new file mode 100644 index 0000000..86d3364 --- /dev/null +++ b/machines/radial/r2800-film/main.js @@ -0,0 +1,4 @@ +// Radial, the R-2800 film. Shared code in ../r2800-shared. +import {run} from '../r2800-shared/film.js'; + +run(); diff --git a/machines/radial/r2800-film/timeline.json b/machines/radial/r2800-film/timeline.json new file mode 100644 index 0000000..5b015db --- /dev/null +++ b/machines/radial/r2800-film/timeline.json @@ -0,0 +1 @@ +{"duration":40,"beats":{"views":[1.6,3.3,6.4,12.6,18.6,23.6,28.4,32.6,35.4],"presses":[1.6,3.3,28.4,35.4],"starter":1.6,"ignition":3.3,"disengage":4.6,"climb":29.7,"full":32.7,"back":35.6,"idleAgain":38.6}} \ No newline at end of file diff --git a/machines/radial/r2800-live/main.js b/machines/radial/r2800-live/main.js new file mode 100644 index 0000000..9a82e5d --- /dev/null +++ b/machines/radial/r2800-live/main.js @@ -0,0 +1,5 @@ +import {runLive} from '../r2800-shared/live.js'; + +// Radial as an interactive page: starter, magnetos, throttle, slow motion, seven subsystems, cutaways, four views, +// orbit and zoom, and an auto tour when left alone. +runLive(); diff --git a/machines/radial/r2800-shared/console.js b/machines/radial/r2800-shared/console.js new file mode 100644 index 0000000..9827c42 --- /dev/null +++ b/machines/radial/r2800-shared/console.js @@ -0,0 +1,400 @@ +// Radial's overlay, a plate from a period technical manual: paper panels with a double rule, figure numbers, a +// condensed grotesque and a stencil title. Left: the title, three round dials drawn from the model (rpm, manifold +// pressure, brake power) with the torque and the piston speed under them, the engine's data plate, the run (phase and +// slow motion). Right: the firing-order star turning with the crank, a context figure (the piston law of a master +// and an articulated rod, the cam ring, the throws, the take-off curve), the subsystem, cutaway, view and layer +// buttons. Callouts with thin leaders. Fixed 1600 x 900 CSS layout; update(s) redraws every live part. +import {ACCENT} from './model.js'; +import {CAM, CYL, DISPL_IN3, DISPL_L, E, FIRE, GEAR, LAW, lift, MASS_KG, ORDER, RATING, ROWS, pistonS, propHp, stateAt, VALVE} from './engine.js'; +import {FONT} from './fonts.js'; +import {nf, T as tr} from './i18n.js'; + +export const C = {ink: '#22262b', mute: '#777b80', faint: '#b9b6ad', rule: '#2b2f34', paper: 'rgba(246,243,234,.95)', paperSolid: '#f6f3ea', acc: ACCENT, accSoft: 'rgba(10,143,224,.16)', red: '#b8322a'}; +export const SUBS = ['crank', 'cyl', 'valve', 'ign', 'air', 'prop', 'oil']; +export const CUTS = ['glass', 'section', 'solid']; +export const CAMS = ['face', 'hero', 'profile', 'inside']; +export const LAYERS = ['cowl', 'callouts']; +const CSS = ` +#app{font-family:${FONT.cond};color:${C.ink};-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums} +.ui{position:absolute;inset:0;pointer-events:none} +.pl{position:absolute;background:${C.paper};border:1.5px solid ${C.rule};box-shadow:0 1px 0 rgba(0,0,0,.05)} +.pl::after{content:'';position:absolute;inset:3px;border:.75px solid rgba(34,38,43,.45);pointer-events:none} +.fig{position:absolute;font-weight:700;font-size:13px;letter-spacing:1.6px;text-transform:uppercase;white-space:nowrap} +.fig i{font-style:normal;color:${C.mute};font-weight:500;margin-right:6px} +.mu{color:${C.mute}} +.tb{position:absolute;height:22px;padding:0 7px;display:flex;align-items:center;justify-content:center;font-size:12.5px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;border:1.2px solid ${C.rule};background:${C.paperSolid};color:${C.ink};white-space:nowrap;box-sizing:border-box} +.tb.on{background:${C.ink};color:${C.paperSolid}} +.call{position:absolute;white-space:nowrap;background:rgba(246,243,234,.88);padding:3px 7px 4px;border-left:2px solid ${C.rule}} +.call.r{border-left:0;border-right:2px solid ${C.rule}} +.call b{display:block;font-size:24px;font-weight:700;letter-spacing:.2px;line-height:1} +.call span{display:block;font-size:12.5px;font-weight:500;letter-spacing:1.2px;color:${C.mute};text-transform:uppercase;margin-top:2px} +.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:36px;height:36px;margin:-18px 0 0 -18px;border-radius:50%;border:2px solid ${C.ink};opacity:0;z-index:49} +.row{position:absolute;display:flex;justify-content:space-between;font-size:13.5px;letter-spacing:.3px} +.row span:first-child{color:${C.mute};text-transform:uppercase;letter-spacing:1px;font-size:12px;font-weight:500} +.row b{font-weight:700} +svg text{font-family:${FONT.cond}} +`; +const el = (h) => { + const d = document.createElement('div'); + d.innerHTML = h.trim(); + return d.firstElementChild; +}; +const fInt = (x) => nf(Math.round(x)); +const D2R = Math.PI / 180; + +/** a round dial: the face and ticks are drawn once, the needle every frame */ +function dial(svg, {cx, cy, r, min, max, major, minor, label, unit, scaleTxt = (v) => nf(v), red, skipEnds = false}) { + const a0 = -225; + const a1 = 45; + const ang = (v) => a0 + ((v - min) / (max - min)) * (a1 - a0); + const P = (a, rr) => [cx + Math.cos(a * D2R) * rr, cy + Math.sin(a * D2R) * rr]; + let h = ``; + for (let v = min; v <= max + 1e-6; v += minor) { + const isMaj = Math.abs(((v - min) / major) % 1) < 1e-6 || Math.abs(((v - min) / major) % 1 - 1) < 1e-6; + const [x0, y0] = P(ang(v), r - 6); + const [x1, y1] = P(ang(v), r - (isMaj ? 15 : 10)); + h += ``; + if (isMaj && !(skipEnds && (Math.abs(v - min) < 1e-6 || Math.abs(v - max) < 1e-6))) { + const [tx, ty] = P(ang(v), r - (r > 60 ? 26 : 23)); + h += `${scaleTxt(v)}`; + } + } + if (red !== undefined) { + const [x0, y0] = P(ang(red), r - 5); + const [x1, y1] = P(ang(red), r - 18); + h += ``; + } + const ly = cy + r * (r > 60 ? 0.42 : 0.66); + h += `${label}`; + h += `${unit}`; + h += ``; + svg.insertAdjacentHTML('beforeend', h); + const ndl = svg.lastElementChild.previousElementSibling; + return (v) => ndl.setAttribute('transform', `rotate(${ang(Math.min(max, Math.max(min, v))) + 90} ${cx} ${cy})`); +} + +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; + }; + + // ------------------------------------------------------------ title plate + add(`
`); + add(`
RADIAL
`); + add(`
${tr('subtitle')}
`); + add(`
${tr('model')}
`); + + // ------------------------------------------------------------ instruments + add(`
`); + add(`
${tr('fig', 1)}${tr('figInstr')}
`); + const dsvg = add(``); + const setRpm = dial(dsvg, {cx: 84, cy: 108, r: 76, min: 0, max: 3000, major: 500, minor: 100, label: tr('dRpm'), unit: tr('dRpmU'), scaleTxt: (v) => nf(v / 100), red: RATING.takeoff.rpm}); + const setMap = dial(dsvg, {cx: 222, cy: 56, r: 52, min: 10, max: 70, major: 20, minor: 5, label: tr('dMap'), unit: 'IN HG', red: RATING.takeoff.map, skipEnds: true}); + const setHp = dial(dsvg, {cx: 222, cy: 164, r: 52, min: 0, max: 2500, major: 500, minor: 250, label: tr('dHp'), unit: 'HP × 100', scaleTxt: (v) => nf(v / 100), red: RATING.takeoff.hp, skipEnds: true}); + const readTop = 382; + const rowsTxt = ['rTorque', 'rPiston', 'rProp'].map((k, i) => + add(`
${tr(k)}
`).querySelector('b'), + ); + + // ------------------------------------------------------------ data plate + add(`
`); + add(`
${tr('fig', 2)}${tr('figData')}
`); + const data = [ + ['pBore', `${nf(5.75, 2)} × ${nf(6, 2)} in`], + ['pDispl', `${fInt(DISPL_IN3)} in³ · ${nf(DISPL_L, 1)} L`], + ['pCr', `${nf(E.cr, 2)} : 1`], + ['pTo', `${fInt(RATING.takeoff.hp)} hp · ${fInt(RATING.takeoff.rpm)} · ${nf(RATING.takeoff.map, 1)}″`], + ['pWet', `${fInt(RATING.wet.hp)} hp · ${fInt(RATING.wet.rpm)} · ${nf(RATING.wet.map, 1)}″`], + ['pRed', `${nf(GEAR.prop, 2)} : 1`], + ['pSc', tr('pScV', nf(GEAR.impLow, 2), nf(GEAR.impHigh, 2))], + ['pMass', `${fInt(MASS_KG)} kg`], + ['pCam', tr('pCamV')], + ['pIgn', tr('pIgnV')], + ]; + data.forEach(([k, v], i) => add(`
${tr(k)}${v}
`)); + + // ------------------------------------------------------------ the run: phase, slow motion, live controls + add(`
`); + add(`
${tr('fig', 3)}${tr('figRun')}
`); + const phaseTxt = add(`
`); + const slowTxt = add(`
`); + const btn = {}; + ['starter', 'ignition', 'slowmo', 'pause'].forEach((k, i) => { + btn[k] = add(`
${tr({starter: 'bStarter', ignition: 'bIgnition', slowmo: 'bSlow', pause: 'pause'}[k])}
`); + }); + add(`
${tr('throttle')}
`); + const thrVal = add(`
`); + const SL = {x: 44, w: 260}; + add(`
`); + const thrKnob = add(`
`); + + + // ------------------------------------------------------------ firing-order star + add(`
`); + add(`
${tr('fig', 4)}${tr('figStar')}
`); + add(`
${tr('starSub')}
`); + const SC = {cx: 146, cy: 112, rr: 64, rf: 92}; + const star = add(``); + const sp = (a, r) => [SC.cx - Math.sin(a * D2R) * r, SC.cy - Math.cos(a * D2R) * r]; + { + let h = ``; + h += ``; + for (const cy of CYL) { + const [x, y] = sp(cy.angle, cy.row === 'rear' ? SC.rr : SC.rf); + h += ``; + h += `${cy.c}`; + } + h += ``; + h += `${tr('starKey')}`; + star.innerHTML = h; + } + const starDots = Object.fromEntries(CYL.map((cy) => [cy.c, star.querySelector(`[data-c="${cy.c}"]`)])); + const starTxt = Object.fromEntries(CYL.map((cy) => [cy.c, star.querySelector(`[data-t="${cy.c}"]`)])); + const pins = star.querySelector('.pins'); + const orderBox = add(`
`); + orderBox.innerHTML = ORDER.map((c) => `${c}`).join(''); + const orderSpans = Object.fromEntries(ORDER.map((c) => [c, orderBox.querySelector(`[data-o="${c}"]`)])); + const starFoot = add(`
`); + + // ------------------------------------------------------------ context figure + add(`
`); + const ctxTitle = add(`
`); + const ctxSvg = add(``); + const ctxTxt = add(`
`); + + // ------------------------------------------------------------ subsystems, cutaways, views, layers + add(`
`); + const spots = {}; + const group = (title, keys, y, label, cols) => { + add(`
${title}
`); + const W = 264; + const gap = 4; + const w = (W - gap * (cols - 1)) / cols; + keys.forEach((k, i) => { + const x = 1294 + (i % cols) * (w + gap); + const yy = y + 15 + Math.floor(i / cols) * 25; + btn[k] = add(`
${label(k)}
`); + spots[k] = [x + w / 2, yy + 11]; + }); + return y + 15 + Math.ceil(keys.length / cols) * 25 + 4; + }; + let y = group(tr('grpSub'), SUBS, 628, (k) => tr('sub:' + k), 2); + y = group(tr('grpCut'), CUTS, y, (k) => tr('cut:' + k), 3); + y = group(tr('grpView'), CAMS, y, (k) => tr('cam:' + k), 4); + group(tr('grpLayer'), LAYERS, y, (k) => tr('lay:' + k), 2); + ['starter', 'ignition', 'slowmo', 'pause'].forEach((k, i) => (spots[k] = [42 + i * 66.5 + 31, 813])); + spots.throttle = [SL.x + SL.w * 0.15, 853]; + spots.throttleBack = [SL.x + SL.w, 853]; + + // ------------------------------------------------------------ caption, callouts, cursor + const cap = add(`
`); + const leaders = add(``); + const callPool = []; + const cursor = add(``); + const ring = add(`
`); + + // ------------------------------------------------------------ context figures + const plot = (pts, x0, y0, w, h, xr, yr, color, width = 1.6, dash = '') => { + const X = (x) => x0 + ((x - xr[0]) / (xr[1] - xr[0])) * w; + const Y = (v) => y0 + h - ((v - yr[0]) / (yr[1] - yr[0])) * h; + return ``; + }; + const axes = (x0, y0, w, h, xl, yl) => + `` + + `${xl}${yl}`; + // the piston law: master against its most different articulated rod, the difference magnified + const lawCyl = (row) => ROWS[row].filter((c) => c.c !== E.master[row]).reduce((a, b) => (Math.abs(LAW[b.c].tdcLag) > Math.abs(LAW[a.c].tdcLag) ? b : a)).c; + const lawStatic = {}; + const lawFig = (row) => { + if (lawStatic[row]) return lawStatic[row]; + const m = E.master[row]; + const a = lawCyl(row); + const dm = CYL[a - 1].angle - CYL[m - 1].angle; + const t0 = LAW[m].thTDC; + const ptsD = []; + for (let i = 0; i <= 180; i++) { + const x = i * 2; + ptsD.push([x, pistonS(a, t0 + x + dm) - pistonS(m, t0 + x)]); + } + const lo = Math.min(...ptsD.map((p) => p[1])); + const hi = Math.max(...ptsD.map((p) => p[1])); + const ym = Math.max(Math.abs(lo), Math.abs(hi)) * 1.15; + const h = + axes(30, 6, 236, 100, tr('lawX'), tr('lawY')) + + `` + + plot(ptsD, 30, 6, 236, 100, [0, 360], [-ym, ym], C.acc, 2.2) + + `+${nf(ym, 1)}−${nf(ym, 1)}`; + lawStatic[row] = {h, m, a, dm, ym, t0}; + return lawStatic[row]; + }; + const camFig = {}; + const camFigFor = (c) => { + if (camFig[c]) return camFig[c]; + const pi = []; + const pe = []; + for (let i = 0; i <= 240; i++) { + const th = FIRE[c] + i * 3; + pi.push([i * 3, lift(c, 'in', th)]); + pe.push([i * 3, lift(c, 'ex', th)]); + } + const h = axes(30, 6, 236, 96, tr('camX'), tr('camY')) + plot(pe, 30, 6, 236, 96, [0, 720], [0, 1.15], C.ink, 1.8, '4 3') + plot(pi, 30, 6, 236, 96, [0, 720], [0, 1.15], C.ink, 2) + [180, 360, 540].map((x) => ``).join('') + ['power', 'exhaust', 'intake', 'compression'].map((k, i) => `${tr('st:' + k)}`).join(''); + camFig[c] = h; + return h; + }; + const toFig = (() => { + const pts = []; + for (let N = 600; N <= 2800; N += 50) pts.push([N, propHp(N)]); + const tq = []; + for (let N = 600; N <= 2800; N += 50) tq.push([N, stateAt(N).torque]); + return axes(30, 6, 236, 100, tr('toX'), tr('toY')) + plot(pts, 30, 6, 236, 100, [600, 2800], [0, 2500], C.ink, 2) + plot(tq.map(([a, b]) => [a, b / 2.4]), 30, 6, 236, 100, [600, 2800], [0, 2500], C.ink, 1.6, '4 3') + ``; + })(); + let ctxKey = ''; + + const update = (s) => { + // dials and readouts + setRpm(s.N); + setMap(s.map); + setHp(s.hp); + rowsTxt[0].textContent = `${fInt(s.torque)} N·m · ${fInt(s.torque * 0.7376)} lb·ft`; + rowsTxt[1].textContent = `${nf(s.pistonMs, 1)} m/s`; + rowsTxt[2].textContent = `${fInt(s.propRpm)} · ${fInt(s.impRpm)}`; + // the run + phaseTxt.textContent = tr('ph:' + s.phase); + slowTxt.innerHTML = s.rate < 0.98 ? tr('slow', nf(1000 * s.rate, 0), nf(1 / s.rate, 0)) : tr('realtime'); + thrVal.textContent = `${fInt(s.thrRpm ?? s.N)} ${tr('rpmShort')}`; + thrKnob.style.left = `${SL.x + SL.w * Math.min(1, Math.max(0, ((s.thrRpm ?? s.N) - RATING.idle) / (RATING.takeoff.rpm - RATING.idle)))}px`; + btn.starter.classList.toggle('on', !!s.starter); + btn.ignition.classList.toggle('on', !!s.ignition); + btn.pause.classList.toggle('on', !!s.paused); + btn.slowmo.classList.toggle('on', s.rate < 0.98); + // the star: the crank pins turn, the cylinder that fires lights + pins.setAttribute('transform', `rotate(${-s.th} ${SC.cx} ${SC.cy})`); + let best = 0; + let bestC = 0; + for (const cy of CYL) { + const g = s.lit[cy.c - 1]; + const dot = starDots[cy.c]; + const k = Math.min(1, g * 1.6); + dot.setAttribute('fill', k > 0.02 ? `rgba(10,143,224,${(0.2 + 0.8 * k).toFixed(3)})` : cy.row === 'rear' ? '#e7e3d8' : '#fbf9f3'); + dot.setAttribute('stroke', k > 0.3 ? C.acc : C.ink); + starTxt[cy.c].setAttribute('fill', k > 0.5 ? '#fff' : C.ink); + if (g > best) [best, bestC] = [g, cy.c]; + } + for (const c of ORDER) { + const on = c === bestC && best > 0.15; + orderSpans[c].style.color = on ? '#fff' : C.ink; + orderSpans[c].style.background = on ? C.acc : 'transparent'; + } + starFoot.innerHTML = tr('starFoot', nf(s.fireHz, 0)); + // context figure + const key = s.ctx + ':' + (s.ctxArg ?? ''); + if (key !== ctxKey) { + ctxKey = key; + ctxTitle.innerHTML = `${tr('fig', 5)}${tr('ctx:' + s.ctx)}`; + if (s.ctx === 'law') ctxSvg.innerHTML = lawFig(s.ctxArg || 'front').h + ``; + else if (s.ctx === 'cam') ctxSvg.innerHTML = camFigFor(s.ctxArg) + ``; + else if (s.ctx === 'takeoff') ctxSvg.innerHTML = toFig + ``; + else if (s.ctx === 'throws') ctxSvg.innerHTML = ``; + else ctxSvg.innerHTML = ``; + } + const cur1 = ctxSvg.querySelector('.cur1'); + if (s.ctx === 'law') { + const L = lawFig(s.ctxArg || 'front'); + const m = LAW[L.m]; + const a = LAW[L.a]; + const th = (((s.th - L.t0) % 360) + 360) % 360; + const x = 30 + (th / 360) * 236; + const d = pistonS(L.a, s.th + L.dm) - pistonS(L.m, s.th); + cur1.innerHTML = ``; + ctxTxt.innerHTML = tr('lawTxt', L.m, L.a, nf(m.stroke, 2), nf(a.stroke, 2), nf(Math.abs(a.tdcLag), 1), nf(-a.dTDC, 2), nf(m.cr, 2), nf(a.cr, 2), a.tdcLag > 0); + } else if (s.ctx === 'cam') { + const c = s.ctxArg; + const ph = (((s.th - FIRE[c]) % 720) + 720) % 720; + const x = 30 + (ph / 720) * 236; + cur1.innerHTML = ``; + ctxTxt.innerHTML = tr('camTxt', c, CAM.lobes, nf(CAM.liftMm, 1), nf(lift(c, 'in', s.th) * CAM.liftMm, 1), nf(lift(c, 'ex', s.th) * CAM.liftMm, 1), VALVE.in, VALVE.ex); + } else if (s.ctx === 'takeoff') { + const x = 30 + ((Math.min(2800, Math.max(600, s.N)) - 600) / 2200) * 236; + const yv = 6 + 100 - (Math.min(2500, s.hp) / 2500) * 100; + cur1.innerHTML = ``; + ctxTxt.innerHTML = tr('toTxt', fInt(s.hp), fInt(s.N), nf(s.map, 1), fInt(s.torque)); + } else if (s.ctx === 'throws') { + // the two crankpins seen from the front, and the rows + const cx = 70; + const cy = 66; + const rr = s.th * D2R; + const p = (a, r) => [cx - Math.sin(a) * r, cy - Math.cos(a) * r]; + const [x1, y1] = p(rr, 34); + const [x2, y2] = p(rr + Math.PI, 34); + cur1.innerHTML = `${tr('throwKey')}` + + `${tr('throwA')}${tr('throwA2')}${tr('throwB')}${tr('throwB2')}`; + ctxTxt.innerHTML = tr('throwTxt'); + } else if (s.ctx === 'ign') { + cur1.innerHTML = ''; + ctxTxt.innerHTML = tr('ignTxt'); + } else { + cur1.innerHTML = ''; + ctxTxt.innerHTML = tr('ctxTxt:' + s.ctx); + } + const plain = !['law', 'cam', 'takeoff', 'throws'].includes(s.ctx); + ctxTxt.style.top = plain ? '418px' : '548px'; + ctxTxt.style.fontSize = plain ? '15px' : '13px'; + // buttons + for (const k of SUBS) btn[k].classList.toggle('on', s.sub === k); + for (const k of CUTS) btn[k].classList.toggle('on', s.cut === k); + for (const k of CAMS) btn[k].classList.toggle('on', s.camBtn === k); + btn.cowl.classList.toggle('on', !!s.layers?.cowl); + btn.callouts.classList.toggle('on', !!s.layers?.callouts); + // caption + cap.textContent = s.caption || ''; + cap.style.display = s.caption ? '' : 'none'; + // callouts + const list = s.callouts || []; + let lh = ''; + list.forEach((c, i) => { + let d = callPool[i]; + if (!d) { + d = add(`
`); + callPool[i] = d; + } + const o = c.o ?? 1; + if (!c.at || o < 0.02) { + d.style.display = 'none'; + return; + } + d.style.display = ''; + d.style.opacity = o; + d.querySelector('b').textContent = c.name; + d.querySelector('span').textContent = c.sub || ''; + const right = c.w < 0; + const [lx, ly] = c.label; + d.style.left = right ? '' : `${lx}px`; + d.style.right = right ? `${1600 - lx}px` : ''; + d.style.textAlign = right ? 'right' : 'left'; + d.classList.toggle('r', right); + d.style.top = `${ly - 17}px`; + const ex = lx; + const kx = ex + (right ? 1 : -1) * Math.abs(c.w) * 0.25; + lh += ``; + }); + for (let i = list.length; i < callPool.length; i++) callPool[i].style.display = 'none'; + leaders.innerHTML = lh; + // cursor + const cu = s.cursor || {x: -99, y: -99}; + cursor.style.transform = `translate(${cu.x - 3}px, ${cu.y - 2}px) scale(${cu.press ? 0.9 : 1})`; + ring.style.left = `${cu.ringX ?? 0}px`; + ring.style.top = `${cu.ringY ?? 0}px`; + ring.style.opacity = cu.ring ?? 0; + ring.style.transform = `scale(${1.6 - 0.6 * (cu.ring ?? 0)})`; + }; + return {root: ui, btn, spots, update, SL, thrKnob}; +} +void DISPL_IN3; diff --git a/machines/radial/r2800-shared/engine.js b/machines/radial/r2800-shared/engine.js new file mode 100644 index 0000000..34b6b4b --- /dev/null +++ b/machines/radial/r2800-shared/engine.js @@ -0,0 +1,222 @@ +// The R-2800 Double Wasp, the numbers: one source for every figure the page shows and every pose the 3D takes. +// Modelled on a C-series R-2800 CB16 (Douglas DC-6B, Convair 340) from public sources; not affiliated with Pratt & +// Whitney. The letters match REFERENCES.md: [F] the FAA type certificate data sheet 6A3, [N] the Smithsonian NASM +// record, [H] the FAA powerplant handbook, [W] the published series description, [E] an estimate (not public). +// +// No three.js in here: node imports it for check.mjs, the timeline and the score. Units are millimetres and degrees. +// Angles around the engine are measured clockwise from the top, seen from the rear (the pilot's side); the crankshaft +// turns clockwise seen from the rear, the usual US "right-hand tractor". + +export const E = { + bore: 146.05, // 5.75 in [W] + stroke: 152.4, // 6.0 in [W] + r: 76.2, // crank throw, half the stroke + L: 292, // master rod, crankpin centre to piston pin [E] + rk: 60.3, // knuckle-pin circle on the master rod's big end, 2.375 in [E] + l: 231.7, // articulated rod, knuckle pin to piston pin: L - rk [E] + cr: 6.75, // compression ratio of the C series [W] + compH: 66, // piston pin to crown [E] + rowZ: {rear: -105, front: 105}, // the two rows along the axis [E] + master: {rear: 13, front: 8}, // the cylinders that carry the master rods [E] +}; +export const DISPL_IN3 = (Math.PI / 4) * 5.75 ** 2 * 6 * 18; // 2,804.5 in³ +export const DISPL_L = DISPL_IN3 * 0.016387064; // 45.96 L +export const RATING = { + takeoff: {hp: 2050, rpm: 2700, map: 55.0}, // dry, two minutes, low blower [F] + wet: {hp: 2400, rpm: 2800, map: 59.5}, // water-alcohol injection [F] + meto: {hp: 1800, rpm: 2600, map: 48.5}, // maximum continuous [F] + idle: 700, // [E] + crank: 140, // starter cranking speed [E] +}; +export const GEAR = {prop: 0.45, impLow: 7.29, impHigh: 8.58}; // [F] +export const MASS_KG = 1068; // 2,350 lb [N] +export const DIAMETER_MM = 1341; // 52.8 in [N] +export const LENGTH_MM = 2068; // 81.4 in [N] +export const PROP_D = 4.0; // m, a three-blade Hamilton Standard [E] + +// ------------------------------------------------------------ cylinders, numbering and firing order +// 1 at the top of the rear row, then clockwise seen from the rear: odd numbers in the rear row, even in the front, +// the front row turned 20° from the rear one [H]. 18 cylinders, 20° apart, 40° within a row. +export const CYL = Array.from({length: 18}, (_, i) => { + const c = i + 1; + const row = c % 2 ? 'rear' : 'front'; + return {c, row, k: Math.floor(i / 2), angle: 20 * i, z: E.rowZ[row]}; +}); +export const ROWS = {rear: CYL.filter((x) => x.row === 'rear'), front: CYL.filter((x) => x.row === 'front')}; +// the firing order numbers of an 18-cylinder twin-row radial are +11 and -7 [H] +export const ORDER = (() => { + const o = [1]; + while (o.length < 18) { + const a = o[o.length - 1] + 11; + o.push(a <= 18 ? a : o[o.length - 1] - 7); + } + return o; +})(); +export const INTERVAL = 720 / 18; // 40° of crank between firings +export const FIRE = Object.fromEntries(ORDER.map((c, i) => [c, i * INTERVAL])); // crank angle of each firing TDC +export const SPARK_ADV = 25; // ° before TDC [E] +const mod = (a, m) => ((a % m) + m) % m; +export const wrap180 = (a) => mod(a + 180, 360) - 180; +/** four-stroke phase of cylinder c, degrees after its firing TDC: 0-180 power, 180-360 exhaust, 360-540 intake, 540-720 compression */ +export const psi = (c, th) => mod(th - FIRE[c], 720); +export const STROKES = ['power', 'exhaust', 'intake', 'compression']; +export const strokeOf = (ps) => STROKES[Math.floor(mod(ps, 720) / 180)]; + +// ------------------------------------------------------------ the crank and the rods, exact +const vec = (a) => [Math.sin((a * Math.PI) / 180), Math.cos((a * Math.PI) / 180)]; +const rotCW = ([x, y], a) => { + const c = Math.cos((a * Math.PI) / 180); + const s = Math.sin((a * Math.PI) / 180); + return [x * c + y * s, -x * s + y * c]; +}; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1]; +const cross = (a, b) => a[0] * b[1] - a[1] * b[0]; +/** the crankpin of a row: the rear one at θ, the front one at θ + 180° */ +export const pinAngle = (row, th) => (row === 'rear' ? th : th + 180); +/** + * every rod of one row at crank angle th: the crankpin C, the master rod's direction d, and for each cylinder its + * piston pin distance from the axis s (mm), its knuckle pin K (the master's own is C) and its piston pin P, in the + * plane of the row seen from the rear (x right, y up) + */ +export function rowPose(row, th) { + const C = vec(pinAngle(row, th)).map((v) => v * E.r); + const m = CYL[E.master[row] - 1]; + const um = vec(m.angle); + const sm = dot(C, um) + Math.sqrt(E.L * E.L - cross(C, um) ** 2); + const Pm = [um[0] * sm, um[1] * sm]; + const d = [(Pm[0] - C[0]) / E.L, (Pm[1] - C[1]) / E.L]; + const out = {C, d, cyl: {}}; + for (const cy of ROWS[row]) { + const u = vec(cy.angle); + if (cy.c === m.c) { + out.cyl[cy.c] = {s: sm, K: C, P: Pm, master: true}; + continue; + } + const kd = rotCW(d, cy.angle - m.angle); + const K = [C[0] + kd[0] * E.rk, C[1] + kd[1] * E.rk]; + const s = dot(K, u) + Math.sqrt(E.l * E.l - cross(K, u) ** 2); + out.cyl[cy.c] = {s, K, P: [u[0] * s, u[1] * s], master: false}; + } + return out; +} +export const pistonS = (c, th) => rowPose(CYL[c - 1].row, th).cyl[c].s; + +// the law of each piston, sampled once at 0.05°: stroke, TDC height and where TDC falls, against the ideal +// slider-crank of the master rod +export const LAW = (() => { + const out = {}; + for (const cy of CYL) { + let max = -1e9; + let min = 1e9; + let thMax = 0; + let thMin = 0; + for (let i = 0; i < 7200; i++) { + const th = i * 0.05; + const s = pistonS(cy.c, th); + if (s > max) [max, thMax] = [s, th]; + if (s < min) [min, thMin] = [s, th]; + } + // the crank angle at which the pin points at this cylinder: TDC for a plain slider-crank + const ideal = mod(cy.angle - (cy.row === 'rear' ? 0 : 180), 360); + const A = (Math.PI / 4) * E.bore * E.bore; + out[cy.c] = {sTDC: max, sBDC: min, stroke: max - min, thTDC: thMax, thBDC: thMin, tdcLag: wrap180(thMax - ideal), bdcLag: wrap180(thMin - ideal - 180), A}; + } + // clearance volume: set by the master's compression ratio; a piston that stops lower leaves a bigger chamber + for (const row of ['rear', 'front']) { + const m = out[E.master[row]]; + const Vc = (m.A * m.stroke) / (E.cr - 1); + for (const cy of ROWS[row]) { + const x = out[cy.c]; + x.Vc = Vc + x.A * (m.sTDC - x.sTDC); + x.cr = (x.Vc + x.A * x.stroke) / x.Vc; + x.dTDC = x.sTDC - m.sTDC; + } + } + return out; +})(); +export const MASTER_STROKE = LAW[E.master.front].stroke; + +// ------------------------------------------------------------ valves: two cam rings, no camshaft +// one ring per row, two tracks (inlet, exhaust), 4 lobes 90° apart, turning at 1/8 crank speed against the crank: +// a lobe reaches each tappet once every 720° of crank [H]. Valve events in crank degrees after the firing TDC [E]. +export const CAM = {lobes: 4, ratio: -1 / 8, liftMm: 15.9, zOff: 88}; +export const VALVE = {in: [340, 600], ex: [130, 380]}; // inlet opens 20° BTDC, closes 60° ABDC; exhaust 50° BBDC to 20° ATDC +const centre = ([a, b]) => (a + b) / 2; +const halfCam = ([a, b]) => (b - a) / 16; // the event's half-width in cam degrees (crank ÷ 8 ÷ 2) +// each track's phase, derived from the firing order: the lobe is centred on cylinder c's tappet when the crank is at +// the centre of c's event. The same constant holds for every cylinder of a row (check.mjs verifies it). +const phaseOf = (row, ev) => { + const c = ROWS[row][0]; + return mod(c.angle + (FIRE[c.c] + centre(VALVE[ev])) / 8, 90); +}; +export const CAM_PHASE = {rear: {in: phaseOf('rear', 'in'), ex: phaseOf('rear', 'ex')}, front: {in: phaseOf('front', 'in'), ex: phaseOf('front', 'ex')}}; +/** the ring's rotation (degrees, clockwise from the rear) at crank angle th: it turns against the crank at 1/8 */ +export const camAngle = (th) => th * CAM.ratio; +/** a lobe's offset in front of cylinder c's tappet, cam degrees, in [-45, 45) */ +export const lobeOffset = (c, ev, th) => { + const cy = CYL[c - 1]; + return mod(cy.angle - camAngle(th) - CAM_PHASE[cy.row][ev] + 45, 90) - 45; +}; +/** the lobe's rise under a tappet: 0..1, a smooth bump over the event's width */ +export const lobeRise = (x, half) => { + const u = Math.abs(x) / half; + return u >= 1 ? 0 : 0.5 + 0.5 * Math.cos(Math.PI * u); +}; +/** valve lift of cylinder c (0..1 of CAM.liftMm), read off the cam ring at crank angle th */ +export const lift = (c, ev, th) => lobeRise(lobeOffset(c, ev, th), halfCam(VALVE[ev])); +/** the same lift from the valve timing alone, for check.mjs */ +export const liftFromTiming = (c, ev, th) => { + const ps = psi(c, th); + const [a, b] = VALVE[ev]; + const x = mod(ps - centre([a, b]) + 360, 720) - 360; + return lobeRise(x / 8, halfCam([a, b])); +}; + +// ------------------------------------------------------------ ignition +// 2 plugs per cylinder (36), 2 magnetos each feeding one plug of every cylinder; a distributor turns at half crank +// speed so each plug fires once in two turns [W] +export const IGN = {plugs: 36, magnetos: 2, distRatio: 0.5}; +/** how long ago (crank degrees) cylinder c's plugs last fired, at th; the flame lights from the spark to ~70° ATDC */ +export const sinceSpark = (c, th) => mod(th - (FIRE[c] - SPARK_ADV), 720); + +// ------------------------------------------------------------ power, from the rpm alone +// quasi-steady: the propeller at fine pitch absorbs P ∝ N³, set to the take-off rating at 2,700 rpm [E]. The power +// needs a brake mean effective pressure; manifold pressure follows from bmep = k·MAP − friction − pumping, k set so +// that the take-off point is exact (2,050 hp needs 55.0 inHg [F]). check.mjs holds the METO point within 7 %. +const HP = 745.7; +const INHG = 0.033864; // bar per inHg +const AMB = 29.92; +export const fmep = (N) => 1.2 + 0.6 * (N / 2700) ** 2; // bar [E] +export const propHp = (N) => RATING.takeoff.hp * (N / RATING.takeoff.rpm) ** 3; +/** bmep in bar for a brake power (hp) at N rpm */ +export const bmepOf = (hp, N) => (N > 1 ? (2 * hp * HP) / ((DISPL_L / 1000) * (N / 60)) / 1e5 : 0); +export const K_MAP = (() => { + const {hp, rpm, map} = RATING.takeoff; + return (bmepOf(hp, rpm) + fmep(rpm) + (AMB - map) * INHG) / map; +})(); +/** brake power from MAP and N (the inverse) */ +export const hpOf = (map, N) => (Math.max(0, K_MAP * map - fmep(N) - (AMB - map) * INHG) * 1e5 * (DISPL_L / 1000) * (N / 60)) / 2 / HP; +/** + * the engine's state at N rpm. run 0..1: how far the engine has caught (0 on the starter, 1 running). Returns + * manifold pressure (inHg), brake power (hp), crank torque (N·m), firing frequency (Hz), mean piston speed (m/s), + * propeller and impeller rpm. + */ +export function stateAt(N, run = 1) { + const hp = run * propHp(N); + const mapRun = N > 1 ? (bmepOf(propHp(N), N) + fmep(N) + AMB * INHG) / (K_MAP + INHG) : AMB; + const mapCrank = AMB - 0.9 * Math.min(1, N / 100); + const map = mapCrank + (mapRun - mapCrank) * run; + const w = (N * 2 * Math.PI) / 60; + return { + N, + map, + hp, + kw: hp * HP * 1e-3, + torque: w > 0.1 ? (hp * HP) / w : 0, + fireHz: (N / 60) * 9, + pistonMs: (2 * E.stroke * 1e-3 * N) / 60, + propRpm: N * GEAR.prop, + impRpm: N * GEAR.impLow, + }; +} +export const AMBIENT_INHG = AMB; diff --git a/machines/radial/r2800-shared/film.js b/machines/radial/r2800-shared/film.js new file mode 100644 index 0000000..70bf3bd --- /dev/null +++ b/machines/radial/r2800-shared/film.js @@ -0,0 +1,298 @@ +// Radial, the film: the R-2800 (model.js) on its run-up stand (stage.js) under the manual-plate overlay (console.js). +// One input, the crank angle θ(t) integrated once from the planned run (schedule.js), poses every piston, rod, cam, +// valve, magneto, gear and the propeller, lights each spark, and drives every gauge. The operator starts the engine, +// watches the firing order go round the star, cuts a row, pulls the rows apart, looks at a cam ring lifting a valve, +// opens the throttle to take-off rpm and closes it again. A pure function of t. +import * as THREE from 'three'; +import {runSim} from 'teardown-kit/runtime.js'; +import {createConsole} from './console.js'; +import {CYL, E, lift, ROWS, stateAt, wrap180} from './engine.js'; +import {loadFonts} from './fonts.js'; +import {T as tr} from './i18n.js'; +import {airAt, camAtName, camSince, DURATION, lastSpark, rateAt, rpmAt, RUN, runAtTau, SCRIPT, starterAtTau, tauAt, thetaAt} from './schedule.js'; +import {createStage} from './stage.js'; + +export {DURATION, SCRIPT}; +export const DEFAULTS = {duration: DURATION, fps: 60, theme: 'light', hud: {enabled: false, title: 'radial', subtitle: ''}, director: {enabled: false}, watermark: ''}; +const D2R = Math.PI / 180; +export const smoother = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * u * (u * (u * 6 - 15) + 10); +}; +export const ss = (a, b, t) => smoother((t - a) / (b - a)); + +// the cylinder the cam view watches: the front-row cylinder near the top whose inlet valve opens mid-hold +export const CAM_CYL = (() => { + const t0 = 24.4; + const t1 = 28.0; + let best = ROWS.front[0].c; + let score = -1e9; + for (const cy of ROWS.front) { + let peak = 0; + let tp = 0; + for (let t = t0; t <= t1; t += 0.02) { + const L = lift(cy.c, 'in', thetaAt(t)); + if (L > peak) [peak, tp] = [L, t]; + } + const top = Math.cos(cy.angle * D2R); + const s = peak * 2 + top * 1.2 - Math.abs(tp - 26.2) * 0.6; + if (s > score) [score, best] = [s, cy.c]; + } + return best; +})(); + +// ------------------------------------------------------------ views, each a function of u (seconds since it came on) +const camAng = CYL[CAM_CYL - 1].angle; +export const VIEW_FN = { + hero: (u) => ({tx: 0, ty: -0.04, tz: 0.04, az: 36 + 1.0 * Math.min(u, 8), el: 9 + 0.3 * Math.min(u, 8), d: 4.7 - 0.05 * Math.min(u, 8), sx: 0, sy: 0}), + face: (u) => ({tx: 0, ty: 0, tz: 0, az: 0, el: 1.5, d: 4.35 - 0.06 * Math.min(u, 6), sx: 0, sy: 0}), + faceFull: (u) => ({tx: 0, ty: 0, tz: 0, az: -4 + 1.2 * u, el: 2, d: 4.5 - 0.04 * u, sx: 0, sy: 0}), + rowcut: (u) => ({tx: 0, ty: 0, tz: 0.1, az: 0, el: 0, d: 3.7 - 0.12 * Math.min(u, 6), sx: 0, sy: 0}), + apart: (u) => ({tx: 0, ty: 0.02, tz: 0.03, az: 58 + 1.6 * u, el: 18, d: 3.4, sx: 0, sy: 0}), + cam: (u) => { + // beside the watched cylinder, looking across it: the cam ring, the tappet, the tube, the rocker, the valve + const a = camAng * D2R; + const ur = [-Math.sin(a), Math.cos(a), 0]; + const ut = [Math.cos(a), Math.sin(a), 0]; + const sw = 0.1 * Math.sin(u * 0.35); + const dir = [0, 1, 2].map((i) => (0.82 + sw) * ut[i] + 0.5 * (i === 2 ? 1 : 0) + 0.22 * ur[i]); + const n = Math.hypot(...dir); + const [dx, dy, dz] = dir.map((v) => v / n); + const R = 0.31; + return {tx: ur[0] * R, ty: ur[1] * R, tz: 0.105 + 0.11, az: Math.atan2(dx, dz) / D2R, el: Math.asin(dy) / D2R, d: 1.8 - 0.02 * u, sx: 0, sy: 0}; + }, + takeoff: (u) => ({tx: 0, ty: -0.05, tz: -0.12, az: 64 + 5.5 * u, el: 7, d: 5.3, sx: 0, sy: 0}), + heroEnd: (u) => ({...VIEW_FN.hero(u), d: 4.8 - 0.03 * u}), + // live-only + profile: () => ({tx: 0, ty: 0, tz: -0.25, az: 90, el: 2, d: 5.4, sx: 0, sy: 0}), + inside: () => { + // across the cut through cylinder 2's axis: the piston, the chamber, both valves and both plugs + const a = CYL[1].angle * D2R; + const ur = [-Math.sin(a), Math.cos(a)]; + const ut = [Math.cos(a), Math.sin(a)]; + const dir = [ut[0] * 0.93 + ur[0] * 0.2, ut[1] * 0.93 + ur[1] * 0.2, 0.3]; + const n = Math.hypot(...dir); + return {tx: ur[0] * 0.47, ty: ur[1] * 0.47, tz: 0.105, az: Math.atan2(dir[0] / n, dir[2] / n) / D2R, el: Math.asin(dir[1] / n) / D2R, d: 0.95, fov: 34, sx: 0, sy: 0}; + }, +}; +const VIEWS = SCRIPT.filter((e) => e.cam).map((e) => [e.t, e.cam]); +export function blend(a, b, k) { + const o = {}; + for (const key in b) o[key] = key === 'az' ? a.az + wrap180(b.az - a.az) * k : key === 'd' ? Math.exp(Math.log(a.d) + (Math.log(b.d) - Math.log(a.d)) * k) : (a[key] ?? b[key]) + (b[key] - (a[key] ?? b[key])) * k; + return o; +} +export const camAt = (t) => { + let i = 0; + while (i < VIEWS.length - 1 && VIEWS[i + 1][0] <= t) i++; + const view = (k) => VIEW_FN[VIEWS[k][1]](t - VIEWS[k][0]); + const cur = view(i); + if (i === 0) return cur; + const k = ss(VIEWS[i][0], VIEWS[i][0] + 1.6, t); + if (k >= 1) return cur; + return blend(view(i - 1), cur, k); +}; + +// ------------------------------------------------------------ what each view shows: subsystem opacity, cut, rows +const ALL = {case: 1, crank: 1, cyl: 1, valve: 1, ign: 1, air: 1, prop: 1, nose: 1, oil: 1, stand: 1, cowl: 0, baffle: 1}; +export const LOOK = { + hero: {vis: ALL}, + face: {vis: {...ALL, prop: 0.3}}, + faceFull: {vis: ALL}, + heroEnd: {vis: ALL}, + takeoff: {vis: ALL}, + rowcut: {vis: {...ALL, prop: 0, ign: 0, air: 0, valve: 0, baffle: 0, oil: 0, cowl: 0}, cut: 'rowcut', hideRear: 1}, + apart: {vis: {...ALL, case: 0, prop: 0, nose: 0, ign: 0, air: 0, valve: 0, baffle: 0, oil: 0, stand: 0, cyl: 0}, apart: 1}, + cam: {vis: {...ALL, case: 0.12, cyl: 0.3, crank: 0, prop: 0, nose: 0, ign: 0, air: 0, baffle: 0, oil: 0, stand: 0}, hideRear: 1, focusK: 1}, +}; +const lookAt = (name) => LOOK[name] || LOOK.hero; +/** the looks of two views, blended: opacities, the apart offset, the hidden rear row */ +export function blendLook(a, b, k) { + const vis = {}; + for (const g of Object.keys(ALL)) vis[g] = (a.vis[g] ?? 1) + ((b.vis[g] ?? 1) - (a.vis[g] ?? 1)) * k; + return {vis, apart: (a.apart || 0) + ((b.apart || 0) - (a.apart || 0)) * k, hideRear: (a.hideRear || 0) + ((b.hideRear || 0) - (a.hideRear || 0)) * k, cut: k < 0.5 ? a.cut : b.cut, focusK: (a.focusK || 0) + ((b.focusK || 0) - (a.focusK || 0)) * k, focus: a.focusK || b.focusK ? CAM_CYL : 0}; +} +export const lookAtT = (t) => { + let i = 0; + while (i < VIEWS.length - 1 && VIEWS[i + 1][0] <= t) i++; + const cur = lookAt(VIEWS[i][1]); + if (i === 0) return blendLook(cur, cur, 1); + const prev = lookAt(VIEWS[i - 1][1]); + return blendLook(prev, cur, ss(VIEWS[i][0] + 0.1, VIEWS[i][0] + 1.1, t)); +}; + +// ------------------------------------------------------------ the operator's pointer: a click on the button of each beat +const REST = [[860, 520], [720, 430], [900, 560], [760, 380]]; +const SPOT_OF = {face: 'face', faceFull: 'face', rowcut: 'section', apart: 'crank', cam: 'valve', takeoff: 'throttle', heroEnd: 'throttleBack'}; +export function makeCursor(ui, script = SCRIPT) { + const EV = script.filter((e) => e.t > 0).map((e) => ({t: e.t, spot: ui.spots[e.press || SPOT_OF[e.cam]] || ui.spots.hero})); + 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}; + }; +} + +// ------------------------------------------------------------ sparks to light: from the last spark of each cylinder +/** + * glow of each chamber, 0..1: lit sparks only, fading over ~0.35 s of screen time and ~100° of crank. When the + * firings come faster than the eye (more than ~12 a second on screen) the lights merge into a steady low glow. + * spark(c) → {t (screen s), th (crank °), lit}; now: screen time; th: crank; fireHzScreen: firings per screen second + */ +export function glowOf(spark, now, th, fireHzScreen) { + const merge = ss(12, 60, fireHzScreen); + return CYL.map((cy) => { + const s = spark(cy.c); + if (!s || !s.lit) return 0.22 * merge; + const age = now - s.t; + const dth = th - s.th; + const g = Math.exp(-Math.max(0, age) / 0.35) * (dth < 110 ? 1 : Math.exp(-(dth - 110) / 50)); + return g * (1 - merge) + 0.22 * merge; + }); +} +/** the propeller's blur: how far a blade turns between two frames */ +export const blurOf = (propRpm, rate, fps = 60) => ss(5, 28, (propRpm * 6 * rate) / fps); +export const phaseOf = (tau) => { + const R = RUN; + if (tau < R.starter) return 'stopped'; + if (tau < R.ign) return 'starter'; + if (tau < R.disengage + 0.6) return 'ignition'; + if (tau < R.climb) return 'idle'; + if (tau < R.climb + R.climbLen + 0.3) return 'climb'; + if (tau < R.back) return 'takeoff'; + if (tau < R.back + R.backLen) return 'back'; + return 'idle'; +}; + +// ------------------------------------------------------------ callouts per view, label spots on the 1600 x 900 layout +export function calloutsFor(view, st, vis) { + const P3 = (v) => st.project(v); + const A = st.eng.A; + const out = []; + const c = (key, at, label, w, o = 1) => { + const [name, sub] = tr(key); + out.push({name, sub, at: P3(at), label, w, o: vis * o}); + }; + if (view === 'hero' || view === 'heroEnd') { + c('cProp', A.blade(), [372, 168], 120); + c('cMagneto', A.magneto(), [372, 300], 140); + c('cHarness', A.harness(), [372, 420], 120); + c('cHead', A.head(), [1232, 150], -130); + c('cFins', A.fins(), [1232, 268], -120); + c('cPushrod', A.pushrod(), [1232, 390], -140); + c('cPipe', A.pipe(), [1232, 520], -110); + } else if (view === 'face' || view === 'faceFull') { + c('cStarFront', A.cylAt(2, 560), [372, 200], 160); + c('cStarRear', A.cylAt(5, 560), [372, 610], 160); + c('cNose', A.nose(), [1232, 200], -140); + } else if (view === 'rowcut') { + c('cMaster', A.master('front'), [372, 200], 150); + c('cArt', A.art('front', 12), [1232, 230], -150); + c('cKnuckle', A.pin('front'), [372, 610], 170); + c('cPiston', A.piston(2), [1232, 560], -150); + } else if (view === 'apart') { + c('cPinRear', A.pin('rear'), [372, 220], 150); + c('cPinFront', A.pin('front'), [372, 600], 150); + c('cRows', A.piston(1), [1232, 220], -140); + } else if (view === 'cam') { + c('cCam', A.camRing('front'), [372, 610], 160); + c('cTappet', A.tappet(CAM_CYL, 'in'), [372, 320], 160); + c('cRocker', A.rocker(CAM_CYL, 'in'), [1232, 200], -150); + c('cValve', A.valve(CAM_CYL, 'in'), [1232, 470], -150); + } else if (view === 'takeoff') { + c('cSuper', A.impeller(), [1232, 640], -140); + c('cCarb', A.carb(), [1232, 190], -150); + c('cGear', A.nose(), [372, 190], 130); + c('cStarter', A.starter(), [1232, 470], -130); + } + return out; +} +export const CTX_OF = {hero: 'run', heroEnd: 'run', face: 'ign', faceFull: 'takeoff', rowcut: 'law', apart: 'throws', cam: 'cam', takeoff: 'takeoff'}; +export const BTN_OF = {hero: 'hero', heroEnd: 'hero', face: 'face', faceFull: 'face', rowcut: 'face', apart: 'hero', cam: 'hero', takeoff: 'profile'}; +export const SUB_OF = {rowcut: 'crank', apart: 'crank', cam: 'valve', face: 'ign'}; + +/** one frame of everything from the crank angle and the run's state. Shared by the film and the live tour. */ +export function engineFrame({th, N, run, rate, spark, now}) { + const s = stateAt(N, run); + const fireScreen = s.fireHz * rate; + const lit = glowOf(spark, now, th, fireScreen); + return {s, lit, blur: blurOf(s.propRpm, rate)}; +} + +// ------------------------------------------------------------ the film +export function makeFilm() { + return async ({canvas, capture}) => { + await loadFonts(); + 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'; + window.R2800 = {st}; + return { + resize, + render(t) { + const th = thetaAt(t); + const tau = tauAt(t); + const N = rpmAt(t); + const run = runAtTau(tau); + const rate = rateAt(t); + const {s, lit, blur} = engineFrame({th, N, run, rate, spark: (c) => lastSpark(c, t), now: t}); + const view = camAtName(t); + const since = camSince(t); + const look = lookAtT(t); + st.setCut(look.cut ? 'section' : 'solid', look.cut || 'profile'); + const camHi = look.focusK > 0.5 ? CYL.map((cy) => (cy.c === CAM_CYL ? {in: lift(cy.c, 'in', th), ex: lift(cy.c, 'ex', th)} : {in: 0, ex: 0})) : null; + const fx = {th, vis: look.vis, apart: look.apart, hideRear: look.hideRear, focus: look.focus, focusK: look.focusK, glow: lit, headGlow: lit.map((g) => g * 0.85 * (1 - look.focusK)), blur, camHi, air: airAt(t), airK: 0, oil: th / 1440, oilK: 0}; + const c = camAt(t); + st.frame(fx, {...c, az: c.az * D2R, el: c.el * D2R}); + const phase = phaseOf(tau); + ui.update({ + ...s, + th, + lit, + rate, + phase, + starter: starterAtTau(tau) > 0, + ignition: tau >= RUN.ign, + thrRpm: N, + ctx: CTX_OF[view] || 'run', + ctxArg: view === 'cam' ? 0 + CAM_CYL : view === 'rowcut' ? 'front' : undefined, + sub: SUB_OF[view] || '', + cut: look.cut ? 'section' : 'solid', + camBtn: BTN_OF[view], + layers: {cowl: false, callouts: true}, + caption: tr('cap:' + (view === 'hero' && phase !== 'stopped' ? phase : view)), + callouts: calloutsFor(view, st, ss(1.2, 2.0, since) * (view === 'hero' && t > 1.5 ? 1 - ss(1.5, 2.0, t) : 1)), + cursor: cursorAt(t), + }); + return {}; + }, + }; + }; +} +void E; +export const run = () => runSim(DEFAULTS, makeFilm()); diff --git a/machines/radial/r2800-shared/fonts.js b/machines/radial/r2800-shared/fonts.js new file mode 100644 index 0000000..a533605 --- /dev/null +++ b/machines/radial/r2800-shared/fonts.js @@ -0,0 +1,24 @@ +// Radial's own type, inlined (latin subsets, SIL Open Font License): Barlow Condensed, a condensed grotesque in the +// spirit of the period's technical manuals, and Saira Stencil One for the title, the stencil of a crate or a cowling. +// Both builds (the skill's and the site's) load .woff2 as base64. +import b500 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-500-normal.woff2'; +import b700 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-700-normal.woff2'; +import stencil from '@fontsource/saira-stencil-one/files/saira-stencil-one-latin-400-normal.woff2'; + +export const FONT = {cond: "'Barlow Condensed', 'Arial Narrow', sans-serif", stencil: "'Saira Stencil One', 'Barlow Condensed', sans-serif"}; + +let ready = null; +/** registers the faces and resolves when they are usable (the first frame waits on it) */ +export function loadFonts() { + if (ready) return ready; + const face = (family, b64, weight) => { + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + const f = new FontFace(family, buf, {weight: String(weight), display: 'block'}); + document.fonts.add(f); + return f.load(); + }; + ready = Promise.all([face('Barlow Condensed', b500, 500), face('Barlow Condensed', b700, 700), face('Saira Stencil One', stencil, 400)]).catch(() => {}); + return ready; +} diff --git a/machines/radial/r2800-shared/i18n.js b/machines/radial/r2800-shared/i18n.js new file mode 100644 index 0000000..3ef65c1 --- /dev/null +++ b/machines/radial/r2800-shared/i18n.js @@ -0,0 +1,220 @@ +// 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 S = { + // ------------------------------------------------------------ title + subtitle: {en: 'PRATT & WHITNEY R-2800 DOUBLE WASP', fr: 'PRATT & WHITNEY R-2800 DOUBLE WASP', es: 'PRATT & WHITNEY R-2800 DOUBLE WASP'}, + model: { + en: '18 cylinders, two rows · a C-series CB16 after public sources · not affiliated with P&W', + fr: '18 cylindres, deux rangées · un CB16 de série C d’après les sources publiques · sans lien avec P&W', + es: '18 cilindros, dos filas · un CB16 de la serie C según fuentes públicas · sin relación con P&W', + }, + fig: {en: (n) => `FIG. ${n}`, fr: (n) => `FIG. ${n}`, es: (n) => `FIG. ${n}`}, + // ------------------------------------------------------------ instruments + figInstr: {en: 'Instruments', fr: 'Instruments', es: 'Instrumentos'}, + dRpm: {en: 'RPM', fr: 'TR/MIN', es: 'RPM'}, + dRpmU: {en: '× 100', fr: '× 100', es: '× 100'}, + dMap: {en: 'MANIFOLD', fr: 'ADMISSION', es: 'ADMISIÓN'}, + dHp: {en: 'POWER', fr: 'PUISSANCE', es: 'POTENCIA'}, + rTorque: {en: 'Crank torque', fr: 'Couple au vilebrequin', es: 'Par en el cigüeñal'}, + rPiston: {en: 'Mean piston speed', fr: 'Vitesse moyenne de piston', es: 'Velocidad media del pistón'}, + rFire: {en: 'Firings', fr: 'Allumages', es: 'Encendidos'}, + rFireV: {en: (h) => `${h} per second`, fr: (h) => `${h} par seconde`, es: (h) => `${h} por segundo`}, + rProp: {en: 'Propeller · impeller rpm', fr: 'Hélice · rouet, tr/min', es: 'Hélice · rodete, rpm'}, + // ------------------------------------------------------------ data plate + figData: {en: 'Engine data', fr: 'Caractéristiques', es: 'Datos del motor'}, + pBore: {en: 'Bore × stroke', fr: 'Alésage × course', es: 'Diámetro × carrera'}, + pDispl: {en: 'Displacement', fr: 'Cylindrée', es: 'Cilindrada'}, + pCr: {en: 'Compression', fr: 'Compression', es: 'Compresión'}, + pTo: {en: 'Take-off, dry', fr: 'Décollage, sec', es: 'Despegue, seco'}, + pWet: {en: 'Take-off, wet', fr: 'Décollage, injection', es: 'Despegue, inyección'}, + pRed: {en: 'Propeller gear', fr: 'Réducteur', es: 'Reductora'}, + pSc: {en: 'Supercharger', fr: 'Compresseur', es: 'Compresor'}, + pScV: {en: (a, b) => `1 stage, ${a} / ${b} : 1`, fr: (a, b) => `1 étage, ${a} / ${b} : 1`, es: (a, b) => `1 etapa, ${a} / ${b} : 1`}, + pMass: {en: 'Dry weight', fr: 'Masse à sec', es: 'Peso en seco'}, + pCam: {en: 'Valves', fr: 'Distribution', es: 'Distribución'}, + pCamV: {en: '2 cam rings, 4 lobes', fr: '2 anneaux à cames, 4 lobes', es: '2 anillos de levas, 4 lóbulos'}, + pIgn: {en: 'Ignition', fr: 'Allumage', es: 'Encendido'}, + pIgnV: {en: '2 magnetos, 36 plugs', fr: '2 magnétos, 36 bougies', es: '2 magnetos, 36 bujías'}, + // ------------------------------------------------------------ the run + figRun: {en: 'The run', fr: 'La marche', es: 'La marcha'}, + 'ph:stopped': {en: 'STOPPED', fr: 'À L’ARRÊT', es: 'PARADO'}, + 'ph:starter': {en: 'STARTER', fr: 'DÉMARREUR', es: 'ARRANQUE'}, + 'ph:ignition': {en: 'IGNITION', fr: 'ALLUMAGE', es: 'ENCENDIDO'}, + 'ph:idle': {en: 'IDLE', fr: 'RALENTI', es: 'RALENTÍ'}, + 'ph:climb': {en: 'THROTTLE OPEN', fr: 'PLEIN GAZ', es: 'GASES A FONDO'}, + 'ph:takeoff': {en: 'TAKE-OFF RPM', fr: 'RÉGIME DÉCOLLAGE', es: 'RÉGIMEN DE DESPEGUE'}, + 'ph:back': {en: 'THROTTLE BACK', fr: 'RÉDUCTION', es: 'REDUCCIÓN'}, + slow: {en: (ms, x) => `1 s on screen = ${ms} ms · slowed ×${x}`, fr: (ms, x) => `1 s à l’écran = ${ms} ms · ralenti ×${x}`, es: (ms, x) => `1 s en pantalla = ${ms} ms · ralentizado ×${x}`}, + realtime: {en: 'Real time', fr: 'Temps réel', es: 'Tiempo real'}, + bStarter: {en: 'Starter', fr: 'Démarreur', es: 'Arranque'}, + bIgnition: {en: 'Magnetos', fr: 'Magnétos', es: 'Magnetos'}, + pause: {en: 'Pause', fr: 'Pause', es: 'Pausa'}, + bSlow: {en: 'Slow-mo', fr: 'Ralenti', es: 'Lento'}, + throttle: {en: 'THROTTLE · TARGET RPM', fr: 'MANETTE · RÉGIME VISÉ', es: 'ACELERADOR · RÉGIMEN'}, + rpmShort: {en: 'rpm', fr: 'tr/min', es: 'rpm'}, + // ------------------------------------------------------------ the star + figStar: {en: 'Firing order', fr: 'Ordre d’allumage', es: 'Orden de encendido'}, + starSub: {en: 'SEEN FROM THE FRONT · 1 AT THE TOP OF THE REAR ROW', fr: 'VU DE FACE · 1 EN HAUT DE LA RANGÉE ARRIÈRE', es: 'VISTO DE FRENTE · 1 ARRIBA EN LA FILA TRASERA'}, + starKey: {en: 'INSIDE: REAR ROW (ODD) · OUTSIDE: FRONT ROW (EVEN)', fr: 'DEDANS : ARRIÈRE (IMPAIRS) · DEHORS : AVANT (PAIRS)', es: 'DENTRO: TRASERA (IMPARES) · FUERA: DELANTERA (PARES)'}, + starFoot: {en: (h) => `+11, −7 · every 40° · 18 in 2 turns · ${h} /s`, fr: (h) => `+11, −7 · tous les 40° · 18 en 2 tours · ${h} /s`, es: (h) => `+11, −7 · cada 40° · 18 en 2 vueltas · ${h} /s`}, + // ------------------------------------------------------------ the context figure + 'ctx:run': {en: 'The engine', fr: 'Le moteur', es: 'El motor'}, + 'ctxTxt:run': { + en: 'Two rows of nine cylinders, 20° apart, on one two-throw crankshaft. Each row drives its crankpin through one master rod and eight articulated rods.', + fr: 'Deux rangées de neuf cylindres décalées de 20°, sur un vilebrequin à deux manetons. Chaque rangée attaque son maneton par une bielle maîtresse et huit biellettes.', + es: 'Dos filas de nueve cilindros desfasadas 20°, sobre un cigüeñal de dos codos. Cada fila mueve su muñequilla con una biela maestra y ocho bielas articuladas.', + }, + 'ctx:ign': {en: 'Ignition', fr: 'Allumage', es: 'Encendido'}, + ignTxt: { + en: 'Two magnetos, each firing one of the two plugs in every cylinder: 36 plugs. The spark comes 25° before top centre (est.); the distributors turn at half crank speed.', + fr: 'Deux magnétos, chacune allume une des deux bougies de chaque cylindre : 36 bougies. L’étincelle jaillit 25° avant le point mort haut (estim.) ; les distributeurs tournent à mi-régime.', + es: 'Dos magnetos, cada uno enciende una de las dos bujías de cada cilindro: 36 bujías. La chispa salta 25° antes del punto muerto superior (est.); los distribuidores giran a media velocidad.', + }, + 'ctx:law': {en: 'Master and articulated', fr: 'Maîtresse et biellette', es: 'Maestra y articulada'}, + lawX: {en: 'CRANK °', fr: 'VILEBREQUIN °', es: 'CIGÜEÑAL °'}, + lawY: {en: 'DIFFERENCE, MM', fr: 'ÉCART, MM', es: 'DIFERENCIA, MM'}, + lawTxt: { + en: (m, a, sm, sa, lag, dz, cm, ca, late) => `Stroke ${sm} mm (master, ${m}) vs ${sa} mm (${a}). Its top centre comes ${lag}° ${late ? 'late' : 'early'} and ${dz} mm lower: compression ${cm} vs ${ca}.`, + fr: (m, a, sm, sa, lag, dz, cm, ca, late) => `Course ${sm} mm (maîtresse, ${m}) contre ${sa} mm (${a}). Son PMH arrive ${lag}° ${late ? 'plus tard' : 'plus tôt'} et ${dz} mm plus bas : compression ${cm} contre ${ca}.`, + es: (m, a, sm, sa, lag, dz, cm, ca, late) => `Carrera ${sm} mm (maestra, ${m}) frente a ${sa} mm (${a}). Su PMS llega ${lag}° ${late ? 'más tarde' : 'antes'} y ${dz} mm más abajo: compresión ${cm} frente a ${ca}.`, + }, + 'ctx:throws': {en: 'Two throws at 180°', fr: 'Deux manetons à 180°', es: 'Dos codos a 180°'}, + throwKey: {en: 'SEEN FROM THE FRONT', fr: 'VU DE FACE', es: 'VISTO DE FRENTE'}, + throwA: {en: 'REAR ROW PIN', fr: 'MANETON ARRIÈRE', es: 'MUÑEQUILLA TRASERA'}, + throwA2: {en: 'cylinders 1, 3, 5 … 17', fr: 'cylindres 1, 3, 5 … 17', es: 'cilindros 1, 3, 5 … 17'}, + throwB: {en: 'FRONT ROW PIN', fr: 'MANETON AVANT', es: 'MUÑEQUILLA DELANTERA'}, + throwB2: {en: 'cylinders 2, 4, 6 … 18', fr: 'cylindres 2, 4, 6 … 18', es: 'cilindros 2, 4, 6 … 18'}, + throwTxt: { + en: 'The rows are turned 20° and their pins 180°: the firings alternate row to row, 40° apart, so no row fires twice in a row.', + fr: 'Les rangées sont décalées de 20° et leurs manetons de 180° : les allumages alternent d’une rangée à l’autre, tous les 40°.', + es: 'Las filas están desfasadas 20° y sus muñequillas 180°: los encendidos alternan de fila en fila, cada 40°.', + }, + 'ctx:cam': {en: 'Cam ring', fr: 'Anneau à cames', es: 'Anillo de levas'}, + camX: {en: 'CRANK ° AFTER FIRING', fr: 'VILEBREQUIN ° APRÈS ALLUMAGE', es: 'CIGÜEÑAL ° TRAS ENCENDIDO'}, + camY: {en: 'LIFT', fr: 'LEVÉE', es: 'ALZADA'}, + 'st:power': {en: 'POWER', fr: 'DÉTENTE', es: 'EXPANSIÓN'}, + 'st:exhaust': {en: 'EXHAUST', fr: 'ÉCHAPPEMENT', es: 'ESCAPE'}, + 'st:intake': {en: 'INTAKE', fr: 'ADMISSION', es: 'ADMISIÓN'}, + 'st:compression': {en: 'COMPRESSION', fr: 'COMPRESSION', es: 'COMPRESIÓN'}, + camTxt: { + en: (c, n, L, li, le) => `Cylinder ${c}. ${n} lobes per track, the ring at ⅛ crank speed, against it. Inlet ${li} mm, exhaust ${le} mm of ${L} (est.).`, + fr: (c, n, L, li, le) => `Cylindre ${c}. ${n} lobes par piste, l’anneau à ⅛ du régime, en sens inverse. Admission ${li} mm, échappement ${le} mm sur ${L} (estim.).`, + es: (c, n, L, li, le) => `Cilindro ${c}. ${n} lóbulos por pista, el anillo a ⅛ de la velocidad, en sentido contrario. Admisión ${li} mm, escape ${le} mm de ${L} (est.).`, + }, + 'ctx:takeoff': {en: 'Power and torque', fr: 'Puissance et couple', es: 'Potencia y par'}, + toX: {en: 'RPM', fr: 'TR/MIN', es: 'RPM'}, + toY: {en: 'HP · TORQUE (DASHED)', fr: 'CH · COUPLE (TIRETS)', es: 'CV · PAR (TRAZOS)'}, + toTxt: { + en: (hp, n, map, tq) => `${hp} hp at ${n} rpm and ${map} inHg, ${tq} N·m. The FAA rates the CB16 at 2,050 hp, 2,700 rpm, 55.0″ (circle).`, + fr: (hp, n, map, tq) => `${hp} ch à ${n} tr/min et ${map} inHg, ${tq} N·m. La FAA donne au CB16 2 050 ch, 2 700 tr/min, 55,0″ (cercle).`, + es: (hp, n, map, tq) => `${hp} CV a ${n} rpm y ${map} inHg, ${tq} N·m. La FAA certifica el CB16 a 2050 CV, 2700 rpm, 55,0″ (círculo).`, + }, + 'ctx:cyl': {en: 'Cylinders and fins', fr: 'Cylindres et ailettes', es: 'Cilindros y aletas'}, + 'ctxTxt:cyl': { + en: 'Steel barrels under finned aluminium muffs, forged aluminium heads with fins cut from the solid. Black baffles force the air through the fins.', + fr: 'Fûts en acier sous des manchons d’aluminium à ailettes, culasses en aluminium forgé aux ailettes taillées dans la masse. Les déflecteurs noirs forcent l’air entre les ailettes.', + es: 'Camisas de acero bajo manguitos de aluminio con aletas, culatas de aluminio forjado con aletas talladas en la masa. Los deflectores negros obligan al aire a pasar por las aletas.', + }, + 'ctx:air': {en: 'Supercharger', fr: 'Compresseur', es: 'Compresor'}, + 'ctxTxt:air': { + en: 'The carburettor feeds the impeller’s eye; the impeller (7.29 × crank speed) throws the charge into the diffuser and out through 18 pipes, one per cylinder.', + fr: 'Le carburateur alimente l’ouïe du rouet ; le rouet (7,29 × le régime) projette le mélange dans le diffuseur puis dans 18 pipes, une par cylindre.', + es: 'El carburador alimenta el ojo del rodete; el rodete (7,29 × el régimen) lanza la mezcla al difusor y a 18 tubos, uno por cilindro.', + }, + 'ctx:prop': {en: 'Reduction gear', fr: 'Réducteur', es: 'Reductora'}, + 'ctxTxt:prop': { + en: 'A planetary gear in the nose: the sun on the crankshaft, the ring fixed, the carrier on the propeller shaft. 0.45 turn of the propeller per turn of the crank.', + fr: 'Un train planétaire dans le nez : le planétaire sur le vilebrequin, la couronne fixe, le porte-satellites sur l’arbre d’hélice. 0,45 tour d’hélice par tour de vilebrequin.', + es: 'Un tren planetario en el morro: el planeta en el cigüeñal, la corona fija, el portasatélites en el eje de la hélice. 0,45 vueltas de hélice por vuelta de cigüeñal.', + }, + 'ctx:oil': {en: 'Lubrication', fr: 'Lubrification', es: 'Lubricación'}, + 'ctxTxt:oil': { + en: 'Dry sump: the pressure pump at the rear feeds the crankshaft through its journals and drillings to the crankpins and knuckle pins; scavenge pumps return the oil to the tank.', + fr: 'Carter sec : la pompe de pression, à l’arrière, alimente le vilebrequin par ses tourillons et ses perçages jusqu’aux manetons et aux axes de biellettes ; les pompes de récupération renvoient l’huile au réservoir.', + es: 'Cárter seco: la bomba de presión, atrás, alimenta el cigüeñal por sus apoyos y taladros hasta las muñequillas y los bulones; las bombas de recuperación devuelven el aceite al depósito.', + }, + // ------------------------------------------------------------ buttons + grpSub: {en: 'SUBSYSTEMS', fr: 'SOUS-ENSEMBLES', es: 'SUBSISTEMAS'}, + grpCut: {en: 'CUTAWAY', fr: 'COUPE', es: 'CORTE'}, + grpView: {en: 'VIEW', fr: 'VUE', es: 'VISTA'}, + grpLayer: {en: 'LAYERS', fr: 'CALQUES', es: 'CAPAS'}, + 'sub:crank': {en: 'Rods & crank', fr: 'Embiellage', es: 'Bielas y cigüeñal'}, + 'sub:cyl': {en: 'Cylinders & fins', fr: 'Cylindres & ailettes', es: 'Cilindros y aletas'}, + 'sub:valve': {en: 'Cam rings', fr: 'Distribution', es: 'Distribución'}, + 'sub:ign': {en: 'Ignition', fr: 'Allumage', es: 'Encendido'}, + 'sub:air': {en: 'Supercharger', fr: 'Compresseur', es: 'Compresor'}, + 'sub:prop': {en: 'Gear & propeller', fr: 'Réducteur & hélice', es: 'Reductora y hélice'}, + 'sub:oil': {en: 'Lubrication', fr: 'Lubrification', es: 'Lubricación'}, + 'cut:glass': {en: 'Glass', fr: 'Verre', es: 'Cristal'}, + 'cut:section': {en: 'Section', fr: 'Section', es: 'Sección'}, + 'cut:solid': {en: 'Solid', fr: 'Pleine', es: 'Sólido'}, + 'cam:face': {en: 'Face', fr: 'Face', es: 'Frente'}, + 'cam:hero': {en: '¾ front', fr: '¾ avant', es: '¾ frontal'}, + 'cam:profile': {en: 'Profile', fr: 'Profil', es: 'Perfil'}, + 'cam:inside': {en: 'Inside', fr: 'Intérieur', es: 'Interior'}, + 'lay:cowl': {en: 'Cowling', fr: 'Capot', es: 'Capó'}, + 'lay:callouts': {en: 'Labels', fr: 'Légendes', es: 'Rótulos'}, + // ------------------------------------------------------------ captions + 'cap:hero': {en: 'On its run-up stand, propeller stopped', fr: 'Sur son banc d’essai, hélice arrêtée', es: 'En su banco de pruebas, hélice parada'}, + 'cap:stopped': {en: 'On its run-up stand, propeller stopped', fr: 'Sur son banc d’essai, hélice arrêtée', es: 'En su banco de pruebas, hélice parada'}, + 'cap:starter': {en: 'The starter turns the crank, no spark yet', fr: 'Le démarreur entraîne le vilebrequin, pas encore d’étincelle', es: 'El arranque gira el cigüeñal, aún sin chispa'}, + 'cap:ignition': {en: 'Magnetos on: the first firings miss, then catch', fr: 'Magnétos en marche : les premiers allumages ratent, puis prennent', es: 'Magnetos activos: los primeros encendidos fallan y luego prenden'}, + 'cap:idle': {en: 'Running on its own', fr: 'Il tourne seul', es: 'Gira solo'}, + 'cap:face': {en: 'Idle, from the front: the firing order goes round the star', fr: 'Ralenti, de face : l’ordre d’allumage parcourt l’étoile', es: 'Ralentí, de frente: el orden de encendido recorre la estrella'}, + 'cap:rowcut': {en: 'One row in section: a master rod and eight articulated rods', fr: 'Une rangée en coupe : une bielle maîtresse et huit biellettes', es: 'Una fila en sección: una biela maestra y ocho articuladas'}, + 'cap:apart': {en: 'The rows pulled apart: turned 20°, pins at 180°', fr: 'Les rangées écartées : décalées de 20°, manetons à 180°', es: 'Las filas separadas: desfasadas 20°, muñequillas a 180°'}, + 'cap:cam': {en: 'A cam ring lifts a valve, no camshaft', fr: 'Un anneau à cames lève une soupape, sans arbre à cames', es: 'Un anillo de levas abre una válvula, sin árbol de levas'}, + 'cap:takeoff': {en: 'Throttle open: to 2,700 rpm in real time', fr: 'Plein gaz : jusqu’à 2 700 tr/min en temps réel', es: 'Gases a fondo: hasta 2700 rpm en tiempo real'}, + 'cap:faceFull': {en: 'Take-off rpm: 2,050 hp, 405 firings a second', fr: 'Régime décollage : 2 050 ch, 405 allumages par seconde', es: 'Régimen de despegue: 2050 CV, 405 encendidos por segundo'}, + 'cap:heroEnd': {en: 'Throttle back to idle', fr: 'Retour au ralenti', es: 'Vuelta al ralentí'}, + 'cap:live': {en: '', fr: '', es: ''}, + // ------------------------------------------------------------ callouts [name, sub] + cProp: {en: ['Propeller', '3 BLADES · 0.45 × CRANK SPEED'], fr: ['Hélice', '3 PALES · 0,45 × LE RÉGIME'], es: ['Hélice', '3 PALAS · 0,45 × EL RÉGIMEN']}, + cMagneto: {en: ['Magneto', '2 ON THE NOSE · ONE PLUG EACH'], fr: ['Magnéto', '2 SUR LE NEZ · UNE BOUGIE CHACUNE'], es: ['Magneto', '2 EN EL MORRO · UNA BUJÍA CADA UNO']}, + cHarness: {en: ['Ignition harness', 'BRAIDED · 36 LEADS'], fr: ['Faisceau d’allumage', 'TRESSÉ · 36 FILS'], es: ['Arnés de encendido', 'TRENZADO · 36 CABLES']}, + cHead: {en: ['Cylinder head', 'FORGED ALUMINIUM · 2 VALVES'], fr: ['Culasse', 'ALUMINIUM FORGÉ · 2 SOUPAPES'], es: ['Culata', 'ALUMINIO FORJADO · 2 VÁLVULAS']}, + cFins: {en: ['Barrel fins', 'ALUMINIUM MUFF ON STEEL'], fr: ['Ailettes du fût', 'MANCHON ALU SUR ACIER'], es: ['Aletas de la camisa', 'MANGUITO DE ALUMINIO SOBRE ACERO']}, + cPushrod: {en: ['Pushrod tubes', 'FROM THE CAM RING'], fr: ['Tubes de culbuteurs', 'DEPUIS L’ANNEAU À CAMES'], es: ['Tubos de varillas', 'DESDE EL ANILLO DE LEVAS']}, + cPipe: {en: ['Intake pipe', '18 FROM THE SUPERCHARGER'], fr: ['Pipe d’admission', '18 DEPUIS LE COMPRESSEUR'], es: ['Tubo de admisión', '18 DESDE EL COMPRESOR']}, + cStarFront: {en: ['Front row', 'EVEN NUMBERS · 9 CYLINDERS'], fr: ['Rangée avant', 'NUMÉROS PAIRS · 9 CYLINDRES'], es: ['Fila delantera', 'NÚMEROS PARES · 9 CILINDROS']}, + cStarRear: {en: ['Rear row', 'ODD NUMBERS · TURNED 20°'], fr: ['Rangée arrière', 'NUMÉROS IMPAIRS · DÉCALÉE DE 20°'], es: ['Fila trasera', 'NÚMEROS IMPARES · DESFASADA 20°']}, + cNose: {en: ['Nose case', 'REDUCTION GEAR INSIDE'], fr: ['Carter de nez', 'LE RÉDUCTEUR DEDANS'], es: ['Cárter del morro', 'LA REDUCTORA DENTRO']}, + cMaster: {en: ['Master rod', 'ONE PIECE · ON THE CRANKPIN'], fr: ['Bielle maîtresse', 'MONOBLOC · SUR LE MANETON'], es: ['Biela maestra', 'DE UNA PIEZA · EN LA MUÑEQUILLA']}, + cArt: {en: ['Articulated rod', '8 PER ROW · ON KNUCKLE PINS'], fr: ['Biellette', '8 PAR RANGÉE · SUR AXES'], es: ['Biela articulada', '8 POR FILA · EN BULONES']}, + cKnuckle: {en: ['Crankpin', 'r = 76.2 MM · HALF THE STROKE'], fr: ['Maneton', 'r = 76,2 MM · LA DEMI-COURSE'], es: ['Muñequilla', 'r = 76,2 MM · MEDIA CARRERA']}, + cPiston: {en: ['Piston', 'BORE 146 MM'], fr: ['Piston', 'ALÉSAGE 146 MM'], es: ['Pistón', 'DIÁMETRO 146 MM']}, + cPinRear: {en: ['Rear crankpin', 'ROW 1, 3, 5 …'], fr: ['Maneton arrière', 'RANGÉE 1, 3, 5 …'], es: ['Muñequilla trasera', 'FILA 1, 3, 5 …']}, + cPinFront: {en: ['Front crankpin', '180° AWAY'], fr: ['Maneton avant', 'À 180°'], es: ['Muñequilla delantera', 'A 180°']}, + cRows: {en: ['Two rows of nine', 'THE CRANK IS SPLIT AT THE PINS'], fr: ['Deux rangées de neuf', 'VILEBREQUIN EN PIÈCES AUX MANETONS'], es: ['Dos filas de nueve', 'CIGÜEÑAL PARTIDO EN LAS MUÑEQUILLAS']}, + cCam: {en: ['Cam ring', '4 LOBES · ⅛ CRANK SPEED, REVERSED'], fr: ['Anneau à cames', '4 LOBES · ⅛ DU RÉGIME, INVERSE'], es: ['Anillo de levas', '4 LÓBULOS · ⅛ DEL RÉGIMEN, INVERSO']}, + cTappet: {en: ['Tappet', 'RIDES THE LOBE'], fr: ['Poussoir', 'SUIT LE LOBE'], es: ['Taqué', 'SIGUE EL LÓBULO']}, + cRocker: {en: ['Rocker', 'IN ITS BOX ON THE HEAD'], fr: ['Culbuteur', 'DANS SA BOÎTE SUR LA CULASSE'], es: ['Balancín', 'EN SU CAJA SOBRE LA CULATA']}, + cValve: {en: ['Inlet valve', 'OPENS 20° BEFORE TOP CENTRE'], fr: ['Soupape d’admission', 'OUVRE 20° AVANT LE PMH'], es: ['Válvula de admisión', 'ABRE 20° ANTES DEL PMS']}, + cSuper: {en: ['Supercharger', '1 STAGE · 7.29 × CRANK'], fr: ['Compresseur', '1 ÉTAGE · 7,29 × LE RÉGIME'], es: ['Compresor', '1 ETAPA · 7,29 × EL RÉGIMEN']}, + cCarb: {en: ['Carburettor', 'PRESSURE TYPE'], fr: ['Carburateur', 'À INJECTION'], es: ['Carburador', 'DE PRESIÓN']}, + cGear: {en: ['Reduction gear', '0.45 : 1'], fr: ['Réducteur', '0,45 : 1'], es: ['Reductora', '0,45 : 1']}, + cStarter: {en: ['Starter', 'AT THE REAR'], fr: ['Démarreur', 'À L’ARRIÈRE'], es: ['Arranque', 'ATRÁS']}, + cOilPump: {en: ['Oil pump', 'DRY SUMP'], fr: ['Pompe à huile', 'CARTER SEC'], es: ['Bomba de aceite', 'CÁRTER SECO']}, + cBaffle: {en: ['Baffles', 'FORCE THE AIR THROUGH THE FINS'], fr: ['Déflecteurs', 'FORCENT L’AIR ENTRE LES AILETTES'], es: ['Deflectores', 'OBLIGAN AL AIRE A PASAR']}, + cCowl: {en: ['Cowling', 'NACA RING'], fr: ['Capot', 'ANNEAU NACA'], es: ['Capó', 'ANILLO NACA']}, + // ------------------------------------------------------------ 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 console', fr: 'Tournez le téléphone pour la console complète', es: 'Gira el teléfono para ver la consola completa'}, + slowmo: {en: 'SLOW MOTION', fr: 'RALENTI', es: 'CÁMARA LENTA'}, + docTitle: {en: 'Radial · the R-2800 Double Wasp, taken apart', fr: 'Radial · le R-2800 Double Wasp, démonté', es: 'Radial · el R-2800 Double Wasp, desmontado'}, + footCredit: { + en: 'A C-series R-2800 after public sources, not affiliated with Pratt & Whitney', + fr: 'Un R-2800 de série C d’après les sources publiques, sans lien avec Pratt & Whitney', + es: 'Un R-2800 de la serie C según fuentes públicas, sin relación con Pratt & Whitney', + }, +}; + +/** the string for key in the page's language; extra arguments fill a templated one */ +export const T = makeT(S); +export const KEYS = Object.keys(S); +export const DICT = S; diff --git a/machines/radial/r2800-shared/live.js b/machines/radial/r2800-shared/live.js new file mode 100644 index 0000000..d659de6 --- /dev/null +++ b/machines/radial/r2800-shared/live.js @@ -0,0 +1,510 @@ +// Radial as a page you drive: the film's engine and overlay, run in real time. The starter, the magnetos and the +// throttle drive the one input (the crank angle, integrated from the engine speed each frame); slow motion, pause, +// the seven subsystems, the cutaways (glass, section, solid), four views, the cowling, orbit and zoom, H to hide the +// overlay. Left alone it plays the film's script as an auto tour with the operator's cursor, looped; a click, drag, +// wheel or key hands control to the visitor, with the engine as the tour left it; 25 s of quiet hands it back. +import {warmComposer} from 'teardown-kit/warm.js'; +import {CAMS, createConsole, CUTS, LAYERS, SUBS} from './console.js'; +import {CYL, FIRE, lift, ORDER, RATING, SPARK_ADV} from './engine.js'; +import {calloutsFor, CAM_CYL, camAt, CTX_OF, engineFrame, lookAtT, makeCursor, phaseOf, smoother, ss, SUB_OF, BTN_OF, VIEW_FN, blendLook, LOOK} from './film.js'; +import {loadFonts} from './fonts.js'; +import {airAt, camAtName, camSince, DURATION, lastSpark, rateAt, rpmAt, RUN, runAtTau, SCRIPT, starterAtTau, tauAt, thetaAt} from './schedule.js'; +import {createStage} from './stage.js'; +import {createChrome} from '../../../shared/chrome.js'; +import {LANG, 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 RATES = [1, 1 / 4, 1 / 10, 1 / 25, 1 / 40]; +// what each subsystem button shows: its own parts solid, the case and the cylinders as ghosts, the rest hidden +const ALL = {case: 1, crank: 1, cyl: 1, valve: 1, ign: 1, air: 1, prop: 1, nose: 1, oil: 1, stand: 1, cowl: 0, baffle: 1}; +const ISO = { + crank: {case: 0.1, cyl: 0.12, valve: 0, ign: 0, air: 0, prop: 0.12, nose: 0.1, oil: 0, baffle: 0, stand: 0.5}, + cyl: {case: 0.5, crank: 0, valve: 0.35, ign: 0, air: 0.25, prop: 0.2, nose: 0.4, oil: 0, stand: 0.5}, + valve: {case: 0.12, cyl: 0.16, crank: 0, ign: 0, air: 0, prop: 0.1, nose: 0.1, oil: 0, baffle: 0, stand: 0.5}, + ign: {case: 0.5, cyl: 0.4, crank: 0, valve: 0.25, air: 0.15, prop: 0.25, nose: 0.5, oil: 0, baffle: 0, stand: 0.5}, + air: {case: 0.35, cyl: 0.35, crank: 0, valve: 0.15, ign: 0, air: 0.4, prop: 0.2, nose: 0.3, oil: 0, baffle: 0, stand: 0.5}, + prop: {case: 0.3, cyl: 0.3, crank: 0.4, valve: 0, ign: 0, air: 0.2, nose: 0.15, oil: 0, baffle: 0, stand: 0.5}, + oil: {case: 0.18, cyl: 0.12, crank: 0.4, valve: 0, ign: 0, air: 0.12, prop: 0.12, nose: 0.15, baffle: 0, stand: 0.5}, +}; +const CTX_SUB = {crank: 'law', cyl: 'cyl', valve: 'cam', ign: 'ign', air: 'air', prop: 'prop', oil: 'oil'}; +const PLANE = {face: 'rowcut', hero: 'profile', profile: 'profile', inside: 'inside'}; +const INSIDE_CYL = 2; + +const CSS = (top) => ` +html,body{margin:0;background:#e9eef1 linear-gradient(#e4ebf0,#edf0f0 55%,#f3ece1) 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{filter:brightness(.93)} +.hit{position:absolute;pointer-events:auto;cursor:pointer} +.hit.ew{cursor:ew-resize} +.tourchip{position:absolute;left:800px;top:${30 + top}px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;height:24px;padding:0 12px;border:1.5px solid #2b2f34;background:rgba(246,243,234,.96);color:#22262b;font:700 12px 'Barlow Condensed',sans-serif;letter-spacing:1.3px;white-space:nowrap;cursor:pointer;pointer-events:auto} +.tourchip i{display:block;width:7px;height:7px;border-radius:50%;background:#0a8fe0} +.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:700 13px/1.4 'Barlow Condensed',sans-serif;letter-spacing:1px;color:#777b80;text-transform:uppercase;display:none} +@media (orientation:portrait) and (max-width:820px){.turn{display:block}} +`; + +export async function runLive() { + const style = document.createElement('style'); + style.textContent = CSS(0); + document.head.appendChild(style); + document.title = tr('docTitle'); + document.documentElement.lang = LANG; + await loadFonts(); + const app = document.getElementById('app'); + const canvas = document.createElement('canvas'); + canvas.id = 'gl'; + app.appendChild(canvas); + const st = createStage({canvas}); + const ui = createConsole(app, {live: true}); + const tourCursor = makeCursor(ui); + + // ------------------------------------------------------------ quality tiers + const TIERS = [ + {samples: 8, ao: true, shadow: 4096, pr: 2}, + {samples: 4, ao: false, shadow: 2048, pr: 1.5}, + {samples: 2, ao: false, shadow: 1024, 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: 'radial', name: 'Radial', 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]; + void pr; + st.setQuality(q); + fit(); + }; + applyTier(); + addEventListener('resize', fit); + const toStage = (cx, cy) => { + const r = app.getBoundingClientRect(); + return [(cx - r.left) / scale, (cy - r.top) / scale]; + }; + 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 engine, live: θ integrated from N each frame + const E = {th: 6, N: 0, target: RATING.idle, ign: false, starter: false, run: 0, air: 0, sparks: CYL.map(() => null), n: 0}; + const hash = (n) => { + const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); + }; + const stepEngine = (dtau, T) => { + const running = E.ign && E.run > 0.55; + if (E.starter && E.run >= 1) E.starter = false; + let want = 0; + let tc = 1.2; + if (running) { + want = E.target; + tc = want > E.N ? 0.9 : 1.1; + } else if (E.starter) { + want = RATING.crank; + tc = 0.35; + } + E.N += (want - E.N) * (1 - Math.exp(-dtau / tc)); + if (!running && !E.starter && E.N < 25) E.N = Math.max(0, E.N - 40 * dtau); + if (E.ign && E.N > 80) E.run = Math.min(1, E.run + dtau / 1.0); + if (!E.ign) E.run = Math.max(0, E.run - dtau / 0.4); + const th0 = E.th; + E.th += E.N * 6 * dtau; + E.air += (E.N / 60) * dtau * 0.2; + // the sparks this step crossed: each lights its charge with the engine's chance of catching + if (E.ign) { + const pCatch = 0.12 + 0.88 * smoother(E.run / 0.8); + for (const c of ORDER) { + const base = FIRE[c] - SPARK_ADV; + let m = Math.ceil((th0 - base) / 720); + for (let k = 0; k < 4; k++, m++) { + const ths = base + 720 * m; + if (ths > E.th) break; + if (ths <= th0) continue; + const f = (ths - th0) / Math.max(1e-9, E.th - th0); + E.sparks[c - 1] = {t: T - (1 - f) * frameDt, th: ths, lit: hash(m * 18 + ORDER.indexOf(c) + 7 + E.n * 0.0) < pCatch}; + } + } + } + }; + let frameDt = 1 / 60; + + // ------------------------------------------------------------ state + let T = 0; + let rateI = 3; + let paused = false; + let view = 'hero'; + let viewAt = 0; + let cut = 'solid'; + let sub = ''; + const layers = {cowl: false, callouts: true}; + 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 visNow = {...ALL}; + const pulse = (x, y) => (ring = {t: T, x, y}); + const pulseBtn = (k) => pulse(...ui.spots[k]); + + // ------------------------------------------------------------ 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; + cut = 'solid'; + sub = ''; + layers.cowl = false; + layers.callouts = true; + 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 name = camAtName(cyc); + view = {face: 'face', faceFull: 'face', rowcut: 'face', apart: 'hero', cam: 'hero', takeoff: 'profile', heroEnd: 'hero'}[name] || 'hero'; + viewAt = T - 10; + sub = {rowcut: 'crank', cam: 'valve'}[name] || ''; + cut = name === 'rowcut' ? 'section' : 'solid'; + for (const g of Object.keys(ALL)) visNow[g] = sub ? (ISO[sub][g] ?? 1) : ALL[g]; + // the engine carries on where the tour left it + const tau = tauAt(cyc); + Object.assign(E, {th: thetaAt(cyc), N: rpmAt(cyc), ign: tau >= RUN.ign, starter: starterAtTau(tau) > 0, run: runAtTau(tau), air: airAt(cyc)}); + E.target = clamp(Math.round(E.N / 50) * 50, RATING.idle, RATING.takeoff.rpm); + rateI = RATES.reduce((b, r, i) => (Math.abs(Math.log(r / rateAt(cyc))) < Math.abs(Math.log(RATES[b] / rateAt(cyc))) ? i : b), 0); + for (const cy of CYL) { + const s = lastSpark(cy.c, cyc); + E.sparks[cy.c - 1] = {t: T - (cyc - s.t), th: s.th, lit: s.lit}; + } + }; + tourChip.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + lastInput = T; + if (AP.on) takeOver(); + else startTour(); + }); + addEventListener('pointerdown', (e) => { + if (e.target !== tourChip) takeOver(); + }); + + // ------------------------------------------------------------ controls + const setView = (v) => { + view = v; + viewAt = T; + Object.assign(off, {az: 0, el: 0, k: 1}); + if (v === 'inside') cut = 'section'; + }; + const on = (k, fn) => + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + fn(); + }); + for (const k of CAMS) on(k, () => setView(k)); + for (const k of CUTS) on(k, () => (cut = k)); + for (const k of SUBS) on(k, () => (sub = sub === k ? '' : k)); + for (const k of LAYERS) on(k, () => (layers[k] = !layers[k])); + on('pause', () => (paused = !paused)); + on('starter', () => { + if (E.run < 1) E.starter = !E.starter; + }); + on('ignition', () => (E.ign = !E.ign)); + on('slowmo', () => (rateI = (rateI + 1) % RATES.length)); + { + const {SL} = ui; + const d = document.createElement('div'); + d.className = 'hit ew'; + Object.assign(d.style, {left: `${SL.x - 8}px`, top: '840px', width: `${SL.w + 16}px`, height: '26px'}); + const apply = (e) => { + const [x] = toStage(e.clientX, e.clientY); + const k = clamp((x - SL.x) / SL.w, 0, 1); + E.target = Math.round((RATING.idle + k * (RATING.takeoff.rpm - RATING.idle)) / 50) * 50; + lastInput = T; + }; + d.addEventListener('pointerdown', (e) => { + takeOver(); + d.setPointerCapture(e.pointerId); + const up = () => { + removeEventListener('pointermove', apply); + removeEventListener('pointerup', up); + }; + addEventListener('pointermove', apply); + addEventListener('pointerup', up); + apply(e); + }); + ui.root.appendChild(d); + } + addEventListener('keydown', (e) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const key = e.key.toLowerCase(); + const v = {1: 'face', 2: 'hero', 3: 'profile', 4: 'inside'}[key]; + takeOver(); + if (v) setView(v); + else if (key === ' ') paused = !paused; + else if (key === 'h') hidden = !hidden; + else if (key === 's') E.starter = E.run < 1 && !E.starter; + else if (key === 'm') E.ign = !E.ign; + else if (key === 'arrowleft') rateI = clamp(rateI + 1, 0, RATES.length - 1); + else if (key === 'arrowright') rateI = clamp(rateI - 1, 0, RATES.length - 1); + else if (key === 'arrowup') E.target = clamp(E.target + 100, RATING.idle, RATING.takeoff.rpm); + else if (key === 'arrowdown') E.target = clamp(E.target - 100, RATING.idle, RATING.takeoff.rpm); + 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, -25, 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 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)); + frameDt = dt; + 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 th; + let N; + let run; + let rate; + let spark; + let tgt; + let cursor; + let fxLook; + let caption = ''; + let calloutView; + let since; + let phase; + let ctx; + let ctxArg; + let camBtn; + let camHi = null; + let starterOn; + let ignOn; + let air; + let thr; + if (AP.on) { + // the film, exactly: its run, its look, its camera + const c = T - AP.t0; + const loop = Math.floor(c / DURATION); + const cyc = c - loop * DURATION; + if (loop > AP.loop) AP.loop = loop; + th = thetaAt(cyc); + const tau = tauAt(cyc); + N = rpmAt(cyc); + run = runAtTau(tau); + rate = rateAt(cyc); + spark = (cc) => { + const s = lastSpark(cc, cyc); + return {t: T - (cyc - s.t), th: s.th, lit: s.lit}; + }; + const name = camAtName(cyc); + since = camSince(cyc); + fxLook = lookAtT(cyc); + tgt = camAt(cyc); + cursor = tourCursor(cyc); + if (AP.loop > 0 && cyc < 0.8) { + const end = tourCursor(DURATION - 0.01); + const k = ss(0, 0.8, cyc); + cursor = {...cursor, x: end.x + (cursor.x - end.x) * k, y: end.y + (cursor.y - end.y) * k}; + } + phase = phaseOf(tau); + caption = tr('cap:' + (name === 'hero' && phase !== 'stopped' ? phase : name)); + calloutView = name; + ctx = CTX_OF[name] || 'run'; + ctxArg = name === 'cam' ? CAM_CYL : name === 'rowcut' ? 'front' : undefined; + camBtn = BTN_OF[name]; + if (fxLook.focusK > 0.5) camHi = CYL.map((cy) => (cy.c === CAM_CYL ? {in: lift(cy.c, 'in', th), ex: lift(cy.c, 'ex', th)} : {in: 0, ex: 0})); + starterOn = starterAtTau(tau) > 0; + ignOn = tau >= RUN.ign; + air = airAt(cyc); + thr = N; + st.setCut(fxLook.cut ? 'section' : 'solid', fxLook.cut || 'profile'); + sub = SUB_OF[name] || ''; + if (name === 'hero' && cyc > 1.5) since = -1; + } else { + if (T - lastInput > IDLE) startTour(); + rate = paused ? 0 : RATES[rateI]; + stepEngine(dt * rate, T); + th = E.th; + N = E.N; + run = E.run; + spark = (cc) => E.sparks[cc - 1]; + since = T - viewAt; + // the look: the subsystem's, eased + const want = sub ? {...ALL, ...ISO[sub], [sub]: 1} : {...ALL}; + want.cowl = layers.cowl ? 1 : 0; + if (sub === 'prop') want.nose = 0.15; + const kk = 1 - Math.exp(-dt / 0.25); + for (const g of Object.keys(ALL)) visNow[g] += ((want[g] ?? 1) - visNow[g]) * kk; + fxLook = {vis: {...visNow}, apart: 0, hideRear: 0, focus: 0}; + tgt = VIEW_FN[view](since); + tgt.az += off.az; + tgt.el = clamp(tgt.el + off.el, -20, 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}; + phase = !E.ign && !E.starter && N < 1 ? 'stopped' : E.starter && E.run < 0.55 ? (E.ign ? 'ignition' : 'starter') : N > E.target + 60 ? 'back' : N < E.target - 60 ? 'climb' : N > 2500 ? 'takeoff' : 'idle'; + calloutView = view === 'hero' ? 'hero' : view === 'face' ? 'face' : ''; + ctx = sub ? CTX_SUB[sub] : N > 1500 ? 'takeoff' : 'run'; + ctxArg = sub === 'valve' ? INSIDE_CYL : sub === 'crank' ? 'front' : undefined; + camBtn = view; + if (sub === 'valve') camHi = CYL.map((cy) => ({in: lift(cy.c, 'in', th), ex: lift(cy.c, 'ex', th)})); + starterOn = E.starter; + ignOn = E.ign; + air = E.air; + thr = E.target; + if (cut === 'section') { + if (view === 'inside') { + const a = CYL[INSIDE_CYL - 1].angle * D2R; + st.planes.inside.normal.set(-Math.cos(a), -Math.sin(a), 0); + st.planes.inside.constant = 0; + } + st.setCut('section', PLANE[view] + (view === 'inside' ? '' : '')); + } else st.setCut('solid'); + } + const {s, lit, blur} = engineFrame({th, N, run, rate: Math.max(rate, 1e-4), spark, now: T}); + + // the camera eases toward its target + 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] ?? 0) - (cam[key] ?? 0)) * k; + cam.fov = (cam.fov ?? 26) + ((tgt.fov ?? 26) - (cam.fov ?? 26)) * k; + cam.d = Math.exp(Math.log(cam.d) + (Math.log(tgt.d) - Math.log(cam.d)) * k); + const fx = { + th, + vis: fxLook.vis, + apart: fxLook.apart, + hideRear: fxLook.hideRear, + focus: fxLook.focus, + focusK: fxLook.focusK ?? 0, + glass: !AP.on && cut === 'glass', + glow: lit, + headGlow: lit.map((g) => g * 0.85 * (1 - (fxLook.focusK ?? 0))), + blur, + camHi, + air, + airK: !AP.on && sub === 'air' ? 1 : 0, + oil: th / 1440, + oilK: !AP.on && sub === 'oil' ? 1 : 0, + }; + st.frame(fx, {...cam, az: cam.az * D2R, el: cam.el * D2R}); + + ui.root.style.display = hidden ? 'none' : ''; + const callVis = layers.callouts && calloutView && since >= 0 ? ss(1.2, 2.0, since) : 0; + ui.update({ + ...s, + th, + lit, + rate: Math.max(rate, 1e-4), + phase, + starter: starterOn, + ignition: ignOn, + thrRpm: thr, + ctx, + ctxArg, + sub, + cut: AP.on ? (fxLook.cut ? 'section' : 'solid') : cut, + camBtn, + layers: {...layers}, + paused, + slowmo: rate < 0.98, + caption, + callouts: calloutView ? calloutsFor(calloutView, st, callVis) : [], + cursor, + }); + 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); + }; + st.frame({th: 6, vis: ALL, glow: CYL.map(() => 0), headGlow: CYL.map(() => 0), blur: 0}, {...cam, az: cam.az * D2R, el: cam.el * D2R}); + warmComposer(st.renderer, st.composer, st.scene, st.camera).then(() => requestAnimationFrame(frame)); + window.LIVE = { + st, + cam, + E, + startTour, + takeOver, + get tier() { + return tier; + }, + get T() { + return T; + }, + }; + void blendLook; + void LOOK; + void SCRIPT; +} diff --git a/machines/radial/r2800-shared/make-timeline.mjs b/machines/radial/r2800-shared/make-timeline.mjs new file mode 100644 index 0000000..060702e --- /dev/null +++ b/machines/radial/r2800-shared/make-timeline.mjs @@ -0,0 +1,47 @@ +// Writes ../r2800-film/timeline.json (the beats, for review sheets and the score) and ../r2800-film/events.json +// (every spark of the film, lit or missed, and the engine's speed, slow motion, manifold pressure and firing rate on +// screen sampled at 100 Hz), all from the same schedule and model the film draws. +// node r2800-shared/make-timeline.mjs (from machines/radial) +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {stateAt} from './engine.js'; +import {BEATS, DURATION, rateAt, rpmAt, RUN, runAtTau, SCRIPT, sparkList, tauAt, thetaAt} from './schedule.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const r = (x) => +x.toFixed(3); +// film time of a physical time (τ is monotone in t) +const tOfTau = (tau) => { + let lo = 0; + let hi = DURATION; + for (let k = 0; k < 50; k++) { + const mid = (lo + hi) / 2; + if (tauAt(mid) < tau) lo = mid; + else hi = mid; + } + return r((lo + hi) / 2); +}; +const beats = { + views: BEATS, + presses: SCRIPT.filter((e) => e.press).map((e) => e.t), + starter: tOfTau(RUN.starter), + ignition: tOfTau(RUN.ign), + disengage: tOfTau(RUN.disengage), + climb: tOfTau(RUN.climb), + full: tOfTau(RUN.climb + RUN.climbLen), + back: tOfTau(RUN.back), + idleAgain: tOfTau(RUN.back + RUN.backLen), +}; +fs.writeFileSync(path.join(HERE, '../r2800-film/timeline.json'), JSON.stringify({duration: DURATION, beats})); +const samples = []; +for (let i = 0; i <= DURATION * 100; i++) { + const t = i / 100; + const N = rpmAt(t); + const tau = tauAt(t); + const s = stateAt(N, runAtTau(tau)); + const rate = rateAt(t); + samples.push([r(N), r(rate), r(s.map), r(s.fireHz * rate * runAtTau(tau)), r(thetaAt(t))]); +} +const sparks = sparkList(); +fs.writeFileSync(path.join(HERE, '../r2800-film/events.json'), JSON.stringify({rate: 100, fields: ['rpm', 'slow', 'map', 'fireHzScreen', 'theta'], samples, sparks})); +console.log(beats, sparks.length, 'sparks', sparks.filter((s) => s[2]).length, 'lit'); diff --git a/machines/radial/r2800-shared/model.js b/machines/radial/r2800-shared/model.js new file mode 100644 index 0000000..a31986b --- /dev/null +++ b/machines/radial/r2800-shared/model.js @@ -0,0 +1,928 @@ +// The R-2800 itself, built from code in metres from the numbers in engine.js and against the reference photographs +// (REFERENCES.md): a grey-blue forged crankcase in three sections, 18 steel barrels under black finned muffs, raw +// aluminium heads with deep fins and two rocker boxes each, chromed pushrod tubes from two cam rings, two plugs per +// head on a braided harness ring, two magnetos on the nose, black intake pipes from the supercharger's diffuser, a +// two-throw crankshaft with a master rod and eight articulated rods per row, a planetary reduction gear, a three-blade +// propeller, a cowling and the black inter-cylinder baffles. update(fx) poses everything from the crank angle. +// +// World: the engine's axis is z (the propeller at +z), y up, the axis at the origin. An angle a measured clockwise +// from the top seen from the rear (engine.js) is a rotation of +a about z. +import * as THREE from 'three'; +import {mergeGeometries} from 'three/addons/utils/BufferGeometryUtils.js'; +import {CAM, CAM_PHASE, CYL, E, GEAR, lift, lobeOffset, PROP_D, ROWS, rowPose, VALVE} from './engine.js'; + +export const ACCENT = '#0a8fe0'; +const V3 = THREE.Vector3; +const D2R = Math.PI / 180; +const mm = (x) => x / 1000; +export const FLOOR_Y = -2.2; +const PROP_OFF = 0; // the blades' angle at θ = 0, chosen so frame 0 shows the cylinders between them + +// ------------------------------------------------------------ geometry helpers (millimetres in, metres out) +/** a lathed part around Y: profile [[r, y], ...] in mm */ +const lathe = (pts, seg = 40) => new THREE.LatheGeometry(pts.map(([r, y]) => new THREE.Vector2(mm(r), mm(y))), seg); +/** a lathed part around the engine axis z: profile [[r, z], ...] in mm */ +const latheZ = (pts, seg = 64) => lathe(pts, seg).rotateX(Math.PI / 2); +/** the same, its profile smoothed by a centripetal spline through the points (no facets at the knees) */ +const latheZs = (pts, seg = 72, n = 4) => { + const c = new THREE.SplineCurve(pts.map(([r, z]) => new THREE.Vector2(r, z))); + const out = c.getSpacedPoints(Math.max(8, pts.length * n)).map((v) => [Math.max(0, v.x), v.y]); + out[0] = pts[0]; + out[out.length - 1] = pts[pts.length - 1]; + return latheZ(out, seg); +}; +/** an annular fin: outer wall plus two flat faces, between y and y + t, from rIn to rOut (mm) */ +function finGeo(rIn, rOut, y, t, seg) { + const side = new THREE.CylinderGeometry(mm(rOut), mm(rOut), mm(t), seg, 1, true).translate(0, mm(y + t / 2), 0); + const top = new THREE.RingGeometry(mm(rIn), mm(rOut), seg, 1).rotateX(-Math.PI / 2).translate(0, mm(y + t), 0); + const bot = new THREE.RingGeometry(mm(rIn), mm(rOut), seg, 1).rotateX(Math.PI / 2).translate(0, mm(y), 0); + return [side, top, bot]; +} +const tube = (r, y0, y1, seg = 32, open = true) => new THREE.CylinderGeometry(mm(r), mm(r), mm(y1 - y0), seg, 1, open).translate(0, mm((y0 + y1) / 2), 0); +const merge = (list) => { + const g = mergeGeometries(list.map((x) => (x.index ? x.toNonIndexed() : x))); + g.computeBoundingSphere(); + return g; +}; +/** a gear disc in the XY plane (extruded along z): teeth, pitch radius, width, all mm */ +function gearGeo(teeth, rp, width, hole = 0) { + const m = (2 * rp) / teeth; + const ro = rp + m; + const rr = rp - 1.2 * m; + const sh = new THREE.Shape(); + for (let i = 0; i < teeth; i++) { + const a0 = (i / teeth) * Math.PI * 2; + const da = (Math.PI * 2) / teeth; + const pts = [ + [rr, a0], + [rr, a0 + da * 0.2], + [ro, a0 + da * 0.36], + [ro, a0 + da * 0.6], + [rr, a0 + da * 0.76], + ]; + pts.forEach(([r, a], k) => (i === 0 && k === 0 ? sh.moveTo(mm(r * Math.cos(a)), mm(r * Math.sin(a))) : sh.lineTo(mm(r * Math.cos(a)), mm(r * Math.sin(a))))); + } + sh.closePath(); + if (hole) { + const h = new THREE.Path(); + h.absarc(0, 0, mm(hole), 0, Math.PI * 2, true); + sh.holes.push(h); + } + const g = new THREE.ExtrudeGeometry(sh, {depth: mm(width), bevelEnabled: false, curveSegments: 2}); + g.translate(0, 0, -mm(width) / 2); + return g; +} +/** an internal (ring) gear: teeth pointing in */ +function ringGearGeo(teeth, rp, rOut, width) { + const m = (2 * rp) / teeth; + const ri = rp - m; + const rr = rp + 1.2 * m; + const sh = new THREE.Shape(); + sh.absarc(0, 0, mm(rOut), 0, Math.PI * 2, false); + const h = new THREE.Path(); + for (let i = 0; i < teeth; i++) { + const a0 = (i / teeth) * Math.PI * 2; + const da = (Math.PI * 2) / teeth; + const pts = [ + [rr, a0], + [rr, a0 + da * 0.2], + [ri, a0 + da * 0.36], + [ri, a0 + da * 0.6], + [rr, a0 + da * 0.76], + ]; + pts.forEach(([r, a], k) => (i === 0 && k === 0 ? h.moveTo(mm(r * Math.cos(a)), mm(r * Math.sin(a))) : h.lineTo(mm(r * Math.cos(a)), mm(r * Math.sin(a))))); + } + h.closePath(); + sh.holes.push(h); + const g = new THREE.ExtrudeGeometry(sh, {depth: mm(width), bevelEnabled: false, curveSegments: 24}); + g.translate(0, 0, -mm(width) / 2); + return g; +} +/** a cam track: base circle plus 4 lobes at the given ring angles (degrees clockwise from the rear), in XY */ +function camTrackGeo(phase, half, base, rise, width) { + const sh = new THREE.Shape(); + const N = 360; + for (let i = 0; i <= N; i++) { + const b = (i / N) * 360; // ring angle, clockwise from the top seen from the rear + const x = (((b - phase) % 90) + 90 + 45) % 90 - 45; + const u = Math.abs(x) / half; + const r = base + (u >= 1 ? 0 : rise * (0.5 + 0.5 * Math.cos(Math.PI * u))); + const px = -Math.sin(b * D2R) * r; + const py = Math.cos(b * D2R) * r; + if (i === 0) sh.moveTo(mm(px), mm(py)); + else sh.lineTo(mm(px), mm(py)); + } + const h = new THREE.Path(); + h.absarc(0, 0, mm(base - 16), 0, Math.PI * 2, true); + sh.holes.push(h); + const g = new THREE.ExtrudeGeometry(sh, {depth: mm(width), bevelEnabled: false, curveSegments: 48}); + g.translate(0, 0, -mm(width) / 2); + return g; +} +const curveTube = (pts, r, seg = 48, rs = 10) => new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts.map((p) => new V3(mm(p[0]), mm(p[1]), mm(p[2])))), seg, mm(r), rs, false); +/** a point given by its engine angle (clockwise from the top seen from the rear), radius and z (mm) */ +const polar = (a, r, z) => [-Math.sin(a * D2R) * r, Math.cos(a * D2R) * r, z]; + +// ------------------------------------------------------------ textures from code +function braidTexture() { + const c = document.createElement('canvas'); + c.width = 64; + c.height = 16; + const g = c.getContext('2d'); + g.fillStyle = '#8f918c'; + g.fillRect(0, 0, 64, 16); + for (let x = -16; x < 80; x += 4) { + g.strokeStyle = x % 8 ? '#6c6e69' : '#b4b5b0'; + g.lineWidth = 1.6; + g.beginPath(); + g.moveTo(x, 0); + g.lineTo(x + 16, 16); + g.stroke(); + g.beginPath(); + g.moveTo(x + 16, 0); + g.lineTo(x, 16); + g.stroke(); + } + const t = new THREE.CanvasTexture(c); + t.wrapS = t.wrapT = THREE.RepeatWrapping; + t.repeat.set(40, 1); + t.colorSpace = THREE.SRGBColorSpace; + return t; +} +function blurTexture() { + const c = document.createElement('canvas'); + c.width = 256; + c.height = 4; + const g = c.getContext('2d'); + const gr = g.createLinearGradient(0, 0, 256, 0); + gr.addColorStop(0, 'rgba(24,24,24,0.0)'); + gr.addColorStop(0.08, 'rgba(24,24,24,0.55)'); + gr.addColorStop(0.5, 'rgba(26,26,26,0.42)'); + gr.addColorStop(0.9, 'rgba(30,30,30,0.3)'); + gr.addColorStop(0.94, 'rgba(214,170,40,0.55)'); + gr.addColorStop(1, 'rgba(214,170,40,0.0)'); + g.fillStyle = gr; + g.fillRect(0, 0, 256, 4); + const t = new THREE.CanvasTexture(c); + t.colorSpace = THREE.SRGBColorSpace; + return t; +} + +export function buildEngine() { + const root = new THREE.Group(); + const CLIP = [new THREE.Plane(new V3(0, 0, -1), 0)]; + const M = { + fins: new THREE.MeshStandardMaterial({color: '#18191b', roughness: 0.62, metalness: 0.35, side: THREE.DoubleSide}), + case: new THREE.MeshStandardMaterial({color: '#6b7884', roughness: 0.55, metalness: 0.35, side: THREE.DoubleSide}), + caseDark: new THREE.MeshStandardMaterial({color: '#56616b', roughness: 0.58, metalness: 0.35, side: THREE.DoubleSide}), + alu: new THREE.MeshStandardMaterial({color: '#a3a7ab', roughness: 0.5, metalness: 0.8, side: THREE.DoubleSide}), + chrome: new THREE.MeshStandardMaterial({color: '#aeb2b7', roughness: 0.42, metalness: 1, envMapIntensity: 0.55}), + steel: new THREE.MeshStandardMaterial({color: '#a7acb2', roughness: 0.42, metalness: 0.95, side: THREE.DoubleSide}), + steelDark: new THREE.MeshStandardMaterial({color: '#6e7277', roughness: 0.48, metalness: 0.9, side: THREE.DoubleSide}), + piston: new THREE.MeshStandardMaterial({color: '#c9ccd0', roughness: 0.45, metalness: 0.8, side: THREE.DoubleSide}), + black: new THREE.MeshStandardMaterial({color: '#1e2022', roughness: 0.62, metalness: 0.25, side: THREE.DoubleSide}), + exhaust: new THREE.MeshStandardMaterial({color: '#4a3f37', roughness: 0.75, metalness: 0.5}), + braid: new THREE.MeshStandardMaterial({color: '#9a9b96', roughness: 0.55, metalness: 0.7, map: braidTexture()}), + hub: new THREE.MeshStandardMaterial({color: '#3b3e42', roughness: 0.48, metalness: 0.7}), + plug: new THREE.MeshStandardMaterial({color: '#8c8f93', roughness: 0.45, metalness: 0.9}), + stand: new THREE.MeshStandardMaterial({color: '#55585b', roughness: 0.6, metalness: 0.5}), + cowl: new THREE.MeshStandardMaterial({color: '#959ba1', roughness: 0.5, metalness: 0.55, side: THREE.DoubleSide, envMapIntensity: 0.6}), + blade: new THREE.MeshStandardMaterial({color: '#ffffff', roughness: 0.62, metalness: 0.2, vertexColors: true, side: THREE.DoubleSide}), + oil: new THREE.MeshStandardMaterial({color: '#8a6a3a', roughness: 0.5, metalness: 0.6, emissive: '#c58a2a', emissiveIntensity: 0}), + glass: new THREE.MeshPhysicalMaterial({color: '#e9eef2', roughness: 0.1, metalness: 0, transparent: true, opacity: 0.13, depthWrite: false, side: THREE.DoubleSide}), + flame: new THREE.MeshBasicMaterial({color: ACCENT, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide}), + accent: new THREE.MeshBasicMaterial({color: ACCENT}), + blur: new THREE.MeshBasicMaterial({color: '#2a2b2c', transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide}), + blurTip: new THREE.MeshBasicMaterial({color: '#c99a1e', transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide}), + }; + // every lit material can be cut by the section plane + for (const k of Object.keys(M)) M[k].clipShadows = true; + + // groups: what each subsystem button isolates + const GROUPS = ['case', 'crank', 'cyl', 'valve', 'ign', 'air', 'prop', 'nose', 'oil', 'stand', 'cowl', 'baffle']; + const parts = Object.fromEntries(GROUPS.map((g) => [g, []])); + const add = (parent, geo, mat, group, opts = {}) => { + const m = new THREE.Mesh(geo, mat); + m.castShadow = opts.cast ?? true; + m.receiveShadow = opts.receive ?? true; + m.userData = {group, mat, glassable: !!opts.glassable, nocut: !!opts.nocut}; + parent.add(m); + parts[group].push(m); + return m; + }; + + // per-row assemblies (they slide apart along the axis in the "rows apart" view) + const rowG = {rear: new THREE.Group(), front: new THREE.Group()}; + root.add(rowG.rear, rowG.front); + + // ------------------------------------------------------------ the power section crankcase + const caseGeo = latheZ([ + [0, -232], + [236, -232], + [252, -214], + [258, -150], + [262, -105], + [258, -60], + [250, 0], + [258, 60], + [262, 105], + [258, 150], + [252, 214], + [246, 250], + [0, 250], + ]); + add(root, caseGeo, M.case, 'case', {glassable: true}); + // the cylinder pads, one boss per cylinder + const padGeo = tube(112, 236, 262, 36, false); + for (const cy of CYL) { + const p = add(rowG[cy.row], padGeo, M.caseDark, 'case', {glassable: true}); + p.rotation.z = cy.angle * D2R; + p.position.z = mm(E.rowZ[cy.row]); + p.rotation.order = 'ZYX'; + } + + // ------------------------------------------------------------ cylinders + const barrelGeo = (() => { + const g = [tube(84, 262, 300), tube(73, 236, 470), tube(84, 438, 448)]; + g.push(...finGeo(84, 99, 254, 10, 40)); + for (let y = 302; y < 432; y += 5.6) g.push(...finGeo(84, 102, y, 1.8, 40)); + return merge(g); + })(); + const headGeo = (() => { + const g = [lathe([[86, 440], [88, 470], [86, 540], [70, 580], [40, 598], [0, 604]], 40)]; + for (let y = 452, i = 0; y < 570; y += 6.6, i++) { + const k = (y - 452) / 118; + g.push(...finGeo(84 - 22 * k * k, 112 - 26 * k * k, y, 2.4, 40)); + } + // the combustion chamber's roof, seen in section + g.push(lathe([[73, 470], [70, 478], [52, 492], [0, 500]], 36)); + return merge(g); + })(); + // rocker boxes: one over each valve, their covers at the top + const boxGeo = merge([new THREE.CapsuleGeometry(mm(24), mm(62), 6, 16).rotateX(Math.PI / 2), new THREE.CylinderGeometry(mm(29), mm(29), mm(8), 20).translate(0, mm(16), mm(-18))]); + const portGeo = new THREE.CylinderGeometry(mm(34), mm(38), mm(60), 20).rotateX(Math.PI / 2); + const exGeo = curveTube([[0, 512, 92], [0, 520, 128], [0, 540, 158]], 22, 12, 12); + const plugGeo = merge([new THREE.CylinderGeometry(mm(10), mm(10), mm(44), 12).rotateX(Math.PI / 2), new THREE.CylinderGeometry(mm(14), mm(14), mm(12), 6).rotateX(Math.PI / 2).translate(0, 0, mm(-14))]); + const flameGeo = lathe([[70, 436], [72, 468], [52, 490], [0, 498]], 32); + const cylinders = CYL.map((cy) => { + const g = new THREE.Group(); + g.rotation.z = cy.angle * D2R; + g.position.z = mm(E.rowZ[cy.row]); + rowG[cy.row].add(g); + add(g, barrelGeo, M.fins, 'cyl', {glassable: true}); + const headMat = M.alu.clone(); + headMat.clipShadows = true; + headMat.emissive = new THREE.Color(ACCENT); + headMat.emissiveIntensity = 0; + const head = add(g, headGeo, headMat, 'cyl', {glassable: true}); + head.userData.ownMat = true; + head.userData.mat = headMat; + const flame = add(g, flameGeo, M.flame.clone(), 'cyl', {cast: false, receive: false}); + flame.userData.isFlame = true; + flame.renderOrder = 2; + // the cam side: the front row's ring is in front of it, the rear row's behind + const side = cy.row === 'front' ? 1 : -1; + for (const s of [-1, 1]) { + const b = add(g, boxGeo, M.alu, 'cyl'); + b.position.set(0, mm(592), mm(60 * s)); + b.rotation.x = -0.32 * s; + } + add(g, portGeo, M.alu, 'cyl').position.set(0, mm(522), mm(-100)); + const ex = add(g, exGeo, M.exhaust, 'cyl'); + if (cy.row === 'rear') ex.scale.z = 0.55; + for (const s of [-1, 1]) { + const p = add(g, plugGeo, M.plug, 'ign'); + p.position.set(mm(s * 30), mm(476), mm(110 * s)); + p.rotation.y = s * 0.25; + } + return {cy, g, head, flame, side}; + }); + + // ------------------------------------------------------------ the valve train: two cam rings, tappets, pushrods, rockers, valves + const camRings = {}; + for (const row of ['rear', 'front']) { + const side = row === 'front' ? 1 : -1; + const z = E.rowZ[row] + side * 125; + const g = new THREE.Group(); + g.position.z = mm(z); + rowG[row].add(g); + const half = {in: (VALVE.in[1] - VALVE.in[0]) / 16, ex: (VALVE.ex[1] - VALVE.ex[0]) / 16}; + const tin = add(g, camTrackGeo(CAM_PHASE[row].in, half.in, 140, 12, 12), M.steel, 'valve'); + tin.position.z = mm(-8); + const tex = add(g, camTrackGeo(CAM_PHASE[row].ex, half.ex, 140, 12, 12), M.steel, 'valve'); + tex.position.z = mm(8); + add(g, tube(120, -14, 14, 48, false).rotateX(Math.PI / 2), M.steelDark, 'valve'); + camRings[row] = g; + } + const tappetGeo = merge([tube(9, 0, 96, 12, false), new THREE.CylinderGeometry(mm(12), mm(12), mm(10), 14).rotateZ(Math.PI / 2)]); + const rodGeo = (a, b) => curveTube([a, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2], b], 7.5, 8, 10); + // the pushrod tubes: the near valve goes straight up the cam side, the far one around the barrel + const nearTube = curveTube([[18, 262, 125], [18, 420, 124], [18, 566, 116]], 7.5, 24, 10); + const farTube = curveTube([[-24, 262, 125], [-92, 340, 86], [-114, 440, 0], [-92, 528, -84], [-24, 566, -116]], 7.5, 48, 10); + const valveGeo = merge([tube(4.5, 470, 580, 10, false), lathe([[0, 462], [27, 462], [27, 466], [10, 474], [4.5, 486]], 24)]); + const rockerGeo = merge([new THREE.BoxGeometry(mm(12), mm(10), mm(96)).translate(0, 0, mm(10)), new THREE.CylinderGeometry(mm(9), mm(9), mm(22), 12).rotateZ(Math.PI / 2)]); + const train = cylinders.map((C) => { + const {g, side} = C; + const out = {}; + for (const ev of ['in', 'ex']) { + // the inlet valve is at the rear of the head, the exhaust at the front + const vs = ev === 'in' ? -1 : 1; + const near = vs === side; + const tz = side * 125 + (ev === 'in' ? -8 : 8); + const tp = add(g, tappetGeo, M.steel, 'valve'); + tp.position.set(mm(near ? 18 : -18), mm(152), mm(tz)); + const tb = add(g, near ? nearTube : farTube, M.chrome, 'valve', {glassable: true}); + if (side < 0) tb.scale.z = -1; + const v = add(g, valveGeo, M.steel, 'valve'); + v.position.z = mm(34 * vs); + v.rotation.x = 0; + const rk = add(g, rockerGeo, M.steel, 'valve'); + rk.position.set(0, mm(590), mm(70 * vs)); + rk.scale.z = vs; + out[ev] = {tp, tb, v, rk, near}; + } + return out; + }); + + // every part that belongs to one cylinder knows which (the cam view shows one cylinder's train); its tappet stays + for (const C of cylinders) C.g.traverse((o) => o.isMesh && (o.userData.cyl = C.cy.c)); + for (const tr of train) for (const ev of ['in', 'ex']) tr[ev].tp.userData.keep = true; + + // ------------------------------------------------------------ the crankshaft: two throws at 180°, two halves + const crank = {}; + const webGeo = (() => { + const sh = new THREE.Shape(); + const R = 128; + sh.moveTo(mm(-R * Math.sin(1.1)), mm(-R * Math.cos(1.1))); + sh.absarc(0, 0, mm(R), -Math.PI / 2 - 1.1, -Math.PI / 2 + 1.1, false); + sh.lineTo(mm(52), mm(20)); + sh.absarc(0, mm(E.r), mm(54), 0, Math.PI, false); + sh.lineTo(mm(-52), mm(20)); + sh.closePath(); + const g = new THREE.ExtrudeGeometry(sh, {depth: mm(26), bevelEnabled: true, bevelThickness: mm(2), bevelSize: mm(2), bevelSegments: 1, curveSegments: 20}); + g.translate(0, 0, -mm(13)); + return g; + })(); + for (const row of ['rear', 'front']) { + const g = new THREE.Group(); + g.position.z = mm(E.rowZ[row]); + rowG[row].add(g); + const sign = row === 'rear' ? 1 : -1; // the front pin is 180° from the rear one + const inner = row === 'rear' ? 1 : -1; // toward the centre of the engine + for (const dz of [-44, 44]) { + const w = add(g, webGeo, M.steel, 'crank'); + w.position.z = mm(dz); + if (sign < 0) w.rotation.z = Math.PI; + } + add(g, tube(46, -32, 32, 32, false).rotateX(Math.PI / 2).translate(0, mm(E.r * sign), 0), M.steel, 'crank'); + // main journals: outward to the end bearing, inward to the centre one + add(g, tube(50, 0, 160, 32, false).rotateX(Math.PI / 2).translate(0, 0, mm(-inner * 130)), M.steel, 'crank'); + add(g, tube(50, -inner * 60, -inner * 4, 32, false).rotateX(Math.PI / 2).translate(0, 0, mm(inner * 60 + (inner > 0 ? 0 : 0))), M.steel, 'crank'); + // the oil drilling, through the journal and out to the crankpin + add(g, curveTube([[0, 0, -inner * 150], [0, 0, -inner * 40], [0, E.r * sign * 0.5, 0], [0, E.r * sign, 0]], 7, 24, 8), M.oil, 'oil', {cast: false}); + crank[row] = g; + } + // the centre main bearing + add(root, tube(58, -16, 16, 36, false).rotateX(Math.PI / 2), M.steelDark, 'crank'); + + // ------------------------------------------------------------ rods and pistons + const pistonGeo = (() => { + const g = [tube(72.5, -50, 66, 40, false), new THREE.CircleGeometry(mm(72.5), 40).rotateX(-Math.PI / 2).translate(0, mm(66), 0)]; + for (const y of [40, 48, 56]) g.push(tube(73.2, y, y + 2.2, 40, false)); + return merge(g); + })(); + const pinGeo = new THREE.CylinderGeometry(mm(16), mm(16), mm(128), 16).rotateZ(Math.PI / 2); + const masterGeo = (() => { + const sh = new THREE.Shape(); + sh.absarc(0, 0, mm(88), 0, Math.PI * 2, false); + const h = new THREE.Path(); + h.absarc(0, 0, mm(47), 0, Math.PI * 2, true); + sh.holes.push(h); + const flange = new THREE.ExtrudeGeometry(sh, {depth: mm(52), bevelEnabled: false, curveSegments: 40}).translate(0, 0, -mm(26)); + const shank = new THREE.BoxGeometry(mm(34), mm(E.L - 110), mm(30)).translate(0, mm(80 + (E.L - 110) / 2 - 10), 0); + const eye = new THREE.CylinderGeometry(mm(26), mm(26), mm(34), 20).rotateX(Math.PI / 2).translate(0, mm(E.L), 0); + return merge([flange, shank, eye]); + })(); + const knuckleGeo = new THREE.CylinderGeometry(mm(11), mm(11), mm(60), 14).rotateX(Math.PI / 2); + const artGeo = merge([ + new THREE.BoxGeometry(mm(22), mm(E.l - 40), mm(18)).translate(0, mm(E.l / 2), 0), + new THREE.CylinderGeometry(mm(18), mm(18), mm(22), 16).rotateX(Math.PI / 2), + new THREE.CylinderGeometry(mm(22), mm(22), mm(26), 16).rotateX(Math.PI / 2).translate(0, mm(E.l), 0), + ]); + const rods = {}; + for (const row of ['rear', 'front']) { + const m = E.master[row]; + const mg = add(rowG[row], masterGeo, M.steel, 'crank'); + const knuckles = []; + for (const cy of ROWS[row]) { + if (cy.c === m) continue; + const k = add(mg, knuckleGeo, M.steelDark, 'crank'); + const d = (cy.angle - CYL[m - 1].angle) * D2R; + k.position.set(-Math.sin(d) * mm(E.rk), Math.cos(d) * mm(E.rk), 0); + knuckles.push(k); + } + const arts = {}; + for (const cy of ROWS[row]) if (cy.c !== m) arts[cy.c] = add(rowG[row], artGeo, M.steel, 'crank'); + rods[row] = {master: mg, arts}; + } + const pistons = CYL.map((cy) => { + const p = add(cylinders[cy.c - 1].g, pistonGeo, M.piston, 'crank'); + add(p, pinGeo, M.steelDark, 'crank'); + return p; + }); + + // ------------------------------------------------------------ the nose: reduction gear, magnetos, harness, propeller + add( + root, + latheZs([ + [246, 250], + [282, 282], + [300, 330], + [292, 400], + [252, 470], + [205, 540], + [165, 598], + [128, 640], + [0, 652], + ]), + M.case, + 'nose', + {glassable: true}, + ); + const gearG = new THREE.Group(); + gearG.position.z = mm(380); + root.add(gearG); + const RG = {sun: 72, ring: 88, module: 3.4}; // 72 / (72 + 88) = 0.45 [E: the layout] + const rpS = (RG.sun * RG.module) / 2; + const rpR = (RG.ring * RG.module) / 2; + const rpP = (rpR - rpS) / 2; + const sun = add(gearG, gearGeo(RG.sun, rpS, 34, 30), M.steel, 'prop'); + add(gearG, ringGearGeo(RG.ring, rpR, rpR + 16, 34), M.steelDark, 'prop'); + const carrier = new THREE.Group(); + gearG.add(carrier); + add(carrier, new THREE.CylinderGeometry(mm(rpR - 4), mm(rpR - 4), mm(10), 48).rotateX(Math.PI / 2).translate(0, 0, mm(26)), M.steelDark, 'prop'); + const planets = []; + for (let i = 0; i < 6; i++) { + const pg = add(carrier, gearGeo(8, rpP, 34, 0), M.steel, 'prop'); + const a = (i / 6) * Math.PI * 2; + pg.position.set(Math.cos(a) * mm(rpS + rpP), Math.sin(a) * mm(rpS + rpP), 0); + planets.push(pg); + } + // the propeller shaft (splined) and hub: they turn with the carrier + const propG = new THREE.Group(); + root.add(propG); + add(propG, tube(62, 600, 760, 32, false).rotateX(Math.PI / 2), M.steel, 'prop'); + add(propG, latheZs([[0, 720], [132, 722], [140, 760], [130, 830], [100, 880], [50, 905], [0, 910]], 48), M.hub, 'prop'); + const bladeGeo = (() => { + const R = PROP_D * 500; + const rows = 28; + const cols = 14; + const pos = []; + const col = []; + const idx = []; + const tipY = new THREE.Color('#d8a925'); + const blk = new THREE.Color('#1b1c1d'); + for (let i = 0; i <= rows; i++) { + const u = i / rows; + const r = 150 + (R - 150) * u; + const chord = u < 0.1 ? 120 + 1300 * u : 250 * (1 - 0.45 * Math.max(0, (u - 0.3) / 0.7) ** 1.4) + (u > 0.97 ? -1500 * (u - 0.97) : 0); + const thick = 60 * (1 - u) + 7; + const tw = (52 - 40 * u) * D2R; + const cc = (r > R - 120 ? tipY : blk).clone(); + for (let j = 0; j < cols; j++) { + const a = (j / cols) * Math.PI * 2; + const x = (Math.cos(a) * chord) / 2; + const z = (Math.sin(a) * thick) / 2; + pos.push(mm(x * Math.cos(tw) - z * Math.sin(tw)), mm(r), mm(x * Math.sin(tw) + z * Math.cos(tw))); + col.push(cc.r, cc.g, cc.b); + } + } + for (let i = 0; i < rows; i++) + for (let j = 0; j < cols; j++) { + const a = i * cols + j; + const b = i * cols + ((j + 1) % cols); + idx.push(a, b, a + cols, b, b + cols, a + cols); + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); + g.setIndex(idx); + g.computeVertexNormals(); + return g; + })(); + const blades = []; + for (let i = 0; i < 3; i++) { + const b = add(propG, bladeGeo, M.blade, 'prop'); + b.rotation.z = (i * 2 * Math.PI) / 3; + b.position.z = mm(790); + blades.push(b); + } + const blurDisc = add(root, new THREE.RingGeometry(mm(150), mm(PROP_D * 500 - 120), 96, 1), M.blur, 'prop', {cast: false, receive: false}); + const blurTip = add(blurDisc, new THREE.RingGeometry(mm(PROP_D * 500 - 120), mm(PROP_D * 500), 96, 1), M.blurTip, 'prop', {cast: false, receive: false}); + blurDisc.position.z = mm(790); + blurDisc.renderOrder = 3; + // two magnetos on the nose, their distributors turning at half crank speed + const magnetos = []; + for (const a of [-38, 38]) { + const g = new THREE.Group(); + const [x, y] = polar(a, 300, 0); + g.position.set(mm(x), mm(y), mm(360)); + g.rotation.z = a * D2R; + root.add(g); + add(g, new THREE.BoxGeometry(mm(92), mm(96), mm(128)).translate(0, mm(34), 0), M.caseDark, 'ign'); + const cap = add(g, new THREE.CylinderGeometry(mm(42), mm(46), mm(30), 24).translate(0, mm(96), 0), M.black, 'ign'); + const rotor = add(g, new THREE.BoxGeometry(mm(60), mm(6), mm(10)).translate(mm(16), mm(114), 0), M.steel, 'ign'); + void cap; + magnetos.push({g, rotor}); + } + // the harness ring and its 36 leads + add(root, new THREE.TorusGeometry(mm(302), mm(15), 12, 96), M.braid, 'ign').position.z = mm(268); + const plugAt = (cy, s) => { + // a plug's tip in world mm: s +1 front plug, -1 rear plug + const a = cy.angle; + const local = [30 * s, 476, 110 * s + 22 * s]; + const r = local[1]; + const t = (local[0] * Math.PI) / 180 / 4.76; // small tangential offset as an angle + void t; + const ang = a - (local[0] / r) * (180 / Math.PI); + return polar(ang, r, E.rowZ[cy.row] + local[2]); + }; + for (const cy of CYL) { + for (const s of [1, -1]) { + const p = plugAt(cy, s); + const a0 = cy.angle + (cy.row === 'front' ? 0 : 0); + const start = polar(a0, 302, 268); + let path; + if (cy.row === 'front' && s > 0) path = [start, polar(a0, 380, 262), polar(a0 - 3, 455, 245), p]; + else if (cy.row === 'front') path = [start, polar(a0 - 14, 360, 250), polar(a0 - 17, 440, 120), polar(a0 - 14, 470, 20), p]; + else if (s > 0) path = [start, polar(a0, 340, 240), polar(a0, 420, 130), polar(a0 - 3, 466, 30), p]; + else path = [start, polar(a0, 340, 240), polar(a0, 420, 130), polar(a0 - 13, 470, -60), polar(a0 - 9, 474, -180), p]; + add(root, curveTube(path, 5, 40, 6), M.braid, 'ign', {cast: false}); + } + } + + // ------------------------------------------------------------ the rear: supercharger, diffuser, 18 intake pipes, carburettor, accessories + add( + root, + latheZs([ + [0, -232], + [252, -232], + [318, -268], + [336, -300], + [330, -340], + [282, -400], + [272, -430], + [262, -520], + [248, -760], + [238, -1000], + [228, -1180], + [150, -1240], + [0, -1244], + ]), + M.case, + 'air', + {glassable: true}, + ); + const impG = new THREE.Group(); + impG.position.z = mm(-312); + root.add(impG); + add(impG, latheZ([[0, -26], [40, -26], [140, 10], [140, 16], [0, 16]], 40), M.alu, 'air'); + const vane = new THREE.BoxGeometry(mm(4), mm(98), mm(34)).translate(0, mm(90), 0); + for (let i = 0; i < 18; i++) { + const v = add(impG, vane, M.alu, 'air'); + v.rotation.z = (i / 18) * Math.PI * 2; + } + // the pipes: from the diffuser's outlets to each cylinder's inlet port + const pipeCurves = []; + for (const cy of CYL) { + const zr = E.rowZ[cy.row]; + const a = cy.angle; + const pts = [polar(a, 318, -300), polar(a, 400, -310), polar(a, 492, cy.row === 'front' ? -150 : zr - 175), polar(a, 522, zr - 128)]; + const c = new THREE.CatmullRomCurve3(pts.map((p) => new V3(mm(p[0]), mm(p[1]), mm(p[2])))); + pipeCurves.push(c); + add(root, new THREE.TubeGeometry(c, 40, mm(34), 14, false), M.black, 'air', {glassable: true}); + } + // carburettor on top of the rear case, the air scoop's throat + add(root, new THREE.BoxGeometry(mm(240), mm(150), mm(260)).translate(0, mm(330), mm(-560)), M.black, 'air'); + add(root, new THREE.BoxGeometry(mm(200), mm(60), mm(220)).translate(0, mm(430), mm(-560)), M.caseDark, 'air'); + // the starter at the very rear, a generator and pumps on their pads + add(root, latheZ([[0, -1244], [96, -1244], [100, -1300], [92, -1420], [60, -1450], [0, -1452]], 32), M.black, 'case'); + for (const [a, r, len] of [[120, 160, 230], [240, 160, 230], [180, 170, 180]]) { + const [x, y] = polar(a, r, 0); + add(root, new THREE.CylinderGeometry(mm(64), mm(64), mm(len), 24).rotateX(Math.PI / 2).translate(mm(x), mm(y), mm(-1240 - len / 2 + 30)), M.caseDark, 'case'); + } + // the oil system: pressure pump low at the rear, its line to the rear main bearing, the scavenge sump, the nose feed + add(root, new THREE.BoxGeometry(mm(150), mm(120), mm(170)).translate(0, mm(-250), mm(-760)), M.caseDark, 'oil'); + const oilLines = [ + [[0, -250, -680], [0, -238, -480], [0, -120, -300], [0, -40, -240]], + [[60, -262, -150], [50, -300, -400], [40, -300, -700], [40, -260, -760]], + [[-120, -230, 250], [-200, -250, 330], [-160, -230, 440], [-60, -120, 470]], + ]; + const oilCurves = oilLines.map((pts) => new THREE.CatmullRomCurve3(pts.map((p) => new V3(mm(p[0]), mm(p[1]), mm(p[2]))))); + for (const c of oilCurves) add(root, new THREE.TubeGeometry(c, 40, mm(11), 10, false), M.oil, 'oil'); + add(root, new THREE.BoxGeometry(mm(240), mm(60), mm(300)).translate(0, mm(-272), 0), M.caseDark, 'oil'); + + // ------------------------------------------------------------ baffles, cowling, the test stand + const baffleGeo = (() => { + const sh = new THREE.Shape(); + sh.moveTo(mm(-44), mm(420)); + sh.lineTo(mm(44), mm(420)); + sh.lineTo(mm(56), mm(560)); + sh.lineTo(mm(-56), mm(560)); + sh.closePath(); + return new THREE.ExtrudeGeometry(sh, {depth: mm(3), bevelEnabled: false}).translate(0, 0, -mm(1.5)).rotateY(Math.PI / 2); + })(); + for (const row of ['rear', 'front']) { + for (const cy of ROWS[row]) { + const b = add(rowG[row], baffleGeo, M.black, 'baffle'); + b.rotation.z = (cy.angle + 20) * D2R; + b.position.z = mm(E.rowZ[row]); + b.rotation.order = 'ZYX'; + b.scale.set(1, 1, 1); + } + } + const cowl = add( + root, + latheZ( + [ + [640, -300], + [668, -120], + [690, 120], + [700, 260], + [690, 330], + [655, 352], + [610, 340], + [612, 320], + [664, 300], + [672, 240], + [662, 120], + [636, -120], + [612, -298], + ], + 96, + ), + M.cowl, + 'cowl', + {glassable: true}, + ); + void cowl; + // the mount ring at the rear and a tubular run-up stand down to the concrete + add(root, new THREE.TorusGeometry(mm(352), mm(22), 12, 64).translate(0, 0, mm(-360)), M.stand, 'stand'); + const leg = (a, b) => { + const A = new V3(...a.map(mm)); + const B = new V3(...b.map(mm)); + const g = new THREE.CylinderGeometry(mm(24), mm(24), A.distanceTo(B), 12); + const m = add(root, g, M.stand, 'stand'); + m.position.copy(A).add(B).multiplyScalar(0.5); + m.quaternion.setFromUnitVectors(new V3(0, 1, 0), B.clone().sub(A).normalize()); + }; + const Fy = FLOOR_Y * 1000; + for (const s of [-1, 1]) { + leg([s * 300, -180, -360], [s * 700, Fy, -150]); + leg([s * 300, -180, -360], [s * 700, Fy, -1100]); + leg([s * 340, 60, -360], [s * 700, Fy + 900, -620]); + leg([s * 700, Fy, -150], [s * 700, Fy, -1100]); + } + leg([-700, Fy, -1100], [700, Fy, -1100]); + leg([-700, Fy, -150], [700, Fy, -150]); + + // ------------------------------------------------------------ the air and the oil, as particles along their paths + const dotGeo = new THREE.SphereGeometry(1, 10, 8); + const airDots = new THREE.InstancedMesh(dotGeo, new THREE.MeshBasicMaterial({color: ACCENT, transparent: true, opacity: 0.9, depthWrite: false}), 18 * 5 + 60); + airDots.frustumCulled = false; + airDots.renderOrder = 4; + root.add(airDots); + const oilDots = new THREE.InstancedMesh(dotGeo, new THREE.MeshBasicMaterial({color: '#d0902c', transparent: true, opacity: 0.95, depthWrite: false}), 3 * 10); + oilDots.frustumCulled = false; + oilDots.renderOrder = 4; + root.add(oilDots); + // the charge's path inside the rear case: the carburettor's throat down to the impeller's eye, out to the diffuser + const throat = new THREE.CatmullRomCurve3([new V3(0, 0.33, -0.56), new V3(0, 0.22, -0.48), new V3(0, 0.06, -0.4), new V3(0, 0.0, -0.33)]); + + // ------------------------------------------------------------ anchors for the callouts (world, metres) + const top = CYL[0]; + const front1 = CYL[1]; + const A = { + head: () => cylinders[0].g.localToWorld(new V3(0, mm(560), 0)), + fins: () => cylinders[0].g.localToWorld(new V3(mm(-100), mm(360), 0)), + headFront: () => cylinders[1].g.localToWorld(new V3(0, mm(540), mm(30))), + pushrod: () => cylinders[1].g.localToWorld(new V3(mm(18), mm(400), mm(124))), + harness: () => new V3(...polar(-60, 302, 268).map(mm)), + magneto: () => magnetos[1].g.localToWorld(new V3(0, mm(60), 0)), + nose: () => new V3(0, mm(250), mm(520)), + hub: () => new V3(0, 0, mm(900)), + blade: () => blades[0].localToWorld(new V3(0, 1.4, 0)), + pipe: () => pipeCurves[8].getPoint(0.55), + carb: () => new V3(0, mm(410), mm(-560)), + starter: () => new V3(0, 0, mm(-1440)), + impeller: () => impG.localToWorld(new V3(0, mm(120), 0)), + camRing: (row = 'front') => camRings[row].localToWorld(new V3(mm(-100), mm(-100), 0)), + master: (row = 'front') => rods[row].master.localToWorld(new V3(0, mm(150), 0)), + art: (row, c) => rods[row].arts[c].localToWorld(new V3(0, mm(E.l * 0.5), 0)), + pin: (row = 'front') => crank[row].localToWorld(new V3(0, mm(E.r * (row === 'rear' ? 1 : -1)), mm(-44))), + piston: (c) => pistons[c - 1].localToWorld(new V3(0, mm(66), 0)), + tappet: (c, ev) => train[c - 1][ev].tp.localToWorld(new V3(0, mm(40), 0)), + rocker: (c, ev) => train[c - 1][ev].rk.localToWorld(new V3(0, 0, mm(30))), + valve: (c, ev) => train[c - 1][ev].v.localToWorld(new V3(0, mm(470), 0)), + gear: () => gearG.localToWorld(new V3(mm(-90), mm(90), 0)), + oilPump: () => new V3(0, mm(-250), mm(-760)), + sump: () => new V3(mm(100), mm(-280), 0), + baffle: () => cylinders[4].g.localToWorld(new V3(mm(120), mm(520), 0)), + cowl: () => new V3(mm(-600), mm(330), mm(300)), + cylAt: (c, y = 520) => cylinders[c - 1].g.localToWorld(new V3(0, mm(y), 0)), + }; + void top; + void front1; + + // ------------------------------------------------------------ cutaways and visibility + const ghostOf = new Map(); + const ghost = (mat) => { + let g = ghostOf.get(mat); + if (!g) { + g = mat.clone(); + g.transparent = true; + g.depthWrite = false; + g.clipShadows = true; + ghostOf.set(mat, g); + } + return g; + }; + const cut = {mode: 'solid', glass: false}; + const all = Object.values(parts).flat(); + const allMats = new Set(); + for (const m of all) allMats.add(m.material); + for (const m of Object.values(M)) allMats.add(m); + const setCut = (mode, plane) => { + cut.mode = mode; + if (plane) CLIP[0].copy(plane); + const planes = mode === 'section' ? CLIP : null; + for (const mat of [...allMats, ...ghostOf.values()]) { + mat.clippingPlanes = planes; + mat.needsUpdate = true; + } + }; + + // ------------------------------------------------------------ pose everything from the crank angle + const tmp = new V3(); + const m4 = new THREE.Matrix4(); + const q = new THREE.Quaternion(); + const sc = new V3(); + const up = new V3(0, 1, 0); + /** + * fx: th (crank °), vis {group: 0..1 opacity}, glass (bool: case, barrels, heads, pipes as glass), apart (0..1), + * glow[18] (the flame in each chamber, 0..1), headGlow[18], camHi[18] ({in, ex} 0..1 accent on the tappet and tube), + * blur (propeller blur 0..1), air (phase), airK (0..1), oil (phase), oilK (0..1) + */ + const update = (fx) => { + const th = fx.th; + const vis = fx.vis || {}; + // groups' visibility: 1 is the part's own material, 0 is hidden, between is a ghost + for (const g of GROUPS) { + const v = vis[g] ?? 1; + for (const m of parts[g]) { + if (v <= 0.01) { + m.visible = false; + continue; + } + const vf = fx.focus && m.userData.cyl && m.userData.cyl !== fx.focus && !m.userData.keep ? v * (1 - (fx.focusK ?? 1)) : v; + if (vf <= 0.01) { + m.visible = false; + continue; + } + m.visible = true; + if (m.userData.isFlame) continue; + const base = m.userData.mat; + if (m.userData.glassable && fx.glass) m.material = M.glass; + else if (m.userData.ownMat) { + m.material = base; + base.transparent = vf < 0.99; + base.depthWrite = vf >= 0.99; + base.opacity = vf; + } else if (vf < 0.99) { + const gm = ghost(base); + gm.opacity = vf; + m.material = gm; + } else m.material = base; + } + } + const apart = fx.apart || 0; + rowG.front.position.z = apart * 0.2; + rowG.rear.position.z = -apart * 0.14; + rowG.rear.visible = (fx.hideRear || 0) < 0.5; + // crank + crank.rear.rotation.z = th * D2R; + crank.front.rotation.z = th * D2R; + // rods and pistons, from the exact geometry of the master and articulated rods + for (const row of ['rear', 'front']) { + const P = rowPose(row, th); + const z = mm(E.rowZ[row]); + const R = rods[row]; + R.master.position.set(mm(-P.C[0]), mm(P.C[1]), z); + R.master.rotation.z = Math.atan2(P.d[0], P.d[1]); + for (const cy of ROWS[row]) { + const x = P.cyl[cy.c]; + pistons[cy.c - 1].position.y = mm(x.s); + if (x.master) continue; + const art = R.arts[cy.c]; + const dx = x.P[0] - x.K[0]; + const dy = x.P[1] - x.K[1]; + art.position.set(mm(-x.K[0]), mm(x.K[1]), z); + art.rotation.z = Math.atan2(dx, dy); + } + } + // the cam rings turn against the crank at 1/8; tappets ride their lobes, rockers and valves follow + for (const row of ['rear', 'front']) camRings[row].rotation.z = th * CAM.ratio * D2R; + for (const C of cylinders) { + const c = C.cy.c; + for (const ev of ['in', 'ex']) { + const T = train[c - 1][ev]; + const L = lift(c, ev, th); + T.tp.position.y = mm(152 + 12 * L); + T.v.position.y = mm(-CAM.liftMm * L); + T.rk.rotation.x = L * 0.2 * (ev === 'in' ? -1 : 1); + const hi = fx.camHi ? fx.camHi[c - 1]?.[ev] || 0 : 0; + if (hi > 0.05) { + T.tp.material = M.accent; + T.v.material = M.accent; + } + } + const glow = fx.glow ? fx.glow[c - 1] : 0; + C.flame.material.opacity = Math.min(0.85, glow) * (vis.cyl ?? 1); + C.flame.visible = glow > 0.01 && (vis.cyl ?? 1) > 0.01 && (!fx.focus || fx.focus === c || (fx.focusK ?? 1) < 0.5); + const hg = fx.headGlow ? fx.headGlow[c - 1] : 0; + if (C.head.material !== M.glass) C.head.material.emissiveIntensity = 0.95 * hg; + } + // the reduction gear: the sun on the crank, the ring fixed, the carrier (and the propeller) at 0.45 + const prop = th * GEAR.prop; + sun.rotation.z = th * D2R; + carrier.rotation.z = prop * D2R; + // a pinion rolling on the fixed ring: its spin relative to the carrier is -(R/p) times the carrier's turn + for (const p of planets) p.rotation.z = (-(rpR / rpP) * prop) * D2R; + propG.rotation.z = (prop + (fx.propOff ?? PROP_OFF)) * D2R; + const blur = fx.blur || 0; + const bo = (1 - blur) * (vis.prop ?? 1); + for (const b of blades) { + b.visible = bo > 0.02; + if (b.visible) { + const gm = bo < 0.99 ? ghost(M.blade) : M.blade; + gm.opacity = bo; + b.material = gm; + } + } + blurDisc.visible = blur > 0.01 && (vis.prop ?? 1) > 0.01; + blurDisc.material.opacity = blur * 0.32 * (vis.prop ?? 1); + blurTip.visible = blurDisc.visible; + blurTip.material.opacity = blur * 0.45 * (vis.prop ?? 1); + blurDisc.rotation.z = prop * D2R; + impG.rotation.z = th * GEAR.impLow * D2R; + for (const mg of magnetos) mg.rotor.rotation.y = th * 0.5 * D2R; + // the charge: dots down the throat, through the impeller, out along every pipe + const airK = fx.airK || 0; + airDots.visible = airK > 0.01; + if (airDots.visible) { + let n = 0; + const ph = fx.air || 0; + const s = 0.011 * airK; + for (let i = 0; i < 18; i++) { + for (let j = 0; j < 5; j++) { + const u = (((j / 5 + ph + i * 0.137) % 1) + 1) % 1; + pipeCurves[i].getPoint(u, tmp); + m4.compose(tmp, q, sc.set(s, s, s)); + airDots.setMatrixAt(n++, m4); + } + } + for (let j = 0; j < 60; j++) { + const u = (((j / 60 + ph * 3) % 1) + 1) % 1; + if (u < 0.5) throat.getPoint(u * 2, tmp); + else { + const a = j * 2.4 + ph * 20; + const r = 0.04 + (u - 0.5) * 0.58; + tmp.set(Math.cos(a) * r, Math.sin(a) * r, -0.31); + } + m4.compose(tmp, q, sc.set(s, s, s)); + airDots.setMatrixAt(n++, m4); + } + airDots.instanceMatrix.needsUpdate = true; + airDots.material.opacity = 0.9 * airK; + } + const oilK = fx.oilK || 0; + oilDots.visible = oilK > 0.01; + M.oil.emissiveIntensity = 0.6 * oilK; + if (oilDots.visible) { + let n = 0; + for (let i = 0; i < 3; i++) + for (let j = 0; j < 10; j++) { + const u = (((j / 10 + (fx.oil || 0)) % 1) + 1) % 1; + oilCurves[i].getPoint(u, tmp); + m4.compose(tmp, q, sc.set(0.014, 0.014, 0.014)); + oilDots.setMatrixAt(n++, m4); + } + oilDots.instanceMatrix.needsUpdate = true; + oilDots.material.opacity = oilK; + } + void up; + void lobeOffset; + }; + + return {root, update, setCut, CLIP, A, parts, M, cylinders, rods, pistons, crank, camRings, blades, rowG, plugAt, cut, FLOOR_Y}; +} diff --git a/machines/radial/r2800-shared/schedule.js b/machines/radial/r2800-shared/schedule.js new file mode 100644 index 0000000..eb0fc8b --- /dev/null +++ b/machines/radial/r2800-shared/schedule.js @@ -0,0 +1,168 @@ +// The film's operator script and its one input. A run of the engine is planned once in physical time τ (s): the +// starter, the ignition, the first firings missed then caught, idle, the climb to take-off rpm, the throttle back. +// The film plays it through a slow-motion map τ(t). The crank angle θ is integrated from the rpm once, at load, and +// every pose, valve, spark, gauge and sound of the film is read from θ(t). No three.js: node imports it for the +// timeline and the score. +import {FIRE, ORDER, RATING, SPARK_ADV, stateAt} from './engine.js'; + +export const DURATION = 40; +export const FPS_TABLE = 1200; // table rate, samples per second of film + +// operator moves: views and the buttons the cursor presses +export const SCRIPT = [ + {t: 0, cam: 'hero'}, + {t: 1.6, press: 'starter'}, + {t: 3.3, press: 'ignition'}, + {t: 6.4, cam: 'face'}, + {t: 12.6, cam: 'rowcut'}, + {t: 18.6, cam: 'apart'}, + {t: 23.6, cam: 'cam'}, + {t: 28.4, cam: 'takeoff', press: 'throttle'}, + {t: 32.6, cam: 'faceFull'}, + {t: 35.4, cam: 'heroEnd', press: 'throttleBack'}, +]; +export const BEATS = SCRIPT.filter((e) => e.t > 0).map((e) => e.t); +export const camAtName = (t) => SCRIPT.filter((e) => e.cam && e.t <= t).pop().cam; +export const camSince = (t) => t - SCRIPT.filter((e) => e.cam && e.t <= t).pop().t; + +const smoother = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * u * (u * (u * 6 - 15) + 10); +}; +// slow motion: τ seconds per film second, eased between keys in log space +const RATE = [ + [0, 1], + [6.4, 1], + [7.6, 1 / 25], + [12.6, 1 / 25], + [13.4, 1 / 40], + [23.6, 1 / 40], + [24.4, 1 / 14], + [28.4, 1 / 14], + [29.6, 1], + [DURATION, 1], +]; +export const rateAt = (t) => { + let i = 0; + while (i < RATE.length - 2 && t >= RATE[i + 1][0]) i++; + const [t0, a] = RATE[i]; + const [t1, b] = RATE[i + 1]; + const k = smoother((t - t0) / (t1 - t0)); + return Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * k); +}; + +// ------------------------------------------------------------ the tables: τ(t), then N(τ), θ, the air +const NT = Math.ceil(DURATION * FPS_TABLE) + 2; +const TAU = new Float64Array(NT); +for (let i = 1; i < NT; i++) { + const t = (i - 0.5) / FPS_TABLE; + TAU[i] = TAU[i - 1] + rateAt(t) / FPS_TABLE; +} +const lerpT = (arr, t) => { + const x = Math.min(NT - 1.001, Math.max(0, t * FPS_TABLE)); + const i = Math.floor(x); + return arr[i] + (arr[i + 1] - arr[i]) * (x - i); +}; +export const tauAt = (t) => lerpT(TAU, t); + +// the run, in physical seconds, keyed to the film's beats +export const RUN = (() => { + const s = tauAt(1.6); + const ign = tauAt(3.3); + return { + starter: s, // the starter engages + ign, // magnetos on + catch0: ign + 0.25, // the first firings, mostly missed + catch1: ign + 1.05, // every cylinder firing + disengage: ign + 1.3, // the starter lets go + climb: tauAt(29.7), // the throttle opens + climbLen: 3.0, + back: tauAt(35.6), // the throttle closes + backLen: 3.0, + }; +})(); +const {idle, crank} = RATING; +const TO = RATING.takeoff.rpm; +/** the planned engine speed (rpm) at physical time τ */ +export const rpmAtTau = (tau) => { + const R = RUN; + let N = crank * smoother((tau - R.starter) / 1.0); + // caught: a flare to ~1,050 rpm, then idle + const up = smoother((tau - (R.ign + 0.85)) / 1.0); + const settle = smoother((tau - (R.ign + 1.85)) / 1.15); + N += up * (1050 - crank) - settle * (1050 - idle); + N += smoother((tau - R.climb) / R.climbLen) * (TO - idle); + N -= smoother((tau - R.back) / R.backLen) * (TO - idle); + return Math.max(0, N); +}; +/** 0 on the starter, 1 when every cylinder fires and the engine carries itself */ +export const runAtTau = (tau) => smoother((tau - (RUN.ign + 0.6)) / 1.0); +/** the chance that a spark lights its charge, while the engine catches */ +export const catchAtTau = (tau) => (tau < RUN.ign ? 0 : 0.12 + 0.88 * smoother((tau - RUN.catch0) / (RUN.catch1 - RUN.catch0))); +export const starterAtTau = (tau) => (tau >= RUN.starter && tau < RUN.disengage ? 1 : 0); + +const TH = new Float64Array(NT); // crank angle, degrees +const AIR = new Float64Array(NT); // the charge carried down the pipes, in turns of a pipe (for the particles) +const THETA0 = 6; +TH[0] = THETA0; +for (let i = 1; i < NT; i++) { + const tau = (TAU[i] + TAU[i - 1]) / 2; + const dtau = TAU[i] - TAU[i - 1]; + const N = rpmAtTau(tau); + TH[i] = TH[i - 1] + N * 6 * dtau; + const st = stateAt(N, runAtTau(tau)); + AIR[i] = AIR[i - 1] + (N / 60) * (st.map / 29.92) * dtau * 0.2; +} +export const thetaAt = (t) => lerpT(TH, t); +export const airAt = (t) => lerpT(AIR, t); +export const rpmAt = (t) => rpmAtTau(tauAt(t)); +/** film time at which the crank reaches th (θ is monotone) */ +export const tAtTheta = (th) => { + if (th <= TH[0]) return 0; + let lo = 0; + let hi = NT - 1; + if (th >= TH[hi]) return DURATION; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (TH[mid] < th) lo = mid; + else hi = mid; + } + const f = (th - TH[lo]) / Math.max(1e-9, TH[hi] - TH[lo]); + return (lo + f) / FPS_TABLE; +}; + +// ------------------------------------------------------------ the sparks +const hash = (n) => { + const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); +}; +const IDX = Object.fromEntries(ORDER.map((c, i) => [c, i])); +/** spark number m of cylinder c (m-th cycle): its crank angle */ +const sparkTheta = (c, m) => FIRE[c] - SPARK_ADV + 720 * m; +/** does spark (c, m) light its charge? the ignition must be on and the engine catching */ +export const firesAt = (c, m, tau) => hash(m * 18 + IDX[c] + 7) < catchAtTau(tau); +/** the last spark of cylinder c at film time t: when it was (film s), whether it lit, its crank angle */ +export function lastSpark(c, t) { + const th = thetaAt(t); + const m = Math.floor((th - (FIRE[c] - SPARK_ADV)) / 720); + const ths = sparkTheta(c, m); + const ts = tAtTheta(ths); + const tau = tauAt(ts); + return {t: ts, th: ths, lit: tau >= RUN.ign && firesAt(c, m, tau), m}; +} +/** every spark of the film after the ignition, for the score: [t, cylinder, lit] */ +export function sparkList() { + const out = []; + const th1 = TH[NT - 1]; + for (const c of ORDER) { + for (let m = -1; sparkTheta(c, m) < th1; m++) { + const ths = sparkTheta(c, m); + if (ths < TH[0]) continue; + const t = tAtTheta(ths); + const tau = tauAt(t); + if (tau < RUN.ign) continue; + out.push([+t.toFixed(4), c, firesAt(c, m, tau) ? 1 : 0]); + } + } + return out.sort((a, b) => a[0] - b[0]); +} diff --git a/machines/radial/r2800-shared/score.py b/machines/radial/r2800-shared/score.py new file mode 100644 index 0000000..6990726 --- /dev/null +++ b/machines/radial/r2800-shared/score.py @@ -0,0 +1,129 @@ +"""Score for Radial (an R-2800 Double Wasp after public sources), every sound on the film's own timeline. + +The starter whines up and settles on A4 (the one key, D minor, that every tonal sound keeps: the starter, the low +drone, the click); the first sparks after the magnetos come on are coughs, the missed ones silent (events.json lists +every spark, lit or not, from the same schedule the film draws). Once the engine runs, each firing is a dull thud +while they come slower than ~12 a second on screen (the slow-motion stretches); faster, they merge into a growl +whose pulse is the firing rate on screen, 9 a turn, with the half- and first-order beats under it. The propeller's +wash rises with its speed. A quiet click on each button the tour's cursor presses. + + node r2800-shared/make-timeline.mjs && python3 "$SKILL_DIR/run_sfx.py" r2800-shared/score.py +""" + +import json +import math +import os +import random + +from sfx.dsp import svf, zeros +from sfx.score_kit import airy, bang, hz, thud +from sfx.synth import SR, bandnoise, click + +TIMELINE = "../r2800-film/timeline.json" +OUT = os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), "radial-score.wav") +TARGET_LUFS = -14.0 +SEED = 2800 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def smooth(a, b, x): + u = min(1.0, max(0.0, (x - a) / (b - a))) + return u * u * (3 - 2 * u) + + +def score(mix, tl): + dur = tl["duration"] + b = tl["beats"] + with open(os.path.join(HERE, "../r2800-film/events.json"), encoding="utf8") as f: + ev = json.load(f) + rate = ev["rate"] + S = ev["samples"] # rpm, slow, map, fireHzScreen, theta + n = int(dur * SR) + + def at(t, i): + x = min(len(S) - 1.001, max(0.0, t * rate)) + k = int(x) + return S[k][i] + (S[k + 1][i] - S[k][i]) * (x - k) + + # ---------------------------------------------------------------- the low drone: D, very quiet, the whole way + drone = zeros(n) + f0 = hz("D2") + for k in range(n): + t = k / SR + env = min(1.0, t / 2.0, (dur - t) / 2.5) + drone[k] = env * (math.sin(2 * math.pi * f0 * t) + 0.5 * math.sin(2 * math.pi * f0 * 1.5 * t + 0.4 * math.sin(0.3 * t)) + 0.3 * math.sin(2 * math.pi * f0 * 2 * t)) + drone = svf(drone, 500, 0.6, "lp") + mix.put(0, drone, 0.05) + + # ---------------------------------------------------------------- the starter: a whine up to A4, then it lets go + t0 = b["starter"] + t1 = b["disengage"] + m = int((t1 - t0 + 0.6) * SR) + whine = [] + ph = 0.0 + for k in range(m): + t = t0 + k / SR + spin = smooth(t0, t0 + 1.0, t) if t < t1 else max(0.0, 1 - (t - t1) / 0.6) + f = hz("A4") * (0.25 + 0.75 * spin) + ph += 2 * math.pi * f / SR + g = spin * (1 if t < t1 else 0.8) + whine.append(g * (math.sin(ph) * 0.6 + 0.25 * math.sin(2 * ph) + 0.12 * math.sin(3 * ph))) + mix.put(t0, svf(whine, 2400, 0.7, "lp"), 0.06) + mix.put(t0, airy(t1 - t0 + 0.4, 300, 900, 0.5), 0.02) + + # ---------------------------------------------------------------- sparks: coughs while it catches, thuds in slow motion + rnd = random.Random(SEED) + catch_end = b["disengage"] + 0.6 + for t, c, lit in ev["sparks"]: + if t >= dur - 0.05: + continue + fire = at(t, 3) + if t < catch_end: + if lit: + mix.put(t, bang(0.5 + 0.5 * rnd.random(), seed=int(t * 1000)), 0.11 * (0.6 + 0.4 * rnd.random()), rnd.uniform(-0.25, 0.25)) + continue + g = 1 - smooth(8, 18, fire) + if g > 0.01: + mix.put(t, thud(54 + 6 * (c % 3), 0.07, 0.32), 0.16 * g, 0.12 if c % 2 else -0.12) + + # ---------------------------------------------------------------- the growl: the firings merged, pulse = their rate on screen + growl = zeros(n) + ph = 0.0 + phc = 0.0 + for k in range(n): + t = k / SR + fire = at(t, 3) + level = smooth(10, 30, fire) + if fire <= 0.5: + continue + ph += 2 * math.pi * fire / SR + phc += 2 * math.pi * fire / 9 / SR + # a pulse train at the firing rate, the first and half orders under it (rows and rods not quite alike) + p = math.sin(ph) + 0.5 * math.sin(2 * ph) + 0.25 * math.sin(3 * ph) + p *= 0.75 + 0.25 * math.sin(phc) + 0.12 * math.sin(phc / 2) + sub = math.sin(phc) * 0.6 + math.sin(phc / 2) * 0.35 + growl[k] = level * (math.tanh(1.4 * p) * 0.7 + sub) + growl = svf(growl, 900, 0.7, "lp") + mix.put(0, growl, 0.11) + # the exhaust's grit over it: band noise opening with the rpm + grit = bandnoise(n, lambda k: 300 + 1.1 * at(k / SR, 0) * at(k / SR, 1), q=0.6) + for k in range(n): + t = k / SR + grit[k] *= smooth(10, 30, at(t, 3)) * (0.3 + 0.7 * min(1.0, at(t, 0) / 2700)) + mix.put(0, grit, 0.05) + + # ---------------------------------------------------------------- the propeller's wash: air, rising with its speed, blade-pass beat + wash = bandnoise(n, lambda k: 700 + 500 * min(1.0, at(k / SR, 0) / 2700), q=0.5) + php = 0.0 + for k in range(n): + t = k / SR + N = at(t, 0) * at(t, 1) + bpf = 3 * N * 0.45 / 60 + php += 2 * math.pi * bpf / SR + lvl = (min(1.0, N / 2700)) ** 2 + wash[k] *= lvl * (0.8 + 0.2 * math.sin(php)) + mix.put(0, wash, 0.09) + + # ---------------------------------------------------------------- the cursor's clicks + for t in b["views"]: + mix.put(t, click(2200, 0.03), 0.05) diff --git a/machines/radial/r2800-shared/stage.js b/machines/radial/r2800-shared/stage.js new file mode 100644 index 0000000..f3d77c2 --- /dev/null +++ b/machines/radial/r2800-shared/stage.js @@ -0,0 +1,218 @@ +// Radial's stage: a 1940s airfield under a white overcast. A sky dome pale at the zenith (#e4ebf0) down to a warm, +// light horizon; a light concrete apron in 5 m slabs fading into the haze; a hard outdoor sun from the upper left +// with a crisp shadow on the concrete; a pale sky fill. frame(fx, cam) poses the engine and draws one frame. +import * as THREE from 'three'; +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 {Pass} from 'three/addons/postprocessing/Pass.js'; +import {RenderPass} from 'three/addons/postprocessing/RenderPass.js'; +import {buildEngine, FLOOR_Y} from './model.js'; + +const V3 = THREE.Vector3; +export const SKY = {zenith: '#e4ebf0', mid: '#edf0f0', horizon: '#f3ece1', ground: '#e6dfd3'}; + +function skyDome() { + const mat = new THREE.ShaderMaterial({ + side: THREE.BackSide, + depthWrite: false, + fog: false, + uniforms: { + zen: {value: new THREE.Color(SKY.zenith)}, + mid: {value: new THREE.Color(SKY.mid)}, + hor: {value: new THREE.Color(SKY.horizon)}, + gnd: {value: new THREE.Color(SKY.ground)}, + }, + vertexShader: 'varying vec3 vD; void main(){ vD = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_Position.z = gl_Position.w; }', + fragmentShader: + 'uniform vec3 zen; uniform vec3 mid; uniform vec3 hor; uniform vec3 gnd; varying vec3 vD;' + + 'void main(){ float y = vD.y; vec3 c;' + + ' if (y > 0.0) { float a = clamp(y / 0.18, 0.0, 1.0); float b = clamp((y - 0.18) / 0.6, 0.0, 1.0); c = mix(mix(hor, mid, a), zen, b); }' + + ' else { c = mix(hor, gnd, clamp(-y / 0.08, 0.0, 1.0)); }' + + ' gl_FragColor = vec4(c, 1.0); }', + }); + const m = new THREE.Mesh(new THREE.SphereGeometry(80, 32, 16), mat); + m.frustumCulled = false; + m.renderOrder = -10; + return m; +} +function concreteTexture() { + const c = document.createElement('canvas'); + c.width = c.height = 512; + const g = c.getContext('2d'); + g.fillStyle = '#d9d3c8'; + g.fillRect(0, 0, 512, 512); + // a fixed speckle (a seeded generator: the texture is the same every load) + let s = 7; + const r = () => ((s = (s * 16807) % 2147483647) / 2147483647); + for (let i = 0; i < 9000; i++) { + const v = 190 + Math.floor(r() * 40); + g.fillStyle = `rgba(${v},${v - 4},${v - 12},${0.25 + r() * 0.3})`; + g.fillRect(r() * 512, r() * 512, 1 + r() * 2, 1 + r() * 2); + } + for (let i = 0; i < 14; i++) { + g.fillStyle = `rgba(150,140,125,${0.04 + r() * 0.05})`; + g.beginPath(); + g.ellipse(r() * 512, r() * 512, 20 + r() * 70, 10 + r() * 40, r() * 3, 0, Math.PI * 2); + g.fill(); + } + // the joints of a slab + g.strokeStyle = 'rgba(140,132,120,0.3)'; + g.lineWidth = 2.5; + g.strokeRect(1.5, 1.5, 509, 509); + const t = new THREE.CanvasTexture(c); + t.wrapS = t.wrapT = THREE.RepeatWrapping; + t.repeat.set(16, 16); + t.anisotropy = 8; + t.colorSpace = THREE.SRGBColorSpace; + return t; +} + +export function createStage({canvas}) { + const renderer = new THREE.WebGLRenderer({canvas, antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance'}); + renderer.toneMapping = THREE.NeutralToneMapping; + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFShadowMap; + renderer.localClippingEnabled = true; + const scene = new THREE.Scene(); + scene.background = new THREE.Color(SKY.horizon); + scene.fog = new THREE.Fog(SKY.horizon, 14, 46); + const sky = skyDome(); + scene.add(sky); + const camera = new THREE.PerspectiveCamera(26, 16 / 9, 0.02, 120); + 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.65; + gtao.updateGtaoMaterial({radius: 0.07, distanceExponent: 1.4, thickness: 0.6, scale: 1.0, samples: 16, distanceFallOff: 1.0, screenSpaceRadius: false}); + gtao.updatePdMaterial({lumaPhi: 10, depthPhi: 2, normalPhi: 3, radius: 6, rings: 2, samples: 16}); + class Toggle extends Pass { + constructor(fn) { + super(); + this.needsSwap = false; + this.fn = fn; + } + render() { + this.fn(); + } + } + const aoSkip = (on) => { + sky.visible = on; + floor.visible = on; + }; + composer.addPass(new Toggle(() => aoSkip(false))); + composer.addPass(gtao); + composer.addPass(new Toggle(() => aoSkip(true))); + composer.addPass(new OutputPass()); + + // an overcast sky for the metal to reflect: bright above, the warm apron below, a brighter patch toward the sun + { + const env = new THREE.Scene(); + env.background = new THREE.Color('#d6dbde'); + const box = (w, h, pos, color, k) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({color: new THREE.Color(color).multiplyScalar(k), side: THREE.DoubleSide})); + m.position.copy(pos); + m.lookAt(0, 0, 0); + env.add(m); + }; + box(30, 30, new V3(0, 16, 0), '#eef2f5', 2.2); + box(10, 8, new V3(-12, 9, 6), '#ffffff', 3.0); + box(30, 4, new V3(0, 1, -16), '#f2ece2', 1.3); + box(30, 4, new V3(0, 1, 16), '#f2ece2', 1.3); + const g = new THREE.Mesh(new THREE.CircleGeometry(40, 32), new THREE.MeshBasicMaterial({color: '#bdb6aa', side: THREE.DoubleSide})); + g.rotation.x = -Math.PI / 2; + g.position.y = -3; + env.add(g); + const pm = new THREE.PMREMGenerator(renderer); + scene.environment = pm.fromScene(env, 0.03).texture; + scene.environmentIntensity = 0.75; + pm.dispose(); + } + const sun = new THREE.DirectionalLight('#fffaf0', 3.0); + sun.position.set(-4.2, 7.5, 3.4); + sun.target.position.set(0, -0.6, -0.2); + sun.castShadow = true; + Object.assign(sun.shadow.camera, {left: -3.2, right: 3.2, top: 3.2, bottom: -3.2, near: 2, far: 20}); + sun.shadow.mapSize.set(4096, 4096); + sun.shadow.bias = -0.0003; + sun.shadow.normalBias = 0.012; + scene.add(sun, sun.target); + scene.add(new THREE.HemisphereLight('#eaf0f4', '#cbbfae', 0.9)); + + const floor = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), new THREE.MeshStandardMaterial({color: '#ffffff', map: concreteTexture(), roughness: 0.92, metalness: 0})); + floor.rotation.x = -Math.PI / 2; + floor.position.y = FLOOR_Y; + floor.receiveShadow = true; + floor.material.map.repeat.set(24, 24); + scene.add(floor); + + const eng = buildEngine(); + scene.add(eng.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; + // cam: target (tx, ty, tz), azimuth from +z toward +x, elevation, distance (radians, metres), sx/sy screen shift + const frame = (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.fov = cam.fov ?? 26; + camera.near = Math.max(0.01, d * 0.03); + camera.updateProjectionMatrix(); + camera.setViewOffset(size.w, size.h, -(cam.sx ?? 0), -(cam.sy ?? 0), size.w, size.h); + camera.updateMatrixWorld(); + sky.position.copy(camera.position); + gtao.enabled = aoWanted && eng.cut.mode !== 'section' && !fx.glass; + eng.update(fx); + composer.render(); + }; + const pv = new V3(); + const project = (p) => { + if (!p) return null; + 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}) => { + if (samples !== undefined) { + for (const t of [composer.renderTarget1, composer.renderTarget2]) { + t.samples = samples; + t.dispose(); + } + } + if (ao !== undefined) aoWanted = ao; + if (shadow !== undefined) { + sun.shadow.mapSize.set(shadow, shadow); + sun.shadow.map?.dispose(); + sun.shadow.map = null; + } + }; + // the section plane: n·x + c ≥ 0 is kept + const planes = { + rowcut: new THREE.Plane(new V3(0, 0, -1), 0.105), + profile: new THREE.Plane(new V3(-1, 0, 0), 0), + inside: new THREE.Plane(new V3(-1, 0, 0), 0), + }; + let cutKey = ''; + const setCut = (mode, planeKey = 'profile') => { + const key = mode + ':' + planeKey; + if (key === cutKey) return; + cutKey = key; + eng.setCut(mode, planes[planeKey]); + }; + setCut('solid'); + return {renderer, scene, camera, eng, composer, gtao, sun, resize, frame, project, size, setQuality, setCut, planes}; +} diff --git a/machines/rope/FILM.md b/machines/rope/FILM.md new file mode 100644 index 0000000..af69b9d --- /dev/null +++ b/machines/rope/FILM.md @@ -0,0 +1,23 @@ +# The film + +The tour (40 s) is recorded frame by frame from `machines/rope/agc-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/rope +node agc-shared/make-timeline.mjs # agc-film/timeline.json (beats) and sfx.json (keys, alarms, calls) +node "$SKILL_DIR/sims/build.mjs" ./agc-film +echo '{"lang":"en"}' > teardown-out/en.json # or fr / es +node "$SKILL_DIR/sims/capture.mjs" agc-film --timeline agc-film/timeline.json --config teardown-out/en.json --size 1600x900 --dpr 1.2 --stills beats --cols 4 --out teardown-out/beats.png +node "$SKILL_DIR/sims/capture.mjs" agc-film --config teardown-out/en.json --size 1600x900 --dpr 1.2 --out teardown-out/silent.mp4 +python3 "$SKILL_DIR/run_sfx.py" agc-shared/score.py # teardown-out/rope-score.wav, mastered to −14 LUFS +ffmpeg -y -i teardown-out/silent.mp4 -i teardown-out/rope-score.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart teardown-out/rope-en.mp4 +``` + +The share card is the film at 6 s (the trays open) without the console: `node "$SKILL_DIR/sims/capture.mjs" agc-film --config /tmp/clean.json --size 1600x900 --dpr 1.2 --still 6 --out teardown-out/og-src.png` with `{"lang":"en","clean":true}`, then `ffmpeg -y -i teardown-out/og-src.png -vf "crop=iw:iw*630/1200:0:(ih-iw*630/1200)/2,scale=1200:630" -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 console. diff --git a/machines/rope/REFERENCES.md b/machines/rope/REFERENCES.md new file mode 100644 index 0000000..f3f1443 --- /dev/null +++ b/machines/rope/REFERENCES.md @@ -0,0 +1,98 @@ +# Rope: references + +Rope is a model of the Apollo Guidance Computer Block II and its DSKY, as flown in the lunar module of Apollo 11 +(Luminary 099), built from public sources: the MIT Instrumentation Lab's documents and code as preserved by the +Virtual AGC project, NASA's mission records, and the restoration work of Ken Shirriff and Marc Verdiell. It is not +affiliated with NASA, MIT or Draper. Where a figure is not public or could not be found, the model uses a plausible +value and marks it `[E]` below and in [`agc-shared/machine.js`](agc-shared/machine.js), where every number lives. + +## Sources + +- **[W]** [Apollo Guidance Computer](https://en.wikipedia.org/wiki/Apollo_Guidance_Computer), Wikipedia, and the + documents it cites: 16-bit word (15 data bits + odd parity), ones' complement, 36,864 words of core rope and 2,048 + of erasable core, 2.048 MHz clock, 11.72 µs memory cycle, ~2,800 flat-packs of dual 3-input NOR gates, 32 kg + (70 lb), 55 W, 61 × 32 × 17 cm (24 × 12.5 × 6.5 in), green electroluminescent seven-segment digits. +- **[S]** Ken Shirriff, [Software woven into wire: core rope and the Apollo Guidance Computer](https://www.righto.com/2019/07/software-woven-into-wire-core-rope-and.html) + (2019): 6 rope modules of 6,144 words, 512 cores each (two layers of 256), 12 words per core on 192 sense lines + (12 strands of 16), inhibit lines that select one core, set and reset pulses, a wire through the core reads 1 and + a wire around it reads 0; the photographs of the opened AGC (trays A and B), of tray B's modules B7-B17, of the + inside of a rope module, and the simplified rope diagram the geometry was built against. +- **[L]** Luminary 099 (Apollo 11 LM), as transcribed by the [Virtual AGC project](https://www.ibiblio.org/apollo/) + ([listings](https://www.ibiblio.org/apollo/listings/Luminary099/), [chrislgarry/Apollo-11](https://github.com/chrislgarry/Apollo-11)): + - `EXECUTIVE.agc`: NOVAC2, NOVAC3, NEXTCORE, and `TC BAILOUT1` / `OCT 1202`; the assembled words and their + addresses shown in the rope (`01,2625` to `01,2703`) are copied from the listing. + - `ERASABLE_ASSIGNMENTS.agc`: "EIGHT SETS OF 12 REGISTERS EACH" (the core sets), five VAC areas. + - `PINBALL_NOUN_TABLES.agc`: Noun 63 (absolute velocity, altitude rate, computed altitude), Noun 64 (time left + for redesignation and LPD angle, altitude rate, altitude), Noun 68 (slant range to the landing site, time to go in + the braking phase, LR altitude minus computed altitude), Noun 60. + - `LUNAR_LANDING_GUIDANCE_EQUATIONS.agc`, `THE_LUNAR_LANDING.agc`: V06N63 in P63, V06N64 in P64, DELTAH for V16N68. + - `ALARM_AND_ABORT.agc`: BAILOUT1's `INDEX Q` / `CAF 0`, which fetches the alarm code from the rope. +- **[E1]** Don Eyles, [Tales from the Lunar Module Guidance Computer](https://www.doneyles.com/LM/Tales.html) + (AAS, 2004): the duty-cycle margins (over 15 % in braking, about 13 % once the landing radar's data come in, 10 % + or less with Verb 16 Noun 68 up, below 10 % in P64), the radar's TLOSS (2 angles × up to 6,400 pulses per second, + 13 % assumed, 13.36 % measured after the flight), SERVICER every 2 s, 8 core sets and 5 VAC areas, the restart + that keeps only the newest SERVICER and drops the uncompleted ones and the Noun 68 monitor, high gate at + 102:41:32 and 7,400 ft, P66 at 102:43:08. +- **[J]** [Apollo 11 Lunar Surface Journal](https://apollojournals.org/alsj/a11/a11.landing.html), the landing: + the crew's altitude and speed call-outs from 5,000 ft to contact, "Delta-H is minus 2,900", the ALT light out at + 102:38:11, the altitude and velocity lights near the surface, "Contact light" at 102:45:40. +- **[R]** Apollo 11 Mission Report and NASA timeline: PDI at 102:33:05, alarms at 102:38:22 (1202), 102:39:02 + (1202), 102:42:18 (1201), 102:42:43 (1202), 102:42:58 (1202). +- **[G]** [Apollo descent guidance](https://www.nasa.gov/wp-content/uploads/static/history/alsj/ApolloDescentGuidnce.pdf) (report on the ALSJ): + the braking phase ends about 7,800 m in slant range from the landing site. +- **[D]** The LM DSKY: the photographs at the Computer History Museum and NASM (Wikimedia Commons, below), the + Luminary lamp list; 8 × 8 × 7 in. +- **[H]** The AGC's instruction timing (Virtual AGC, "Assembly language manual"): 12 timing pulses per MCT, AD in + 2 MCT, TC and TCF in 1, DXCH in 3. +- **[P]** Pinball (the DSKY program in Luminary): a Verb 16 monitor redisplays once a second. +- **[Q]** [Quindar tones](https://www.nasa.gov/wp-content/uploads/static/history/alsj/quindar.html), ALSJ: + 2,525 Hz to key the ground transmitter, 2,475 Hz to release it, 250 ms each. +- **[A]** Apollo Operations Handbook, Block II spacecraft, vol. 1, section 2.10, caution and warning + ([ibiblio](https://www.ibiblio.org/apollo/ApolloProjectOnline/Documents/SMA2A-03-BLOCK%20II%20Volume%201%2019691015/aoh-v1-2-10-c+w.pdf)): + the master alarm tone is a square wave alternately 750 and 2,000 Hz. That is the command module's handbook; the + score assumes the LM's tone was the same. + +## Estimates and simplifications **[E]** + +| What | In the model | Why | +|---|---|---| +| Job mix of the Executive | SERVICER every 2 s (needs a VAC area), a Verb 16 monitor job every second at lower priority, an LPD display job every 2 s in P64 from 12 s after high gate, one keyboard job per key | the real Luminary job list is longer; this is the smallest mix that shows the mechanism Eyles describes | +| Priorities | relative only (keyboard > SERVICER > monitor, LPD) | the numbers are not shown on screen; the priority word in the erasable view uses PRIO20 / PRIO10 and an estimated low-9-bit pointer (`0154`) | +| Radar data load | +1.5 % (ramping over 24 s once the landing radar locks at 102:38:11) | Eyles gives "perhaps 13 %" margin; 1.5 % instead of 2 % keeps the braking phase from drifting into overload between the second alarm and P64, as it did not in flight | +| P64 duty | 83.5 % rising to 90 % at P66 | Eyles: margin below 10 % in P64; the ramp places the model's P64 alarms near the flown ones | +| Crew keystrokes | V16 N68 entered at PDI + 305 s, V05 N09 after the first alarm, V16 N68 again at PDI + 350 s, RSETs | the keystroke times are not in the transcript; they are set so the model's first two 1202s land at 317.4 s and 356.4 s after PDI (flown: 317 and 357) | +| Descent profile before high gate | a smooth braking profile through 50,000 ft at PDI, 33,500 ft at the first alarm, 7,400 ft at high gate; horizontal speed 5,560 ft/s at PDI | nobody called numbers in the braking phase; the call-outs after high gate are the crew's [J] | +| Delta-H | −2,900 ft when the radar locks [J], then decaying with a 38 s time constant | the shape of its incorporation is not published | +| Rope strand mapping | word w of a module sits on core w mod 512, strand ⌊w / 512⌋ | the real mapping (address bits to core and strand) is more involved; this one puts consecutive addresses on neighbouring cores, so the 9 cores shown hold the real words `01,2623` to `01,2633` | +| Phases inside the memory cycle | inhibit, set, reset, strobe drawn over the 12 pulses; the 16 bits drawn one after the other | the sense amplifiers read all 16 at once; the drawing staggers them so the eye can follow | +| Erasable plane | 34 × 11 cores of one bit plane (bit 14), coincident-current X / Y lines, one sense line | the AGC's erasable module packs 32,768 cores; the plane is a window onto one of them | +| Inside tray B | the rope bay at one end (3 across, 2 deep), B7-B17 standing across the middle, the X-ribbed plate at the other end | after the photographs of the opened trays; the exact positions are approximate | +| Model's P64 alarms | three 1201s at 577, 586 and 598 s | flown: a 1201 at 553 s and two 1202s at 578 and 593 s; the model's VAC areas run out before its core sets | +| The instruction-per-second figure | 85,333 MCT/s; additions take 2 MCT, so 42,667 additions/s | "about 85,000 instructions per second" holds only for one-MCT instructions (TC, TCF) | + +## The model, by hand + +- 2.048 MHz ÷ 2 = 1.024 MHz; 12 pulses = 11.72 µs per memory cycle; 85,333 cycles a second. +- 6 modules × 512 cores × 12 words = 36,864 words of rope = 36 banks of 1,024; × 16 bits = 589,824 bits. +- 2,048 erasable words × 16 bits = 32,768 cores. +- The radar's two angles at 6,400 pulses per second each steal 12,800 MCT a second: 15.0 % at most; 13 % in the model. +- At the first alarm: 85 % braking + 1.5 % radar data + 3 % Noun 68 + 13 % stolen = 102.5 % of the machine. +- CAF NO.CORES at `01,2627` is `32634` octal = `011 010 110 011 100`, eight ones; the parity bit is 1. + +`node machines/rope/check.mjs` recomputes each of these from the code, runs the Executive's log and compares its +alarms with Apollo 11's. + +## Study material + +The photographs and documents the geometry was built against belong to their authors and are not in this +repository (they sit in `machines/rope/ref/`, which git ignores): + +- Ken Shirriff's photographs in [S]: the AGC closed, the two trays opened, tray B, a rope module opened, the rope + diagram. +- [Apollo DSKY at the Computer History Museum](https://commons.wikimedia.org/wiki/File:Apollo_DSKY_CHM.agr.jpg) + (Arnold Reinhold, CC BY-SA), [Apollo Guidance Computer at NASM](https://commons.wikimedia.org/wiki/File:Apollo_Guidance_Computer_(NASM).JPG), + [AGC logic module](https://commons.wikimedia.org/wiki/File:AGClogicmodule.agr.jpg), + [core rope memory](https://commons.wikimedia.org/wiki/File:Apollo_guidiance_computer_ferrit_core_memory.jpg). +- The Luminary 099 source files listed in [L]. + +The console's lettering is Barlow Condensed (SIL Open Font License, [@fontsource/barlow-condensed](https://fontsource.org/fonts/barlow-condensed)). diff --git a/machines/rope/agc-film/main.js b/machines/rope/agc-film/main.js new file mode 100644 index 0000000..3655774 --- /dev/null +++ b/machines/rope/agc-film/main.js @@ -0,0 +1,4 @@ +// Rope, the film. Shared code in ../agc-shared. +import {run} from '../agc-shared/film.js'; + +run(); diff --git a/machines/rope/agc-film/sfx.json b/machines/rope/agc-film/sfx.json new file mode 100644 index 0000000..3c32ca1 --- /dev/null +++ b/machines/rope/agc-film/sfx.json @@ -0,0 +1 @@ +{"keys":[29.224,29.513,29.802,30.162,30.451,30.74,31.173,32.111],"alarms":[{"t":27.6,"code":1202},{"t":33.503,"code":1202},{"t":35.132,"code":1201},{"t":35.189,"code":1201},{"t":35.266,"code":1201}],"calls":[[28.5,29.9],[31.2,32.2],[37.6,38.8]],"quindar":{"intro":2525,"outro":2475,"ms":250}} \ No newline at end of file diff --git a/machines/rope/agc-film/timeline.json b/machines/rope/agc-film/timeline.json new file mode 100644 index 0000000..63ff34f --- /dev/null +++ b/machines/rope/agc-film/timeline.json @@ -0,0 +1 @@ +{"duration":40,"beats":{"views":[3,6.8,14.6,20.2,24,32.4],"ropeRead":10.2,"erasRead":16,"alarm":27.6,"ff":32.4,"contact":37.4}} \ No newline at end of file diff --git a/machines/rope/agc-live/main.js b/machines/rope/agc-live/main.js new file mode 100644 index 0000000..6ec7377 --- /dev/null +++ b/machines/rope/agc-live/main.js @@ -0,0 +1,5 @@ +import {runLive} from '../agc-shared/live.js'; + +// Rope as an interactive page: seven subsystems, sections, views, the DSKY's keys, the radar's cycle steals, the +// mission speed, orbit and zoom, and an auto tour when left alone. +runLive(); diff --git a/machines/rope/agc-shared/agc.js b/machines/rope/agc-shared/agc.js new file mode 100644 index 0000000..d90ff31 --- /dev/null +++ b/machines/rope/agc-shared/agc.js @@ -0,0 +1,873 @@ +// Rope's geometry, built from code, in metres: the AGC Block II (two magnesium trays bolted face to face, 61 x 32 x +// 17 cm [W]) and the LM DSKY (20 x 20 x 18 cm [D]), after the photographs in REFERENCES.md. Tray A (bottom) holds the +// 24 logic and 7 interface modules standing on edge [S]; tray B holds the memory electronics B7-B17, the erasable +// module B12 (black), the X-ribbed plate, and the bay of the 6 rope modules B1-B6 that slide in from the end [S]. +// The layout inside tray B is simplified [E]. update(fx) poses everything from the frame's state. +import * as THREE from 'three'; +import {AGC, LAMPS, ROPE_WORDS, bits16, ropePlace, wordAt} from './machine.js'; +import {drawDigits} from './seg7.js'; + +const V3 = THREE.Vector3; +export const ACCENT = '#35c26a'; // the DSKY's electroluminescent green +export const ACCENT_INK = '#1f8a4c'; // the same on paper +export const AMBER = '#f0a828'; +const COL = { + mag: '#a39976', // anodised magnesium, dull gold + magDark: '#7f7a62', + magGreen: '#8b8f78', // grey-green + strip: '#d2cbb7', // potted modules, cream grey + stripEdge: '#b9b19b', + black: '#24221f', + ferrite: '#1c1a18', + copper: '#b5713f', + gold: '#c09a4a', + ink: '#2b2925', + dsky: '#8f9194', + key: '#2a2b2d', + pcb: '#5f6b55', +}; +export const L = { + x0: -0.17, // the AGC's centre + len: 0.61, + wid: 0.32, + trayH: 0.085, + dsky: new V3(0.5, 0, 0.04), + dskyYaw: 0.3, + dskyS: 0.203, + dskyD: 0.178, + openZ: -0.37, // tray B beside tray A when apart + liftY: 0.24, +}; +const M0 = new THREE.Matrix4(); +const smooth = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * (3 - 2 * u); +}; +const mix = (a, b, k) => a + (b - a) * k; + +function std(color, o = {}) { + return new THREE.MeshStandardMaterial({color, roughness: 0.62, metalness: 0.15, envMapIntensity: 0.5, ...o}); +} +function box(w, h, d, mat, x = 0, y = 0, z = 0) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); + m.castShadow = true; + m.receiveShadow = true; + return m; +} +const font = (w, px) => `${w} ${px}px "Barlow Condensed", "Arial Narrow", sans-serif`; +function labelTex(text, {w = 512, h = 64, px = 40, color = '#2b2925', bg = null, weight = 600, align = 'center', spacing = 3} = {}) { + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const g = c.getContext('2d'); + if (bg) { + g.fillStyle = bg; + g.fillRect(0, 0, w, h); + } + g.fillStyle = color; + g.font = font(weight, px); + g.textBaseline = 'middle'; + g.textAlign = align; + if ('letterSpacing' in g) g.letterSpacing = `${spacing}px`; + g.fillText(text, align === 'center' ? w / 2 : 12, h / 2 + 2); + const t = new THREE.CanvasTexture(c); + t.colorSpace = THREE.SRGBColorSpace; + t.anisotropy = 8; + return t; +} + +// ------------------------------------------------------------ a magnesium tray: plate, walls, pockets, flange, bolts +function tray({len, wid, h, mat, dark, holes}) { + const g = new THREE.Group(); + const t = 0.006; + const shell = []; + const add = (m) => { + g.add(m); + shell.push(m); + return m; + }; + add(box(len, t, wid, mat, 0, t / 2, 0)); + add(box(len, h, t, mat, 0, h / 2, wid / 2 - t / 2)); + add(box(len, h, t, mat, 0, h / 2, -wid / 2 + t / 2)); + add(box(t, h, wid - 2 * t, mat, len / 2 - t / 2, h / 2, 0)); + add(box(t, h, wid - 2 * t, mat, -len / 2 + t / 2, h / 2, 0)); + // the flange round the open side + const fl = 0.012; + add(box(len, 0.004, fl, mat, 0, h - 0.002, wid / 2 - fl / 2)); + add(box(len, 0.004, fl, mat, 0, h - 0.002, -wid / 2 + fl / 2)); + add(box(fl, 0.004, wid, mat, len / 2 - fl / 2, h - 0.002, 0)); + add(box(fl, 0.004, wid, mat, -len / 2 + fl / 2, h - 0.002, 0)); + // pockets milled into the long walls (the photos' rectangular recesses), and a rib between them + const pk = std(dark); + pk.polygonOffset = true; + pk.polygonOffsetFactor = -2; + const n = 4; + for (let i = 0; i < n; i++) { + const pw = len / n - 0.018; + const px = -len / 2 + (i + 0.5) * (len / n); + for (const s of [1, -1]) { + const p = new THREE.Mesh(new THREE.PlaneGeometry(pw, h * 0.62), pk); + p.position.set(px, h * 0.45, s * (wid / 2 + 0.0002)); + if (s < 0) p.rotation.y = Math.PI; + p.receiveShadow = true; + g.add(p); + shell.push(p); + } + } + // bolt holes on the flange + if (holes) { + const hg = new THREE.CylinderGeometry(0.0022, 0.0022, 0.0006, 10); + const hm = new THREE.MeshBasicMaterial({color: '#3b362c'}); + const pts = []; + for (let i = 0; i <= 22; i++) for (const s of [1, -1]) pts.push([-len / 2 + 0.012 + (i * (len - 0.024)) / 22, s * (wid / 2 - 0.006)]); + for (let i = 1; i < 10; i++) for (const s of [1, -1]) pts.push([s * (len / 2 - 0.006), -wid / 2 + (i * wid) / 10]); + const im = new THREE.InstancedMesh(hg, hm, pts.length); + const M = new THREE.Matrix4(); + pts.forEach(([x, z], i) => im.setMatrixAt(i, M.makeTranslation(x, h + 0.0003, z))); + g.add(im); + shell.push(im); + } + return {g, shell}; +} + +// ------------------------------------------------------------ a rope module: frame, 512 cores, copper weave, cover +export const ROPE = {len: 0.16, wid: 0.092, th: 0.028, cols: 32, rows: 8, layers: 2, px: 0.0036, pz: 0.0098, rCore: 0.00315, rHole: 0.0019, thick: 0.0024}; +const coreX = (c) => (c - (ROPE.cols - 1) / 2) * ROPE.px; +const coreZ = (r) => (r - (ROPE.rows - 1) / 2) * ROPE.pz; +const coreY = (l) => 0.0095 + l * 0.0098; +function ropeModule(i, mats) { + const g = new THREE.Group(); + const frame = std(COL.black, {roughness: 0.7}); + g.add(box(ROPE.len, 0.003, ROPE.wid, frame, 0, 0.0015, 0)); + for (const s of [1, -1]) g.add(box(ROPE.len, ROPE.th, 0.004, frame, 0, ROPE.th / 2, s * (ROPE.wid / 2 - 0.002))); + g.add(box(0.006, ROPE.th, ROPE.wid, frame, -ROPE.len / 2 + 0.003, ROPE.th / 2, 0)); + // the connector end, gold pins + const conn = box(0.01, ROPE.th * 0.8, ROPE.wid * 0.86, std(COL.black), ROPE.len / 2 - 0.005, ROPE.th * 0.42, 0); + g.add(conn); + const pinG = new THREE.CylinderGeometry(0.0005, 0.0005, 0.006, 6); + pinG.rotateZ(Math.PI / 2); + const pins = new THREE.InstancedMesh(pinG, mats.gold, 48); + const M = new THREE.Matrix4(); + for (let k = 0; k < 48; k++) pins.setMatrixAt(k, M.makeTranslation(ROPE.len / 2 + 0.002, 0.008 + (k % 2) * 0.008, -ROPE.wid * 0.4 + Math.floor(k / 2) * (ROPE.wid * 0.8) / 23)); + g.add(pins); + // cordwood rows of resistors and diodes along both long edges (the photo's white combs) + const cw = new THREE.CylinderGeometry(0.0007, 0.0007, 0.009, 6); + const cwm = new THREE.InstancedMesh(cw, std('#e9e3d2', {roughness: 0.8}), 2 * 70); + for (let k = 0; k < 140; k++) { + const s = k < 70 ? 1 : -1; + const x = -0.068 + ((k % 70) * 0.136) / 69; + cwm.setMatrixAt(k, M.makeTranslation(x, 0.012, s * 0.036)); + } + g.add(cwm); + // the cores: 2 layers of 8 rows of 32, axis along the module (the wires run along it) + const cg = new THREE.TorusGeometry((ROPE.rCore + ROPE.rHole) / 2, (ROPE.rCore - ROPE.rHole) / 2, 6, 14); + cg.rotateY(Math.PI / 2); + cg.scale(ROPE.thick / (ROPE.rCore - ROPE.rHole), 1, 1); + const cores = new THREE.InstancedMesh(cg, mats.ferrite, ROPE.cols * ROPE.rows * ROPE.layers); + let k = 0; + for (let l = 0; l < ROPE.layers; l++) for (let r = 0; r < ROPE.rows; r++) for (let c = 0; c < ROPE.cols; c++) cores.setMatrixAt(k++, M.makeTranslation(coreX(c), coreY(l), coreZ(r))); + cores.castShadow = true; + g.add(cores); + // the 192 lines of each row: a bundle through the cores and a looser one round them + const wl = ROPE.cols * ROPE.px + 0.01; + const thr = new THREE.CylinderGeometry(0.0014, 0.0014, wl, 8, 1, true); + thr.rotateZ(Math.PI / 2); + const aro = new THREE.CylinderGeometry(0.0039, 0.0039, wl, 14, 1, true); + aro.rotateZ(Math.PI / 2); + const bundles = new THREE.InstancedMesh(thr, mats.copper, ROPE.rows * ROPE.layers); + const sheath = new THREE.InstancedMesh(aro, mats.copperSheath, ROPE.rows * ROPE.layers); + k = 0; + for (let l = 0; l < ROPE.layers; l++) + for (let r = 0; r < ROPE.rows; r++) { + bundles.setMatrixAt(k, M.makeTranslation(0, coreY(l), coreZ(r))); + sheath.setMatrixAt(k++, M.makeTranslation(0, coreY(l), coreZ(r))); + } + g.add(bundles, sheath); + // the cover and its label + const cover = new THREE.Group(); + const cm = std(COL.magGreen, {roughness: 0.55}); + cover.add(box(ROPE.len - 0.004, 0.0022, ROPE.wid - 0.002, cm, 0, 0, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(0.11, 0.014), new THREE.MeshStandardMaterial({map: labelTex(`FIXED MEMORY MODULE B${i + 1}`, {px: 34, color: '#3b3a33'}), transparent: true, roughness: 0.8})); + lab.rotation.x = -Math.PI / 2; + lab.position.set(-0.01, 0.00115, 0); + cover.add(lab); + cover.position.y = ROPE.th + 0.0011; + g.add(cover); + return {g, cover, cores, conn, bundles, sheath}; +} + +// ------------------------------------------------------------ the detailed row inside module B1: 9 cores, 16 sense lines +const DET_N = 9; +function ropeDetail(mats) { + const g = new THREE.Group(); + const sel = ROPE_WORDS.find((w) => w.op === 'CAF NO.CORES'); + const P = ropePlace(sel.bank, sel.addr); + const row = Math.floor(P.core / ROPE.cols); + const layer = Math.floor(row / ROPE.rows); + const r = row % ROPE.rows; + const c0 = (P.core % ROPE.cols) - (DET_N - 1) / 2; + const y = coreY(layer); + const z = coreZ(r); + // the words held on this strand by the 9 cores: consecutive addresses [L] + const words = []; + for (let k = 0; k < DET_N; k++) { + const addr = sel.addr + (k - (DET_N - 1) / 2); + const w = wordAt(1, addr); + words.push({addr, word: w, bits: w === null ? null : bits16(w)}); + } + const cg = new THREE.TorusGeometry((ROPE.rCore + ROPE.rHole) / 2, (ROPE.rCore - ROPE.rHole) / 2, 18, 40); + cg.rotateY(Math.PI / 2); + cg.scale(ROPE.thick / (ROPE.rCore - ROPE.rHole), 1, 1); + const cores = []; + for (let k = 0; k < DET_N; k++) { + const m = new THREE.Mesh(cg, mats.coreSel.clone()); + m.position.set(coreX(c0 + k), y, z); + m.castShadow = true; + g.add(m); + cores.push(m); + } + // a sense line: through the hole of a core (a 1) or over it (a 0); 16 lines, the bits of this strand + const xs = (k) => coreX(c0 + k); + const throughAt = (j) => [(-1.5 + (j % 4)) * 0.00042, (-1.5 + Math.floor(j / 4)) * 0.00042]; + const aroundAt = (j) => { + const a = Math.PI * (0.12 + (0.76 * j) / 15); + const R = 0.0046 + (j % 2) * 0.0005; + return [Math.cos(a) * R, Math.sin(a) * R]; + }; + const wires = []; + const tubeR = 0.00012; + for (let j = 0; j < 16; j++) { + const pts = []; + const x0 = xs(0) - ROPE.px * 0.9; + const x1 = xs(DET_N - 1) + ROPE.px * 0.9; + const P0 = (k) => (words[k].bits && words[k].bits[j] ? throughAt(j) : aroundAt(j)); + const first = P0(0); + pts.push(new V3(x0 - 0.004, y + first[1] * 0.5 + 0.0006, z + first[0])); + for (let k = 0; k < DET_N; k++) { + const [dz, dy] = P0(k); + pts.push(new V3(xs(k) - ROPE.thick * 0.75, y + dy, z + dz)); + pts.push(new V3(xs(k) + ROPE.thick * 0.75, y + dy, z + dz)); + } + const last = P0(DET_N - 1); + pts.push(new V3(x1 + 0.004, y + last[1] * 0.5 + 0.0006, z + last[0])); + const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal'); + const m = new THREE.Mesh(new THREE.TubeGeometry(curve, 220, tubeR, 6), mats.wire.clone()); + g.add(m); + wires.push(m); + } + // the other 176 lines (11 other strands): faint bundles through and round + const wl = (DET_N + 1.8) * ROPE.px; + const others = new THREE.Mesh(new THREE.CylinderGeometry(0.00125, 0.00125, wl, 16, 1, true).rotateZ(Math.PI / 2), mats.copperDim); + others.position.set((xs(0) + xs(DET_N - 1)) / 2, y - 0.0003, z); + g.add(others); + // inhibit lines (the address decode) below, and the set / reset line + const inhib = []; + for (let q = 0; q < 4; q++) { + const pts = []; + for (let k = 0; k < DET_N; k++) { + const thru = ((k + q) % 3 !== 0) !== (k === (DET_N - 1) / 2) || (k === (DET_N - 1) / 2 && false); + const on = k === (DET_N - 1) / 2 ? true : thru; + const dy = on ? -0.0011 - q * 0.00022 : -0.0047 - q * 0.0004; + pts.push(new V3(xs(k) - ROPE.thick * 0.7, y + dy, z + (q - 1.5) * 0.0005)); + pts.push(new V3(xs(k) + ROPE.thick * 0.7, y + dy, z + (q - 1.5) * 0.0005)); + } + const m = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts, false, 'centripetal'), 160, 0.00013, 5), mats.inhibit.clone()); + g.add(m); + inhib.push(m); + } + const setPts = [new V3(xs(0) - 0.005, y + 0.0012, z - 0.0011), new V3(xs(DET_N - 1) + 0.005, y + 0.0012, z - 0.0011)]; + const setLine = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(setPts), 4, 0.00017, 6), mats.inhibit.clone()); + g.add(setLine); + return {g, cores, wires, inhib, setLine, words, sel, place: P, layer, row: r, c0, anchor: new V3(xs((DET_N - 1) / 2), y, z)}; +} + +// ------------------------------------------------------------ the erasable plane inside B12 +export const ERAS = {cols: 34, rows: 11, px: 0.0029, py: 0.0029, sel: [17, 5]}; +function erasPlane(mats) { + const g = new THREE.Group(); + const cg = new THREE.TorusGeometry(0.00072, 0.00026, 8, 18); + const cores = new THREE.InstancedMesh(cg, mats.ferrite, ERAS.cols * ERAS.rows); + const M = new THREE.Matrix4(); + const Q = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI / 2, Math.PI / 4)); + const S = new V3(1, 1, 1); + const P = new V3(); + const pos = (c, r) => P.set((c - (ERAS.cols - 1) / 2) * ERAS.px, (r - (ERAS.rows - 1) / 2) * ERAS.py, 0); + let k = 0; + for (let r = 0; r < ERAS.rows; r++) for (let c = 0; c < ERAS.cols; c++) cores.setMatrixAt(k++, M.compose(pos(c, r), Q, S)); + g.add(cores); + // X lines (rows), Y lines (columns), the sense line zig-zag, the inhibit line + const W = ERAS.cols * ERAS.px; + const H = ERAS.rows * ERAS.py; + const xl = []; + const yl = []; + for (let r = 0; r < ERAS.rows; r++) { + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.00005, 0.00005, W + 0.004, 5).rotateZ(Math.PI / 2), mats.fine.clone()); + m.position.y = (r - (ERAS.rows - 1) / 2) * ERAS.py; + g.add(m); + xl.push(m); + } + for (let c = 0; c < ERAS.cols; c++) { + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.00005, 0.00005, H + 0.004, 5), mats.fine.clone()); + m.position.x = (c - (ERAS.cols - 1) / 2) * ERAS.px; + m.position.z = 0.0001; + g.add(m); + yl.push(m); + } + const zz = []; + for (let r = 0; r <= ERAS.rows; r++) zz.push(new V3((r % 2 ? 1 : -1) * (W / 2 + 0.0015), (r - ERAS.rows / 2) * ERAS.py, -0.00012)); + const sense = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(zz, false, 'catmullrom', 0.1), 260, 0.00006, 5), mats.fine.clone()); + g.add(sense); + // the selected core, on its own, with its magnetisation mark + const [sc, sr] = ERAS.sel; + const selCore = new THREE.Mesh(new THREE.TorusGeometry(0.00072, 0.00028, 14, 30), mats.erasSel); + selCore.position.copy(pos(sc, sr)); + selCore.quaternion.copy(Q); + g.add(selCore); + const mark = new THREE.Mesh(new THREE.SphereGeometry(0.0003, 10, 8), mats.mark); + g.add(mark); + const backing = new THREE.Mesh(new THREE.PlaneGeometry(W + 0.01, H + 0.01), std('#b9ae92', {roughness: 0.9})); + backing.position.z = -0.0014; + backing.receiveShadow = true; + g.add(backing); + return {g, xl, yl, sense, selCore, mark, centre: pos(sc, sr).clone()}; +} + +// ------------------------------------------------------------ the DSKY: face plate, lamps, EL panel, 19 keys +export const KEY_POS = (() => { + const out = {}; + const col = (i) => (i - 3) * 0.0262; + const row = (j) => -0.024 - j * 0.0262; + out.VERB = [col(0), row(0.5)]; + out.NOUN = [col(0), row(1.5)]; + ['+', '7', '8', '9', 'CLR'].forEach((k, i) => (out[k] = [col(i + 1), row(0)])); + ['-', '4', '5', '6', 'PRO'].forEach((k, i) => (out[k] = [col(i + 1), row(1)])); + ['0', '1', '2', '3', 'KEY REL'].forEach((k, i) => (out[k] = [col(i + 1), row(2)])); + out.ENTR = [col(6), row(0.5)]; + out.RSET = [col(6), row(1.5)]; + return out; +})(); +const keyLabel = (k) => (k === '-' ? '−' : k); +function dskyUnit(mats) { + const g = new THREE.Group(); + const S = L.dskyS; + const D = L.dskyD; + const body = std(COL.dsky, {roughness: 0.55, metalness: 0.25}); + const shell = box(S, S, D, body, 0, S / 2, 0); + g.add(shell); + // back connector block and side rails (the photo's mounting flanges) + g.add(box(S * 0.86, S * 0.9, 0.03, std('#2f3133'), 0, S / 2, -D / 2 - 0.015)); + for (const s of [1, -1]) g.add(box(0.006, S * 0.96, D * 0.9, std('#9a9b9c', {metalness: 0.4}), s * (S / 2 + 0.003), S / 2, -0.004)); + // the face: plate, lamp window, EL window, keyboard well + const plate = new THREE.CanvasTexture(faceCanvas()); + plate.colorSpace = THREE.SRGBColorSpace; + plate.anisotropy = 8; + const face = new THREE.Mesh(new THREE.PlaneGeometry(S, S), new THREE.MeshStandardMaterial({map: plate, roughness: 0.6, metalness: 0.1})); + face.position.set(0, S / 2, D / 2 + 0.0004); + face.receiveShadow = true; + g.add(face); + // the lamp panel and the EL panel: canvases redrawn when their state changes + const lampC = document.createElement('canvas'); + lampC.width = 420; + lampC.height = 470; + const lampT = new THREE.CanvasTexture(lampC); + lampT.colorSpace = THREE.SRGBColorSpace; + lampT.anisotropy = 8; + const lw = 0.08; + const lh = 0.0895; + const lamp = new THREE.Mesh(new THREE.PlaneGeometry(lw, lh), new THREE.MeshBasicMaterial({map: lampT, toneMapped: false})); + lamp.position.set(-0.048, S / 2 + 0.05, D / 2 + 0.0035); + g.add(lamp); + g.add(box(lw + 0.006, lh + 0.006, 0.006, std('#55575a'), -0.048, S / 2 + 0.05, D / 2 + 0.0002)); + const elC = document.createElement('canvas'); + elC.width = 400; + elC.height = 470; + const elT = new THREE.CanvasTexture(elC); + elT.colorSpace = THREE.SRGBColorSpace; + elT.anisotropy = 8; + const el = new THREE.Mesh(new THREE.PlaneGeometry(lw, lh), new THREE.MeshBasicMaterial({map: elT, toneMapped: false})); + el.position.set(0.048, S / 2 + 0.05, D / 2 + 0.0035); + g.add(el); + g.add(box(lw + 0.006, lh + 0.006, 0.006, std('#55575a'), 0.048, S / 2 + 0.05, D / 2 + 0.0002)); + // keys + const keys = {}; + const kg = new THREE.BoxGeometry(0.0218, 0.0218, 0.007); + for (const [k, [x, y]] of Object.entries(KEY_POS)) { + const t = labelTex(keyLabel(k), {w: 128, h: 128, px: k.length > 3 ? 34 : k.length > 1 ? 40 : 62, color: '#e9e6dc', bg: COL.key, weight: 600, spacing: 1}); + if (k === 'KEY REL') { + const c = t.image.getContext('2d'); + c.fillStyle = COL.key; + c.fillRect(0, 0, 128, 128); + c.fillStyle = '#e9e6dc'; + c.font = font(600, 36); + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.fillText('KEY', 64, 46); + c.fillText('REL', 64, 84); + t.needsUpdate = true; + } + const side = std(COL.key, {roughness: 0.5}); + const front = new THREE.MeshStandardMaterial({map: t, roughness: 0.5}); + const m = new THREE.Mesh(kg, [side, side, side, side, front, side]); + m.position.set(x, S / 2 + y, D / 2 + 0.0035); + m.castShadow = true; + m.userData.key = k; + g.add(m); + keys[k] = m; + } + return {g, keys, lampC, lampT, elC, elT, face, el, lamp, lastLamp: '', lastEl: ''}; +} +function faceCanvas() { + const c = document.createElement('canvas'); + c.width = 1024; + c.height = 1024; + const g = c.getContext('2d'); + g.fillStyle = '#8f9194'; + g.fillRect(0, 0, 1024, 1024); + // keyboard well + g.fillStyle = '#7d7f82'; + g.fillRect(24, 548, 976, 452); + g.strokeStyle = 'rgba(0,0,0,.25)'; + g.lineWidth = 3; + g.strokeRect(24, 548, 976, 452); + // screws + g.fillStyle = '#5d5f62'; + for (const [x, y] of [[40, 40], [984, 40], [40, 520], [984, 520], [512, 520], [40, 984], [984, 984]]) { + g.beginPath(); + g.arc(x, y, 9, 0, 7); + g.fill(); + } + // nameplate + g.fillStyle = '#6f7174'; + g.fillRect(392, 14, 240, 22); + g.fillStyle = '#d9d7cf'; + g.font = font(600, 17); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('DISPLAY & KEYBOARD', 512, 26); + return c; +} +// lamp panel: 2 columns of 7 [D]; lit lamps: white (status) or amber (caution) +const CAUTION = new Set(['TEMP', 'GIMBAL LOCK', 'PROG', 'RESTART', 'TRACKER', 'ALT', 'VEL']); +function drawLamps(d, lamps) { + drawLampPanel(d.lampC.getContext('2d'), d.lampC.width, d.lampC.height, lamps); + d.lampT.needsUpdate = true; +} +export function drawLampPanel(g, W, H, lamps) { + g.fillStyle = '#4b4d50'; + g.fillRect(0, 0, W, H); + const cw = (W - 30) / 2; + const ch = (H - 40) / 7; + LAMPS.forEach((rowL, r) => + rowL.forEach((name, c) => { + const x = 10 + c * (cw + 10); + const y = 10 + r * (ch + 3.3); + const on = name && lamps[name]; + g.fillStyle = on ? (CAUTION.has(name) ? AMBER : '#f4f0e0') : '#a6a59c'; + g.fillRect(x, y, cw, ch); + if (!name) return; + g.fillStyle = on ? '#2b2210' : '#6e6d66'; + g.font = font(700, name.length > 9 ? 25 : 29); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + if ('letterSpacing' in g) g.letterSpacing = '1px'; + const parts = name.length > 9 ? name.split(' ') : [name]; + parts.forEach((p, i) => g.fillText(p, x + cw / 2, y + ch / 2 + (i - (parts.length - 1) / 2) * 24 + 2)); + }), + ); +} +// the EL panel: COMP ACTY, PROG, VERB, NOUN, three signed 5-digit registers, green on black +export function drawEL(g, W, H, s, green = ACCENT) { + const off = 'rgba(53,194,106,0.06)'; + g.fillStyle = '#0d0f0e'; + g.fillRect(0, 0, W, H); + const lab = (t, x, y, w, on = true) => { + g.fillStyle = on ? green : '#0d0f0e'; + g.fillRect(x, y, w, 26); + g.fillStyle = on ? '#0d0f0e' : green; + g.font = font(700, 22); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + if ('letterSpacing' in g) g.letterSpacing = '1px'; + g.fillText(t, x + w / 2, y + 14); + }; + // COMP ACTY + if (s.compActy) { + g.fillStyle = green; + g.fillRect(22, 18, 140, 92); + g.fillStyle = '#0d0f0e'; + } else g.fillStyle = 'rgba(53,194,106,0.12)'; + g.font = font(700, 30); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('COMP', 92, 50); + g.fillText('ACTY', 92, 82); + lab('PROG', 252, 16, 120); + lab('VERB', 22, 128, 120); + lab('NOUN', 252, 128, 120); + const dw = 46; + const dh = 70; + drawDigits(g, s.prog, 262, 50, dw, dh, 10, green, off); + drawDigits(g, s.verb, 32, 162, dw, dh, 10, green, off); + drawDigits(g, s.noun, 262, 162, dw, dh, 10, green, off); + for (let r = 0; r < 3; r++) { + const y = 252 + r * 72; + g.fillStyle = 'rgba(53,194,106,0.55)'; + g.fillRect(22, y - 6, W - 44, 3); + drawDigits(g, s.reg[r], 30, y + 2, 46, 60, 9.5, green, off); + } +} +const DIG_STATE = (s) => `${s.compActy}|${s.prog}|${s.verb}|${s.noun}|${s.reg.join('|')}`; + +// ------------------------------------------------------------ a logic module's face: 60 flat-packs, and the enlarged gate +function flatpacks(n, w, h, mats) { + const fg = new THREE.BoxGeometry(0.0064, 0.0036, 0.0012); + const im = new THREE.InstancedMesh(fg, mats.flatpack, n); + const lg = new THREE.BoxGeometry(0.0088, 0.0002, 0.0004); + const leads = new THREE.InstancedMesh(lg, mats.gold, n * 5); + const M = new THREE.Matrix4(); + const per = n / 2; + for (let k = 0; k < n; k++) { + const x = -w / 2 + 0.006 + ((k % per) * (w - 0.012)) / (per - 1); + const y = (Math.floor(k / per) ? -1 : 1) * h * 0.2; + im.setMatrixAt(k, M.makeTranslation(x, y, 0)); + for (let q = 0; q < 5; q++) leads.setMatrixAt(k * 5 + q, M.makeTranslation(x, y - 0.0013 + q * 0.00065, 0.0004)); + } + const g = new THREE.Group(); + g.add(im, leads); + return g; +} + +// ------------------------------------------------------------ the machine +export function buildMachine() { + const root = new THREE.Group(); + const mats = { + mag: std(COL.mag, {roughness: 0.58, metalness: 0.35}), + magB: std(COL.magGreen, {roughness: 0.6, metalness: 0.3}), + strip: std(COL.strip, {roughness: 0.75}), + gold: std(COL.gold, {roughness: 0.42, metalness: 0.8}), + ferrite: std(COL.ferrite, {roughness: 0.5, metalness: 0.2}), + ferriteHi: std('#26231f', {roughness: 0.42, metalness: 0.25}), + coreSel: std('#26231f', {roughness: 0.42, metalness: 0.25, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + copper: std(COL.copper, {roughness: 0.45, metalness: 0.7}), + copperSheath: std('#9c5f37', {roughness: 0.6, metalness: 0.5, transparent: true, opacity: 0.55, depthWrite: false}), + copperDim: std('#8e5a36', {roughness: 0.6, metalness: 0.5, transparent: true, opacity: 0.5, depthWrite: false}), + wire: std('#c07a44', {roughness: 0.4, metalness: 0.7, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + inhibit: std('#6f6b63', {roughness: 0.5, metalness: 0.4, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + mark: new THREE.MeshBasicMaterial({color: '#f4efdf'}), + fine: std('#7d5233', {roughness: 0.5, metalness: 0.6, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + erasSel: std('#26231f', {roughness: 0.42, metalness: 0.25, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0}), + flatpack: std('#1f1f22', {roughness: 0.4, metalness: 0.3}), + black: std(COL.black, {roughness: 0.65}), + }; + const clipPlane = new THREE.Plane(new V3(0, 0, -1), 0.02); + const shells = []; + const A = {}; + + // ------------------------------------------------ tray A: logic + const agc = new THREE.Group(); + agc.position.x = L.x0; + root.add(agc); + const ta = tray({len: L.len, wid: L.wid, h: L.trayH, mat: mats.mag, dark: COL.magDark, holes: true}); + agc.add(ta.g); + shells.push(...ta.shell); + // end connector (the gold field on the photo's front end) and its cover plate + const connA = new THREE.Group(); + connA.position.set(L.len / 2 + 0.004, 0.042, 0); + const field = new THREE.Mesh(new THREE.PlaneGeometry(0.2, 0.026), std('#8a7440', {roughness: 0.5, metalness: 0.7, emissive: new THREE.Color(ACCENT), emissiveIntensity: 0})); + field.rotation.y = Math.PI / 2; + connA.add(field); + connA.add(box(0.005, 0.034, 0.23, mats.magB, -0.002, 0, 0)); + agc.add(connA); + const strips = []; + const SW = 0.0136; + const stripLen = 0.255; + const groups = [ + {x: -0.148, n: 14, from: 0}, + {x: 0.142, n: 17, from: 14}, + ]; + const label = (name) => { + const t = labelTex(name, {w: 512, h: 40, px: 30, color: '#57534a', spacing: 4}); + return new THREE.MeshStandardMaterial({map: t, transparent: true, roughness: 0.8}); + }; + for (const gr of groups) + for (let i = 0; i < gr.n; i++) { + const k = gr.from + i; + const name = k < 24 ? `LOGIC MODULE A${k + 1}` : `INTERFACE MODULE A${k + 1}`; + const s = new THREE.Group(); + s.add(box(stripLen, 0.06, SW - 0.0012, mats.strip, 0, 0.03, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(stripLen * 0.62, 0.0046), label(name)); + lab.rotation.x = -Math.PI / 2; + lab.position.set(0, 0.0602, 0); + s.add(lab); + s.position.set(gr.x, 0.006, -L.wid / 2 + 0.016 + (i + 0.5) * ((L.wid - 0.032) / gr.n)); + agc.add(s); + strips.push(s); + } + // module A13 opened: its flat-packs on the face that comes up, and one enlarged gate + const logicK = 12; + const logicMod = strips[logicK]; + const fp = flatpacks(60, stripLen - 0.02, 0.05, mats); + fp.position.set(0, 0.03, SW / 2 + 0.0002); + logicMod.add(fp); + const bigGate = new THREE.Group(); + { + const b = box(0.064, 0.038, 0.012, mats.flatpack, 0, 0, 0); + bigGate.add(b); + for (let q = 0; q < 5; q++) + for (const s of [1, -1]) { + const lead = box(0.03, 0.0016, 0.004, mats.gold, s * 0.045, -0.014 + q * 0.007, 0); + bigGate.add(lead); + } + const t = labelTex('2 × NOR · 3 INPUTS', {w: 512, h: 96, px: 44, color: '#d9d3c0', spacing: 3}); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(0.052, 0.0098), new THREE.MeshBasicMaterial({map: t, transparent: true})); + lab.position.z = 0.0062; + bigGate.add(lab); + } + bigGate.visible = false; + agc.add(bigGate); + + // ------------------------------------------------ tray B: memory, the rope bay + const tb = new THREE.Group(); + agc.add(tb); + const tbt = tray({len: L.len, wid: L.wid, h: L.trayH, mat: mats.magB, dark: '#6f735f', holes: true}); + tb.add(tbt.g); + shells.push(...tbt.shell); + // X-ribbed plate, inside, over its part of the tray + const xr = new THREE.Group(); + { + const w = 0.14; + const d = L.wid - 0.02; + xr.add(box(w, 0.004, d, mats.magB, 0, 0.002, 0)); + const ang = Math.atan2(d, w); + const len = Math.hypot(w, d); + for (const s of [1, -1]) { + const r = box(len, 0.008, 0.006, mats.magB, 0, 0.006, 0); + r.rotation.y = s * ang; + xr.add(r); + } + xr.position.set(0.225, 0.055, 0); + } + tb.add(xr); + // memory electronics B7-B17, on edge, across the tray + const bNames = ['OSCILLATOR B7', 'ALARM B8', 'ERASABLE DRIVER B9-10', 'ERASABLE DRIVER B9-10', 'CURRENT SWITCH B11', 'ERASABLE MEMORY B12', 'SENSE AMPLIFIER B13-14', 'SENSE AMPLIFIER B13-14', 'STRAND SELECT B15', 'ROPE DRIVER B16-17', 'ROPE DRIVER B16-17']; + const bStrips = []; + bNames.forEach((name, i) => { + const s = new THREE.Group(); + const isE = name.startsWith('ERASABLE MEMORY'); + const lenB = 0.26; + s.add(box(lenB, 0.05, 0.0225, isE ? mats.black : mats.strip, 0, 0.025, 0)); + const lab = new THREE.Mesh(new THREE.PlaneGeometry(lenB * 0.66, 0.0062), isE ? new THREE.MeshStandardMaterial({map: labelTex(name.replace('B12', 'MODULE B12'), {w: 512, h: 40, px: 30, color: '#e8e2cf', spacing: 4}), transparent: true}) : label(name.replace(/ B/, ' MODULE B'))); + lab.rotation.x = -Math.PI / 2; + lab.position.set(0, 0.0502, 0); + s.add(lab); + s.position.set(0.0, 0.034, -L.wid / 2 + 0.018 + (i + 0.5) * ((L.wid - 0.036) / 11)); + tb.add(s); + bStrips.push(s); + }); + const eras = bStrips[5]; + const plane = erasPlane(mats); + plane.g.position.set(0, 0.025, 0.0114); + plane.g.visible = false; + eras.add(plane.g); + // the rope bay, at the -x end: 3 across, 2 deep + const ropes = []; + const bayX = -L.len / 2 + 0.006 + ROPE.len / 2 + 0.004; + for (let i = 0; i < 6; i++) { + const m = ropeModule(i, mats); + const col = i % 3; + const lay = Math.floor(i / 3); + m.home = new V3(bayX, 0.052 - lay * 0.032, (col - 1) * (ROPE.wid + 0.006)); + m.g.position.copy(m.home); + tb.add(m.g); + ropes.push(m); + } + // module B1 (top layer, middle) is the one that comes out + const b1 = ropes[1]; + const det = ropeDetail(mats); + det.g.visible = false; + b1.g.add(det.g); + // windows on tray B's outer face over the rope bay (it faces up when the box is closed) + { + const wm = std('#4a4a40', {roughness: 0.8}); + for (let c = 0; c < 3; c++) { + const w = new THREE.Mesh(new THREE.PlaneGeometry(ROPE.len - 0.03, ROPE.wid - 0.024), wm); + w.rotation.x = Math.PI / 2; + w.position.set(bayX, -0.0003, (c - 1) * (ROPE.wid + 0.006)); + tb.add(w); + shells.push(w); + } + const xo = new THREE.Group(); + const w = 0.36; + const d = L.wid - 0.03; + const ang = Math.atan2(d, w); + for (const s of [1, -1]) { + const r = box(Math.hypot(w, d), 0.005, 0.009, mats.magB, 0, -0.0025, 0); + r.rotation.y = s * ang; + xo.add(r); + } + xo.position.set(0.1, 0, 0); + tb.add(xo); + shells.push(...xo.children); + } + + // ------------------------------------------------ the DSKY + const dsky = dskyUnit(mats); + dsky.g.position.copy(L.dsky); + dsky.g.rotation.y = L.dskyYaw; + root.add(dsky.g); + // a cable from the DSKY to the AGC's end connector + { + const pts = [new V3(L.dsky.x - 0.06, 0.05, L.dsky.z - 0.11), new V3(L.dsky.x - 0.1, 0.012, L.dsky.z - 0.2), new V3(0.18, 0.01, -0.1), new V3(L.x0 + L.len / 2 + 0.03, 0.03, -0.02)]; + const cab = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 60, 0.0075, 10), std('#3a3833', {roughness: 0.75})); + cab.castShadow = true; + root.add(cab); + } + + for (const m of shells) if (m.material) m.material = m.material.clone(); + const shellMats = [...new Set(shells.map((m) => m.material))]; + + // anchors for callouts (world space, filled by update) + const anchors = {}; + const tmp = new V3(); + const world = (obj, local) => obj.localToWorld(tmp.copy(local)).clone(); + let cutWas = ''; + + /** + * fx: {open (0 closed, 1 apart), rope (0 in, 1 out), ropeCover (0 on, 1 off), ropeDetail (0..1), read {phase, on}, + * eras (0 in, 1 up), erasRead {phase, bit, on}, logic (0 in, 1 up), gate (0..1), rr (0..1 connector glow), + * cut ('' | 'case'), dsky {lamps, el: {compActy, prog, verb, noun, reg}, press: {key: 0..1}}} + */ + function update(fx) { + // tray B: lift, flip about the long axis, slide beside A, lower + const o = fx.open; + const k1 = smooth(o / 0.28); + const k2 = smooth((o - 0.22) / 0.56); + const k3 = smooth((o - 0.72) / 0.28); + tb.rotation.x = Math.PI * (1 - k2); + const yClosed = 2 * L.trayH; + // local y 0 is the tray's outer face: at 2 x 85 mm when closed (flipped), on the floor when apart + tb.position.set(0, mix(yClosed, 0, k3) + L.liftY * (k1 - k3), L.openZ * k2); + // rope module B1: out along -x, then up and clear of the tray + const r1 = smooth(fx.rope / 0.6); + const r2 = smooth((fx.rope - 0.55) / 0.45); + b1.g.position.set(b1.home.x - 0.25 * r1, b1.home.y + 0.075 * r2, b1.home.z); + b1.cover.position.y = ROPE.th + 0.0011 + 0.05 * smooth(fx.ropeCover); + b1.cover.visible = fx.ropeCover < 0.98; + b1.cover.children.forEach((c) => c.material && ((c.material.transparent = true), (c.material.opacity = 1 - smooth((fx.ropeCover - 0.4) / 0.6)))); + const inside = fx.ropeDetail > 0.01; + if (det.g.visible !== inside) { + det.g.visible = inside; + b1.bundles.visible = !inside; + b1.sheath.visible = !inside; + // the 9 detailed cores replace their instances + for (let k = 0; k < DET_N; k++) { + const idx = (det.layer * ROPE.rows + det.row) * ROPE.cols + det.c0 + k; + b1.cores.setMatrixAt(idx, inside ? M0.makeScale(0, 0, 0) : M0.makeTranslation(coreX(det.c0 + k), coreY(det.layer), coreZ(det.row))); + } + b1.cores.instanceMatrix.needsUpdate = true; + } + // the read inside one MCT: inhibit lines, then the set, then the reset whose flux the sense lines catch + const rd = fx.read || {phase: -1}; + const ph = rd.phase; + const inh = ph >= 0 && ph < 0.45 ? smooth(ph / 0.06) * (1 - smooth((ph - 0.38) / 0.07)) : 0; + const setK = ph >= 0.12 && ph < 0.45 ? smooth((ph - 0.12) / 0.05) * (1 - smooth((ph - 0.36) / 0.06)) : 0; + const resK = ph >= 0.45 && ph < 0.95 ? smooth((ph - 0.45) / 0.05) * (1 - smooth((ph - 0.85) / 0.1)) : 0; + det.inhib.forEach((m) => (m.material.emissiveIntensity = inh * 0.75)); + det.setLine.material.emissiveIntensity = Math.max(setK, resK) * 0.75; + const ck = rd.core ?? (DET_N - 1) / 2; + const sel = det.words[ck].bits || []; + det.wires.forEach((m, j) => { + const lit = sel[j] ? resK : 0; + m.material.emissiveIntensity = lit * 0.65; + m.material.color.set(lit > 0.5 ? '#7fcf9c' : '#c07a44'); + }); + det.cores.forEach((m, k) => (m.material.emissiveIntensity = k === ck ? Math.max(setK, resK) * 0.55 : 0)); + // B12 rises; its cover window shows the plane + const e1 = smooth(fx.eras); + eras.position.y = 0.034 + 0.085 * e1; + plane.g.visible = fx.eras > 0.3; + const er = fx.erasRead || {phase: -1, bit: 1}; + const ep = er.phase; + // read: half currents on one X and one Y line drive the core to 0; the sense line sees a 1. Rewrite: back to 1 + const rdK = ep >= 0 && ep < 0.5 ? smooth(ep / 0.05) * (1 - smooth((ep - 0.4) / 0.08)) : 0; + const wrK = ep >= 0.55 && ep < 1 ? smooth((ep - 0.55) / 0.05) * (1 - smooth((ep - 0.9) / 0.08)) : 0; + const [sc, sr] = ERAS.sel; + plane.xl.forEach((m, i) => (m.material.emissiveIntensity = i === sr ? Math.max(rdK, wrK) * 0.9 : 0)); + plane.yl.forEach((m, i) => (m.material.emissiveIntensity = i === sc ? Math.max(rdK, wrK) * 0.9 : 0)); + plane.sense.material.emissiveIntensity = er.bit && ep >= 0.18 && ep < 0.45 ? 0.9 * (1 - smooth((ep - 0.35) / 0.1)) : 0; + // the magnetisation: 1 = mark at the top of the ring, 0 = bottom; it turns while the core switches + let ang = er.bit ? Math.PI / 2 : -Math.PI / 2; + if (ep >= 0) { + if (er.bit) { + const a = smooth((ep - 0.12) / 0.14); + const b = smooth((ep - 0.62) / 0.14); + ang = Math.PI / 2 - Math.PI * a + Math.PI * b; + } + } + const rr = 0.00072; + tmp.set(Math.cos(ang) * rr, Math.sin(ang) * rr, 0).applyQuaternion(plane.selCore.quaternion); + plane.mark.position.copy(plane.centre).add(tmp); + mats.erasSel.emissiveIntensity = Math.max(rdK, wrK) * 0.6 * (ep >= 0 ? 1 : 0); + // logic module A13 up, one gate enlarged in front of it + const l1 = smooth(fx.logic); + logicMod.position.y = 0.006 + 0.09 * l1; + bigGate.visible = fx.gate > 0.01; + { + const gk = smooth(fx.gate); + bigGate.position.set(logicMod.position.x + 0.03, 0.2 + 0.02 * gk, logicMod.position.z + 0.07); + bigGate.scale.setScalar(0.2 + 0.8 * gk); + } + // the radar's pulses into the interface connector + field.material.emissiveIntensity = (fx.rr || 0) * 0.6; + // section of the closed case + const cut = fx.cut === 'case' && o < 0.02 ? 'case' : ''; + if (cut !== cutWas) { + cutWas = cut; + for (const m of shellMats) { + m.clippingPlanes = cut ? [clipPlane] : []; + m.needsUpdate = true; + } + } + clipPlane.constant = 0.02; + // the DSKY + const d = fx.dsky; + if (d) { + const ls = JSON.stringify(d.lamps); + if (ls !== dsky.lastLamp) { + dsky.lastLamp = ls; + drawLamps(dsky, d.lamps); + } + const es = DIG_STATE(d.el); + if (es !== dsky.lastEl) { + dsky.lastEl = es; + drawEL(dsky.elC.getContext('2d'), dsky.elC.width, dsky.elC.height, d.el); + dsky.elT.needsUpdate = true; + } + for (const [k, m] of Object.entries(dsky.keys)) m.position.z = L.dskyD / 2 + 0.0035 - 0.0024 * (d.press?.[k] || 0); + } + // anchors + root.updateMatrixWorld(true); + anchors.trayA = world(agc, new V3(-0.05, 0.07, L.wid / 2)); + anchors.trayB = world(tb, new V3(0.05, 0.06, 0)); + anchors.logic = world(logicMod, new V3(0.05, 0.06, 0)); + anchors.gate = world(bigGate, new V3(0.02, 0.01, 0.006)); + anchors.rope = world(b1.g, new V3(0, ROPE.th, 0)); + anchors.ropeEnd = world(b1.g, new V3(ROPE.len / 2, ROPE.th * 0.5, 0)); + anchors.core = world(b1.g, det.anchor.clone()); + anchors.coreNext = world(b1.g, det.anchor.clone().add(new V3(ROPE.px * 3, 0.0045, 0))); + anchors.inhib = world(b1.g, det.anchor.clone().add(new V3(-ROPE.px * 3, -0.0045, 0))); + anchors.eras = world(eras, new V3(0.08, 0.05, 0)); + anchors.erasCore = world(eras, plane.centre.clone().add(new V3(0, 0.025, 0.0114))); + anchors.erasSense = world(eras, new V3(ERAS.cols * ERAS.px * 0.5 + 0.0015, 0.025, 0.0114)); + anchors.dsky = world(dsky.g, new V3(0, L.dskyS * 0.75, L.dskyD / 2)); + anchors.dskyEl = world(dsky.g, new V3(0.048, L.dskyS / 2 + 0.05, L.dskyD / 2)); + anchors.dskyKeys = world(dsky.g, new V3(0, L.dskyS / 2 - 0.05, L.dskyD / 2)); + anchors.dskyProg = world(dsky.g, new V3(-0.028, L.dskyS / 2 + 0.077, L.dskyD / 2)); + anchors.conn = world(connA, new V3(0, 0, 0)); + anchors.xrib = world(tb, new V3(0.225, 0.06, 0)); + anchors.erasMod = world(eras, new V3(-0.06, 0.05, 0)); + } + update({open: 0, rope: 0, ropeCover: 0, ropeDetail: 0, eras: 0, logic: 0, gate: 0}); + return {root, update, anchors, dsky, det, plane, b1, tb, agc, eras, logicMod, bigGate, shellMats}; +} diff --git a/machines/rope/agc-shared/console.js b/machines/rope/agc-shared/console.js new file mode 100644 index 0000000..112c5ba --- /dev/null +++ b/machines/rope/agc-shared/console.js @@ -0,0 +1,435 @@ +// Rope's overlay, drawn as a sheet of the MIT Instrumentation Lab's documentation and a DSKY: the mission clock top +// left in seven-segment figures, the DSKY's face below it (the same drawing as the 3D panel), the Executive (duty, +// core sets, VAC areas, one millisecond of memory cycles, the alarm) on the right, the working subsystem's detail +// bottom left, the operator's buttons bottom centre, the title block (cartouche) bottom right, a one-line caption and +// callouts with thin leaders into the machine. Fixed 1600 x 900 CSS layout; update(s) redraws every live part. +import {ACCENT, ACCENT_INK, AMBER, drawEL, drawLampPanel} from './agc.js'; +import {AGC, EXEC, MCT, MCT_PER_S, ADDS_PER_S, ROPE_WORDS, octal} from './machine.js'; +import {svgDigits, digitsWidth} from './seg7.js'; +import {FAMILY} from './fonts.js'; +import {nf, T as tr} from './i18n.js'; + +export const C = {paper: '#efe8d8', panel: 'rgba(241,235,221,.9)', ink: '#2b2925', mute: '#7a7062', faint: 'rgba(70,58,40,.32)', line: 'rgba(70,58,40,.55)', acc: ACCENT_INK, accEl: ACCENT, amber: AMBER, amberInk: '#a86a06', steal: '#9c4a2e'}; +export const SUBS = ['rope', 'eras', 'nor', 'clock', 'exec', 'dsky', 'iface']; +export const CUTS = ['case', 'tray', 'module']; +export const VIEWS = ['axo', 'dskyFace', 'ropeIn', 'graze']; +const CSS = ` +#app{font-family:${FAMILY};color:${C.ink};-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums} +.ui{position:absolute;inset:0;pointer-events:none} +.pn{position:absolute;background:${C.panel};border:1px solid ${C.line}} +.pn:before{content:'';position:absolute;inset:3px;border:.6px solid ${C.faint};pointer-events:none} +.hd{position:absolute;font-weight:700;font-size:15px;letter-spacing:2.2px;text-transform:uppercase;white-space:nowrap} +.sm{position:absolute;font-weight:500;font-size:12.5px;letter-spacing:1.3px;text-transform:uppercase;color:${C.mute};white-space:nowrap} +.tx{position:absolute;font-weight:500;font-size:14px;line-height:18px;letter-spacing:.3px} +.tb{position:absolute;height:28px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:14px;letter-spacing:1.6px;text-transform:uppercase;border:1px solid ${C.line};background:${C.panel};color:${C.ink};white-space:nowrap;box-sizing:border-box} +.tb.on{background:${C.ink};color:${C.paper}} +.tb.on:after{content:'';position:absolute;left:6px;right:6px;bottom:3px;height:2px;background:${C.accEl}} +.gl{position:absolute;font-weight:600;font-size:12px;letter-spacing:2px;color:${C.mute};text-transform:uppercase} +.call{position:absolute;white-space:nowrap;background:rgba(241,235,221,.88);padding:2px 8px 4px;margin:-2px -8px 0} +.call b{display:block;font-weight:700;font-size:28px;line-height:30px;letter-spacing:.6px;text-transform:uppercase} +.call span{display:block;font-weight:500;font-size:15px;letter-spacing:1.8px;color:${C.mute};text-transform:uppercase;margin-top:1px} +.cur{position:absolute;left:0;top:0;z-index:50;filter:drop-shadow(0 1px 2px rgba(40,30,10,.35))} +.ring{position:absolute;width:36px;height:36px;margin:-18px 0 0 -18px;border-radius:50%;border:2px solid ${C.acc};opacity:0;z-index:49} +.cap{position:absolute;left:380px;width:840px;top:756px;text-align:center;font-weight:600;font-size:21px;line-height:27px;letter-spacing:.5px} +.cap span{background:rgba(241,235,221,.9);padding:1px 8px;box-decoration-break:clone;-webkit-box-decoration-break:clone} +.alarm{position:absolute;background:${C.amber};color:#2a1d05;border:1px solid #6b4a0c} +`; +const el = (h) => { + const d = document.createElement('div'); + d.innerHTML = h.trim(); + return d.firstElementChild; +}; +const KIND_LABEL = () => ({servicer: tr('job:servicer'), monitor: tr('job:monitor'), lpd: tr('job:lpd'), charin: tr('job:charin')}); +export const pad = (n, w) => String(n).padStart(w, '0'); +/** GET as hh:mm:ss.cc */ +export function getText(s) { + const t = Math.max(0, s); + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const sec = t % 60; + return `${pad(h, 3)}:${pad(m, 2)}:${pad(Math.floor(sec), 2)}.${pad(Math.floor((sec % 1) * 100), 2)}`; +} + +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; + }; + + // ------------------------------------------------------------ the mission clock + add(`
`); + add(`
${tr('clockTitle')}
`); + const getSvg = add(``); + add(`
${tr('mctSince')}
`); + const mctSvg = add(``); + const rateTx = add(`
`); + const phaseTx = add(`
`); + + // ------------------------------------------------------------ the DSKY, as on its face + add(`
`); + const lampCv = add(``); + const elCv = add(``); + add(`
${tr('dskyTitle')}
`); + const dskyTx = add(`
`); + + // ------------------------------------------------------------ the Executive + const EX = {x: 1262, y: 32, w: 304}; + add(`
`); + add(`
${tr('execTitle')}
`); + add(`
${tr('dutyTitle')}
`); + const dutySvg = add(``); + const dutyTot = add(``); + add(`
${tr('setsTitle')}
`); + const busyTx = add(`
`); + const setsSvg = add(``); + add(`
${tr('vacTitle')}
`); + const vacSvg = add(``); + add(`
${tr('stripTitle')}
`); + const stripSvg = add(``); + const alarmBox = add(``); + const restartTx = add(`
`); + + // ------------------------------------------------------------ the subsystem's detail + const DT = {x: 34, y: 452, w: 304, h: 326}; + add(`
`); + const detHd = add(`
`); + const detSvg = add(``); + + // ------------------------------------------------------------ buttons + const btn = {}; + const BX = 372; + add(`
${tr('grpSubs')}
`); + SUBS.forEach((k, i) => (btn[k] = add(`
${tr('sub:' + k)}
`))); + add(``); + const BY = 32; + const BXC = 372; + add(`
${tr('grpCuts')}
`); + CUTS.forEach((k, i) => (btn[k] = add(`
${tr('cut:' + k)}
`))); + const BXV = 712; + add(`
${tr('grpViews')}
`); + VIEWS.forEach((k, i) => (btn[k] = add(`
${tr('view:' + k)}
`))); + + // ------------------------------------------------------------ the title block + const CB = {x: 1262, y: 688, w: 304, h: 178}; + add(`
`); + const hl = (y) => add(`
`); + const vl = (x, y0, y1) => add(`
`); + hl(66); + hl(92); + hl(116); + vl(178, 116, CB.h); + add(`
${tr('title')}
`); + add(`
${tr('titleSub')}
`); + add(`
${tr('titleMission')}
`); + add(`
${tr('titleSources')}
`); + add(`
${tr('titleNot')}
`); + add(`
${tr('titleSheet')}
`); + add(`
${tr('titleFigs', nf(AGC.massKg), nf(AGC.powerW))}
`); + + // ------------------------------------------------------------ caption, callouts, the pointer + const cap = add(`
`); + const callSvg = add(``); + const callG = add(`
`); + const ring = add(`
`); + const cur = add(`
`); + + const centre = (n) => { + const l = parseFloat(n.style.left); + const t = parseFloat(n.style.top); + return [l + parseFloat(n.style.width) / 2, t + 14]; + }; + const spots = Object.fromEntries(Object.entries(btn).map(([k, n]) => [k, centre(n)])); + 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)})`; + }; + + // caches so the SVGs are rebuilt only when they change + const last = {}; + const put = (key, node, html) => { + if (last[key] === html) return; + last[key] = html; + node.innerHTML = html; + }; + const txt = (x, y, s, o = {}) => + `${s}`; + const hatch = ``; + + // ------------------------------------------------------------ the detail panel, per subsystem + const drawDetail = (d) => { + const W = DT.w - 28; + let h = hatch; + if (d.kind === 'rope') { + // the word: 16 cells, filled as the sense amplifiers see them + const w = ROPE_WORDS[d.w]; + h += txt(0, 14, tr('ropeAddr', w.bank, octal(w.addr, 4), d.place.module + 1, d.place.core, d.place.strand), {size: 13.5, w: 600}); + h += txt(0, 36, w.op, {size: 26, w: 700, ls: 1.5}); + if (w.label) h += txt(W, 36, w.label, {size: 14, w: 600, anchor: 'end', fill: C.mute}); + const cw = W / 16; + for (let b = 0; b < 16; b++) { + const x = b * cw; + const seen = b < d.fill; + const v = w.bits[b]; + h += ``; + if (seen) h += svgDigits(String(v), x + cw / 2 - 5, 56, 10, 22, 0, v ? C.paper : C.ink, null, 0.05); + h += txt(x + cw / 2, 98, b === 15 ? 'P' : String(15 - b), {size: 10.5, w: 500, anchor: 'middle', fill: C.mute, ls: 0}); + } + h += txt(0, 124, tr('ropeOctal'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(d.fill >= 16 ? octal(w.word) : ' ', 0, 132, 18, 32, 5, C.ink, 'rgba(70,58,40,.08)'); + h += txt(W, 150, tr('ropeParity', w.parity), {size: 13, w: 600, anchor: 'end'}); + h += txt(0, 196, tr('ropePhase'), {size: 12.5, w: 500, fill: C.mute}); + const phases = [tr('ph:inhibit'), tr('ph:set'), tr('ph:reset'), tr('ph:strobe')]; + phases.forEach((p, i) => { + const on = d.stage === i; + h += ``; + h += txt((i * W) / 4 + W / 8, 222, p, {size: 13, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 1}); + }); + h += `
${tr('ropeNote', nf(AGC.fixedWords), AGC.ropeModules, nf(AGC.coresPerModule), AGC.senseLinesPerCore)}
`; + } else if (d.kind === 'eras') { + h += txt(0, 14, tr('erasAddr', d.set), {size: 13.5, w: 600}); + h += txt(0, 36, d.job ? tr('erasJob', KIND_LABEL()[d.job]) : tr('erasFree'), {size: 22, w: 700, ls: 1}); + const cw = W / 16; + for (let b = 0; b < 16; b++) { + const x = b * cw; + const v = d.bits[b]; + const isPlane = b === 1; + const shown = d.state === 'read' ? b < d.fill : d.state === 'gone' ? false : true; + h += ``; + if (shown) h += svgDigits(String(v), x + cw / 2 - 5, 56, 10, 22, 0, v ? C.paper : C.ink, null, 0.05); + h += txt(x + cw / 2, 98, b === 15 ? 'P' : String(15 - b), {size: 10.5, w: 500, anchor: 'middle', fill: isPlane ? C.acc : C.mute, ls: 0}); + } + h += txt(0, 124, tr('erasPlane'), {size: 12.5, w: 600, fill: C.acc}); + const steps = [tr('es:read'), tr('es:sense'), tr('es:rewrite')]; + steps.forEach((p, i) => { + const on = d.stage === i; + h += ``; + h += txt((i * W) / 3 + W / 6, 154, p, {size: 13, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 1}); + }); + h += `
${tr('erasNote', nf(AGC.erasableWords), nf(AGC.erasableWords * AGC.wordBits))}
`; + } else if (d.kind === 'nor') { + // RTL NOR: three transistors, one collector resistor; inputs from three timing pulses + const [a, b, c] = d.in; + const out = a || b || c ? 0 : 1; + const on = (v) => (v ? C.acc : C.ink); + h += txt(0, 14, tr('norHead'), {size: 13.5, w: 600}); + const y0 = 40; + h += `` + txt(236, y0 + 4, 'V+', {size: 12, w: 600}); + h += ``; + h += ``; + h += `` + txt(244, y0 + 57, `Q = ${out}`, {size: 16, w: 700, fill: on(out)}); + [a, b, c].forEach((v, i) => { + const x = 60 + i * 60; + const yb = y0 + 84; + h += ``; + h += ``; + h += ``; + h += ``; + h += ``; + h += txt(x - 34, yb + 4, 'ABC'[i], {size: 14, w: 700, anchor: 'end', fill: on(v)}) + txt(x - 19, yb + 24, String(v), {size: 13, w: 700, anchor: 'middle', fill: on(v)}); + }); + h += `` + txt(206, y0 + 118, '0 V', {size: 12, w: 600}); + // truth table of NOR 3 + const ty = 176; + h += txt(0, ty, 'A B C → Q', {size: 13, w: 700, ls: 2}); + for (let k = 0; k < 8; k++) { + const ia = (k >> 2) & 1; + const ib = (k >> 1) & 1; + const ic = k & 1; + const cur = ia === a && ib === b && ic === c; + const x = (k % 4) * 69; + const y = ty + 10 + Math.floor(k / 4) * 22; + h += ``; + h += txt(x + 33, y + 14, `${ia}${ib}${ic} → ${ia || ib || ic ? 0 : 1}`, {size: 13, w: 600, anchor: 'middle', fill: cur ? C.paper : C.ink, ls: 1}); + } + h += `
${tr('norNote', nf(AGC.ics), nf(AGC.ics * AGC.gatesPerIc))}
`; + } else if (d.kind === 'clock') { + h += txt(0, 14, tr('clockHead', nf(AGC.clockHz / 1e6, 3), nf(MCT * 1e6, 2)), {size: 13.5, w: 600}); + const lane = (W - 34) / 12; + for (let p = 0; p < 12; p++) { + const x = 34 + p * lane; + const on = d.pulse === p; + h += ``; + h += txt(x + lane / 2, 44, `T${p + 1}`, {size: 11.5, w: 700, anchor: 'middle', fill: on ? C.paper : C.ink, ls: 0}); + } + h += txt(0, 44, 'MCT', {size: 12, w: 700}); + // the waveform of the 1.024 MHz chain over one MCT + let path = ''; + for (let k = 0; k <= 24; k++) path += `${k ? 'L' : 'M'}${34 + (k * (W - 34)) / 24},${k % 2 ? 62 : 76}${k < 24 ? `L${34 + ((k + 1) * (W - 34)) / 24},${k % 2 ? 62 : 76}` : ''}`; + h += `` + txt(0, 74, '1 MHZ', {size: 11, w: 600}); + const px = 34 + d.phase * (W - 34); + h += ``; + h += txt(0, 110, tr('clockPulse'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(String(d.pulse + 1).padStart(2, ' '), 0, 118, 18, 32, 5, C.acc) + txt(50, 146, '/ 12', {size: 18, w: 700}); + h += `
${tr('clockNote', nf(MCT_PER_S), nf(ADDS_PER_S), nf(MCT * 2e6, 1))}
`; + } else if (d.kind === 'iface') { + h += txt(0, 14, tr('ifHead'), {size: 13.5, w: 600}); + const rows = [ + [tr('if:rr'), d.rr ? nf(EXEC.rrAngles * EXEC.rrPulses * (EXEC.tloss / (EXEC.rrAngles * EXEC.rrPulses * MCT))) : '0', d.rr], + [tr('if:imu'), '≈ 0', false], + [tr('if:lr'), d.lr ? tr('if:lrOn') : '—', false], + ]; + rows.forEach(([name, v, hot], i) => { + const y = 32 + i * 34; + h += ``; + h += txt(8, y + 19, name, {size: 13.5, w: 600}); + h += txt(W - 8, y + 19, v, {size: 15, w: 700, anchor: 'end', fill: hot ? C.steal : C.ink}); + }); + h += txt(0, 152, tr('ifTloss'), {size: 12.5, w: 500, fill: C.mute}); + h += svgDigits(nf((d.rr ? EXEC.tloss : 0) * 100, 1).replace(',', '.'), 0, 160, 18, 32, 5, d.rr ? C.steal : C.ink); + h += txt(110, 188, '%', {size: 20, w: 700}); + h += `
${tr('ifNote', nf(EXEC.rrPulses), nf(EXEC.tlossMax * 100, 2), nf(EXEC.rrAngles * EXEC.rrPulses * MCT * 100, 1))}
`; + } else if (d.kind === 'dsky') { + h += txt(0, 14, tr('dskyHead'), {size: 13.5, w: 600}); + h += `
${d.text}
`; + } else { + // exec: the job list with priorities + h += txt(0, 14, tr('exHead'), {size: 13.5, w: 600}); + h += `
${tr('exNote', EXEC.coreSets, EXEC.vacAreas, nf(EXEC.braking * 100), nf(EXEC.lrConvert * 100, 1), nf(EXEC.n68 * 100), nf(EXEC.tloss * 100))}
`; + } + return h; + }; + + return { + spots, + btn, + root: ui, + pointer, + lampCv, + elCv, + update(s) { + // clock + put('get', getSvg, svgDigits(getText(s.get), 0, 2, 18, 34, 4.5, C.ink, 'rgba(70,58,40,.045)')); + put('mct', mctSvg, svgDigits(nf(Math.floor(s.mct)).replace(/[^0-9]/g, ' '), 0, 2, 12, 22, 3.5, C.ink, null)); + put('rate', rateTx, s.rateText); + put('phase', phaseTx, `P${s.dsky.prog}`); + // DSKY + const dk = JSON.stringify(s.dsky); + if (dk !== last.dsky) { + last.dsky = dk; + drawLampPanel(lampCv.getContext('2d'), 420, 470, s.dsky.lamps); + drawEL(elCv.getContext('2d'), 400, 470, s.dsky); + } + put('dskyTx', dskyTx, s.dskyText); + // duty: stacked bar of what the machine is asked for, against 100 % + { + const W = EX.w - 28; + const X = (v) => (v / 1.1) * W; + const parts = s.duty.parts; + let h = hatch; + let x = 0; + for (const p of parts) { + const w = X(p.v); + h += ``; + x += w; + } + h += `` + txt(X(1) + 4, 12, '100', {size: 11, w: 700}); + const tot = s.duty.total; + parts.forEach((p, i) => { + const lx = (i % 2) * (W / 2); + const ly = 42 + Math.floor(i / 2) * 15; + h += `` + txt(lx + 13, ly + 9, `${tr('duty:' + p.k)} ${nf(p.v * 100, Math.abs(p.v * 100 - Math.round(p.v * 100)) > 0.05 ? 1 : 0)} %`, {size: 12, w: 600, ls: 0.5}); + }); + put('duty', dutySvg, h); + put('dutyTot', dutyTot, svgDigits(String(Math.round(tot * 100)).padStart(3, ' '), 0, 1, 13, 22, 3.5, tot > 1 ? C.amberInk : C.ink, 'rgba(70,58,40,.05)') + txt(56, 22, '%', {size: 16, w: 700})); + } + // core sets + { + const W = EX.w - 28; + let h = ''; + const kl = KIND_LABEL(); + s.sets.forEach((j, k) => { + const y = k * 26; + const run = j && j.run; + h += `` + txt(11, y + 17, String(k), {size: 14, w: 700, anchor: 'middle', fill: j ? C.paper : C.ink, ls: 0}); + h += ``; + if (j) h += txt(36, y + 17, kl[j.kind], {size: 14, w: 700, fill: run ? C.paper : C.ink, ls: 1.2}) + txt(W - 6, y + 17, j.age, {size: 12.5, w: 600, anchor: 'end', fill: run ? C.paper : C.mute, ls: 0.5}); + else h += txt(36, y + 17, tr('setFree'), {size: 13, w: 500, fill: C.mute, ls: 1.2}); + }); + h += txt(0, 8 * 26 + 2, '', {}); + put('sets', setsSvg, h); + } + { + let h = ''; + s.vacs.forEach((v, k) => { + h += `` + txt(k * 40 + 17, 17, `VAC${k + 1}`, {size: 11, w: 700, anchor: 'middle', fill: v ? C.paper : C.ink, ls: 0}); + }); + put('vac', vacSvg, h); + put('busyTx', busyTx, `${tr('busyOf', s.busy, EXEC.coreSets)}`); + } + // one millisecond of memory cycles: jobs, stolen, idle + { + const W = EX.w - 28; + const n = s.strip.length; + const cw = W / n; + let h = hatch; + for (let i = 0; i < n; i++) { + const v = s.strip[i]; + if (v === 's') h += ``; + else if (v === 'j') h += ``; + else h += ``; + } + if (s.stripAt >= 0) h += ``; + h += `` + txt(13, 39, tr('stripJob'), {size: 11.5, w: 600, ls: 0.6}); + h += `` + txt(109, 39, tr('stripSteal'), {size: 11.5, w: 600, ls: 0.6}); + h += `` + txt(203, 39, tr('stripIdle'), {size: 11.5, w: 600, ls: 0.6}); + put('strip', stripSvg, h); + } + // the alarm, then what the restart kept and dropped + { + const a = s.alarm; + if (a && a.k > 0.01) { + alarmBox.style.display = 'block'; + alarmBox.style.opacity = a.k.toFixed(2); + put( + 'alarm', + alarmBox, + `
${tr('alarmHead')}
+ ${svgDigits(String(a.code).padStart(4, '0'), 0, 2, 22, 44, 6, '#2a1d05')} +
${tr('alarm:' + a.code)}
+
${tr('restartKept', KIND_LABEL()[a.kept] ?? '—')}
${tr('restartDropped', a.dropped)}
`, + ); + put('restart', restartTx, ''); + } else { + alarmBox.style.display = 'none'; + put('restart', restartTx, s.execNote || ''); + } + } + // detail + put('detHd', detHd, tr('det:' + s.detail.kind)); + put('det', detSvg, drawDetail(s.detail)); + put('cap', cap, s.caption ? `${s.caption}` : ''); + // buttons + for (const [k, n] of Object.entries(btn)) { + const on = s.sub === k || s.cut === k || s.view === k; + if (n.classList.contains('on') !== on) n.classList.toggle('on', on); + } + // 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); + const o = c.o.toFixed(2); + lh += ``; + ch += `
${c.name}${c.sub}
`; + } + put('callL', callSvg, lh); + put('callG', callG, ch); + pointer(s.cursor); + }, + }; +} +void digitsWidth; diff --git a/machines/rope/agc-shared/executive.js b/machines/rope/agc-shared/executive.js new file mode 100644 index 0000000..a34a57f --- /dev/null +++ b/machines/rope/agc-shared/executive.js @@ -0,0 +1,305 @@ +// The Executive's log, planned once: the LGC's jobs from PDI to touchdown, simulated in 1 ms steps, with the +// rendezvous radar's cycle steals taken off the top of every millisecond. Everything the DSKY and the Executive panel +// show is read from this log at the frame's mission time. No three.js. +// +// The jobs [E1] [E]: SERVICER every 2 s (needs a core set and a VAC area), its duty set by the phase (braking 85 %, +// +2 % for the landing radar once it locks, approach 90.5 %, P66 80 %); a Verb 16 monitor job every second while the +// crew has one up (3 %, lower priority than SERVICER); in P64 the landing-point-designator job every 2 s; one short +// keyboard job per key press. A job holds its core set from its request until it ends. When a request finds the 8 core +// sets (or the 5 VAC areas) busy: BAILOUT, alarm 1202 (1201), a software restart that keeps only the newest SERVICER, +// drops the rest and the Verb 16 monitor, and lights PROG. +import {CREW_KEYS, EVENTS, EXEC, DESCENT_END} from './machine.js'; + +export const DT = 0.001; +export const SAMPLE = 0.01; // the log keeps one sample every 10 ms +export const JOB = {servicer: 0, monitor: 1, lpd: 2, charin: 3}; +const PRIO = {servicer: 20, monitor: 10, lpd: 10, charin: 30}; // relative order only [E] +const NEEDS_VAC = {servicer: true, monitor: false, lpd: true, charin: false}; +const WORK = {monitor: EXEC.n68 * EXEC.monitorPeriod, lpd: EXEC.approachJobs, charin: 0.004}; +const LPD_START = 12; +const LR_RAMP = 24; // s for the radar's data to come fully into the navigation [E], calibrated on the first alarm + +/** the phase's program number at s */ +export const progAt = (s) => (s < EVENTS.p64 ? 63 : s < EVENTS.p66 ? 64 : s < EVENTS.contact + 3 ? 66 : 68); +/** SERVICER's duty (fraction of the machine) at s */ +export function servicerDuty(s) { + const p = progAt(s); + if (p === 63) return EXEC.braking + (s > EVENTS.alt ? EXEC.lrConvert * Math.min(1, (s - EVENTS.alt) / LR_RAMP) : 0); + if (p === 64) { + const k = Math.min(1, (s - EVENTS.p64) / (EVENTS.p66 - EVENTS.p64)); + return EXEC.approach[0] + (EXEC.approach[1] - EXEC.approach[0]) * k - EXEC.approachJobs / EXEC.servicerPeriod; + } + return EXEC.manual; +} + +// deterministic jitter on each SERVICER pass +const jit = (k) => { + const x = Math.sin(k * 12.9898 + 78.233) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +}; + +/** + * Runs the log. keys: [[s, key]] (the crew's, plus any the visitor adds); radar: the RR cycle steals on (as flown) or + * off. Returns the samples, every job, the alarms, the displays and the lamps. + */ +export function simulate({keys = CREW_KEYS, radar = true, end = DESCENT_END} = {}) { + const N = Math.ceil(end / SAMPLE) + 1; + const S = { + sets: new Uint8Array(N), // bit k: core set k busy + vacs: new Uint8Array(N), + run: new Int8Array(N), // the job running at the sample (JOB.*), -1 the dummy job + duty: new Float32Array(N), // demanded duty incl. the radar's steals + busy: new Uint8Array(N), // core sets in use + }; + const jobs = []; + const alarms = []; + const restarts = []; + const live = []; + const sets = new Array(EXEC.coreSets).fill(null); + const vacs = new Array(EXEC.vacAreas).fill(null); + const tloss = radar ? EXEC.tloss : 0; + // ------------------------------------------------ the DSKY's own state (a small PINBALL) + const kq = [...keys].sort((a, b) => a[0] - b[0]); + let ki = 0; + const disp = []; // display states over time + const lampEv = []; // [s, lamp, on] + let mode = ''; // '', 'verb', 'noun' + let vb = ''; + let nb = ''; + let verb = '06'; + let noun = '63'; + let monitor = false; + let failreg = [0, 0, 0]; + let prog = 63; + const keyEv = []; + const pushDisp = (s, x = {}) => disp.push({s, prog, verb, noun, mode, vb, nb, monitor, fail: [...failreg], ...x}); + pushDisp(0); + let oprErr = false; + const exec = (s) => { + const v = vb.padStart(2, '0'); + const n = nb.padStart(2, '0'); + mode = ''; + if ((v === '16' || v === '06') && ['63', '64', '68', '60'].includes(n)) { + verb = v; + noun = n; + monitor = v === '16'; + } else if (v === '05' && n === '09') { + verb = v; + noun = n; + monitor = false; + } else if (v === '16' && n === '36') { + verb = v; + noun = n; + monitor = true; + } else { + oprErr = true; + lampEv.push([s, 'OPR ERR', 1]); + } + vb = v; + nb = n; + }; + const key = (s, k) => { + keyEv.push([s, k]); + if (k === 'VERB') { + mode = 'verb'; + vb = ''; + } else if (k === 'NOUN') { + mode = 'noun'; + nb = ''; + } else if (/^\d$/.test(k)) { + if (mode === 'verb' && vb.length < 2) vb += k; + else if (mode === 'noun' && nb.length < 2) nb += k; + else { + oprErr = true; + lampEv.push([s, 'OPR ERR', 1]); + } + } else if (k === 'CLR') { + if (mode === 'verb') vb = ''; + if (mode === 'noun') nb = ''; + } else if (k === 'ENTR') { + if (mode) exec(s); + } else if (k === 'RSET') { + lampEv.push([s, 'PROG', 0]); + if (oprErr) lampEv.push([s, 'OPR ERR', 0]); + oprErr = false; + } else if (k === 'KEY REL') { + monitor = false; + mode = ''; + progDisplay(); + } + pushDisp(s); + }; + const progDisplay = () => { + verb = '06'; + noun = prog === 63 ? '63' : prog === 64 ? '64' : '60'; + monitor = false; + }; + + // ------------------------------------------------ the Executive + let nextServ = 0.02; + let nextMon = Infinity; + let nextLpd = Infinity; + let kServ = 0; + let id = 0; + const request = (s, kind, work) => { + const set = sets.indexOf(null); + const vac = NEEDS_VAC[kind] ? vacs.indexOf(null) : -2; + if (set < 0 || vac === -1) return bailout(s, set < 0 ? 1202 : 1201, kind); + const j = {id: id++, kind, prio: PRIO[kind], t0: s, t1: null, work, left: work, set, vac, fate: ''}; + sets[set] = j; + if (vac >= 0) vacs[vac] = j; + jobs.push(j); + live.push(j); + return j; + }; + const free = (j, s, fate) => { + j.t1 = s; + j.fate = fate; + sets[j.set] = null; + if (j.vac >= 0) vacs[j.vac] = null; + live.splice(live.indexOf(j), 1); + }; + const bailout = (s, code, kind) => { + const busy = sets.filter(Boolean).length; + alarms.push({s, code, busy, vacBusy: vacs.filter(Boolean).length, kind}); + if (!failreg[0]) failreg[0] = code; + else if (!failreg[1]) failreg[1] = code; + failreg[2] = code; + lampEv.push([s, 'PROG', 1]); + // the restart: only the newest SERVICER survives, restarted from its restart point + const newest = [...live].reverse().find((j) => j.kind === 'servicer'); + const kept = []; + const dropped = []; + for (const j of [...live]) { + if (j === newest) continue; + dropped.push({kind: j.kind, t0: j.t0}); + free(j, s, 'flushed'); + } + if (newest) { + kept.push({kind: newest.kind, t0: newest.t0}); + newest.left = newest.work; + } + if (monitor) { + monitor = false; + nextMon = Infinity; + } + progDisplay(); + mode = ''; + restarts.push({s, code, kept, dropped}); + pushDisp(s, {restart: true}); + return null; + }; + + const steps = Math.ceil(end / DT); + let si = 0; + for (let i = 0; i <= steps; i++) { + const s = i * DT; + // keys + while (ki < kq.length && kq[ki][0] <= s) { + const [ks, k] = kq[ki++]; + request(ks, 'charin', WORK.charin); + const wasMon = monitor; + key(ks, k); + if (monitor && !wasMon) nextMon = ks + 0.05; + if (!monitor) nextMon = Infinity; + } + // program changes + const p = progAt(s); + if (p !== prog) { + prog = p; + if (p === 64) { + nextLpd = s + LPD_START; // after the pitchover [E] + } else nextLpd = Infinity; + nextMon = Infinity; + progDisplay(); + mode = ''; + pushDisp(s); + } + // the waitlist + if (s >= nextServ) { + const w = EXEC.servicerPeriod * servicerDuty(s) * (1 + 0.025 * jit(kServ++)); + request(s, 'servicer', w); + nextServ += EXEC.servicerPeriod; + } + if (s >= nextMon) { + if (monitor) request(s, 'monitor', WORK.monitor); + nextMon = monitor ? nextMon + EXEC.monitorPeriod : Infinity; + } + if (s >= nextLpd) { + request(s, 'lpd', WORK.lpd); + nextLpd += EXEC.servicerPeriod; + } + // one millisecond of CPU, less the stolen cycles, to the highest priorities first (FIFO among equals) + let cap = DT * (1 - tloss); + let ran = -1; + while (cap > 1e-12 && live.length) { + let best = null; + for (const j of live) if (!best || j.prio > best.prio) best = j; + const use = Math.min(cap, best.left); + best.left -= use; + cap -= use; + if (ran < 0) ran = JOB[best.kind]; + if (best.left <= 1e-12) { + free(best, s, 'done'); + if (best.kind === 'monitor' || best.kind === 'servicer') best.shown = s; // the display it refreshed + } + } + if (s >= si * SAMPLE - 1e-9 && si < N) { + let m = 0; + let v = 0; + sets.forEach((j, k) => j && (m |= 1 << k)); + vacs.forEach((j, k) => j && (v |= 1 << k)); + S.sets[si] = m; + S.vacs[si] = v; + S.run[si] = ran; + S.busy[si] = sets.filter(Boolean).length; + S.duty[si] = tloss + servicerDuty(s) + (monitor ? EXEC.n68 : 0) + (prog === 64 ? EXEC.approachJobs / EXEC.servicerPeriod : 0); + si++; + } + } + // display refreshes: a monitor job's end (Verb 16), a SERVICER pass's end (the program's Verb 06) + const monShown = jobs.filter((j) => j.kind === 'monitor' && j.fate === 'done').map((j) => j.t1); + const servShown = jobs.filter((j) => j.kind === 'servicer' && j.fate === 'done').map((j) => j.t1); + return {S, jobs, alarms, restarts, disp, lampEv, keyEv, monShown, servShown, radar, end}; +} + +const lastLE = (arr, s, key = (x) => x) => { + let lo = 0; + let hi = arr.length - 1; + if (hi < 0 || key(arr[0]) > s) return -1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (key(arr[mid]) <= s) lo = mid; + else hi = mid - 1; + } + return lo; +}; +export const sampleAt = (log, s) => Math.max(0, Math.min(log.S.sets.length - 1, Math.round(s / SAMPLE))); +/** the display state at s, and when its registers were last refreshed */ +export function displayAt(log, s) { + const d = log.disp[Math.max(0, lastLE(log.disp, s, (x) => x.s))]; + const src = d.monitor ? log.monShown : log.servShown; + const k = lastLE(src, s); + const shown = k < 0 ? d.s : Math.max(src[k], d.s); + return {...d, shown}; +} +/** a lamp's state at s */ +export function lampAt(log, s, name) { + let on = 0; + for (const [t, l, v] of log.lampEv) { + if (t > s) break; + if (l === name) on = v; + } + return on; +} +/** the jobs alive at s, in core-set order */ +export function jobsAt(log, s) { + const out = new Array(EXEC.coreSets).fill(null); + for (const j of log.jobs) if (j.t0 <= s && (j.t1 === null || j.t1 > s)) out[j.set] = j; + return out; +} +/** the last restart at or before s */ +export function restartAt(log, s) { + const k = lastLE(log.restarts, s, (x) => x.s); + return k < 0 ? null : log.restarts[k]; +} diff --git a/machines/rope/agc-shared/film.js b/machines/rope/agc-shared/film.js new file mode 100644 index 0000000..15561c5 --- /dev/null +++ b/machines/rope/agc-shared/film.js @@ -0,0 +1,401 @@ +// Rope, the film: the AGC and its DSKY (agc.js) on the drawing sheet (stage.js) under the overlay (console.js). One +// input, the mission time in MCT since PDI (schedule.js), reads the Executive's log (executive.js) and the descent +// (machine.js): the word in the rope, the core sets, the lamps, VERB / NOUN / PROG and R1-R3. An operator opens the +// case, pulls rope module B1 and dives between its cores while one word is read, reads and rewrites one erasable +// word, goes back to the DSKY, watches the radar's stolen cycles fill the core sets until the 1202, then +// fast-forwards through P64 to touchdown. A pure function of t. +import {runSim} from 'teardown-kit/runtime.js'; +import {createConsole} from './console.js'; +import {L} from './agc.js'; +import {loadFonts} from './fonts.js'; +import {AGC, EVENTS, EXEC, GET0, MCT, ROPE_WORDS, bits16, descent, ropePlace} from './machine.js'; +import {SAMPLE, displayAt, jobsAt, lampAt, restartAt, sampleAt, servicerDuty} from './executive.js'; +import {ALARM1, DURATION, ERAS_JOB, ERAS_MCT, LOG, NOVAC, ROPE_MCT, SCRIPT, T as TB, actsAt, camAtName, missionAt, rateAt} from './schedule.js'; +import {createStage} from './stage.js'; +import {nf, T as tr} from './i18n.js'; + +export {DURATION, SCRIPT}; +export const DEFAULTS = {duration: DURATION, fps: 60, theme: 'light', hud: {enabled: false, title: 'rope', subtitle: ''}, director: {enabled: false}, watermark: ''}; +const D2R = Math.PI / 180; +export const smoother = (x) => { + const u = Math.min(1, Math.max(0, x)); + return u * u * u * (u * (u * 6 - 15) + 10); +}; +export const ss = (a, b, t) => smoother((t - a) / (b - a)); +const lerp = (a, b, k) => a + (b - a) * k; +const lerpV = (a, b, k) => ({x: lerp(a.x, b.x, k), y: lerp(a.y, b.y, k), z: lerp(a.z, b.z, k)}); +const logLerp = (a, b, k) => Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * k); + +// ------------------------------------------------------------ views: straight, slow, along the axes. u: seconds in +// the view, A: the machine's anchors (world space) for the frame +export const VIEW_FN = { + hero: (u) => ({tx: 0.05, ty: 0.07, tz: 0.0, az: 34 + 1.4 * Math.sin(u * 0.41), el: 25, d: 3.15 - 0.06 * Math.sin(u * 0.41), sx: 0, sy: 30}), + open: (u) => ({tx: 0.02, ty: 0.05, tz: -0.16, az: 30 + 0.4 * Math.min(u, 6), el: 38, d: 3.15, sx: 0, sy: 20}), + rope: (u, A) => { + const k = smoother((u - 1.2) / 3.0); + const p = lerpV(A.rope, A.core, smoother((u - 1.6) / 2.6)); + return {tx: p.x, ty: p.y, tz: p.z, az: lerp(28, 48, k), el: lerp(40, 26, k), d: logLerp(1.05, 0.066, k), sx: 0, sy: lerp(20, 0, k)}; + }, + eras: (u, A) => { + const k = smoother((u - 0.6) / 2.4); + const p = lerpV(A.eras, A.erasCore, k); + return {tx: p.x, ty: p.y, tz: p.z, az: lerp(16, 6, k), el: lerp(30, 12, k), d: logLerp(1.1, 0.075, k), sx: 0, sy: lerp(20, 0, k)}; + }, + dsky: (u, A) => ({tx: A.dsky.x, ty: A.dsky.y - 0.052, tz: A.dsky.z, az: 26 - 0.4 * Math.min(u, 6), el: 7, d: 1.22, sx: 0, sy: 6}), + exec: (u) => ({tx: 0.06, ty: 0.05, tz: -0.12, az: 22 + 0.4 * Math.min(u, 8), el: 31, d: 3.35, sx: 0, sy: 16}), + // live only + logic: (u, A) => { + const k = smoother((u - 0.4) / 2.2); + return {tx: lerp(-0.1, A.gate.x - 0.03, k), ty: lerp(0.06, A.gate.y - 0.03, k), tz: lerp(0, A.gate.z - 0.04, k), az: lerp(28, 14, k), el: lerp(36, 20, k), d: logLerp(2.6, 0.62, k), sx: 0, sy: 10}; + }, + graze: (u) => ({tx: L.x0 + 0.02, ty: 0.07, tz: 0.0, az: 78 - 0.6 * Math.min(u, 8), el: 7, d: 1.45, sx: 0, sy: 6}), +}; +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' ? logLerp(a.d, b.d, k) : a[key] + (b[key] - a[key]) * k; + // between two close views the camera backs out on the way, so it never passes through the trays + const lift = Math.max(0, Math.log(0.9 / Math.max(a.d, b.d))); + o.d *= Math.exp(lift * Math.sin(Math.PI * k)); + return o; +} +const VIEWS = SCRIPT.filter((e) => e.cam).map((e) => [e.t, e.cam]); +export const camAt = (t, A) => { + let i = 0; + while (i < VIEWS.length - 1 && VIEWS[i + 1][0] <= t) i++; + const view = (k) => VIEW_FN[VIEWS[k][1]](t - VIEWS[k][0], A); + const cur = view(i); + if (i === 0) return cur; + const k = ss(VIEWS[i][0], VIEWS[i][0] + 2.0, t); + return k >= 1 ? cur : blend(view(i - 1), cur, k); +}; + +// ------------------------------------------------------------ the operator's pointer +const REST = [[820, 520], [700, 470], [900, 560], [760, 430]]; +export function makeCursor(ui, script = SCRIPT) { + const EV = script.filter((e) => e.t > 0 && e.btn).map((e) => ({t: e.t, spot: ui.spots[e.btn]})); + 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}; + }; +} + +// ------------------------------------------------------------ the machine's state at mission time s +const sgn = (v, n = 5) => (v < 0 ? '-' : '+') + String(Math.min(10 ** n - 1, Math.round(Math.abs(v)))).padStart(n, '0'); +const mmss = (sec) => { + const t = Math.max(0, Math.round(sec)); + return `+${String(Math.min(99, Math.floor(t / 60))).padStart(2, '0')} ${String(t % 60).padStart(2, '0')}`; +}; +const hash = (n) => { + const x = Math.sin(n * 0.61803 + 3.1) * 43758.5453; + return x - Math.floor(x); +}; +/** registers for a noun from the descent at the display's last refresh */ +export function registers(noun, ds, fail, s) { + if (noun === '68') return [sgn(ds.slantNm * 10), mmss(ds.tgo), sgn(ds.dH)]; + if (noun === '63') return [sgn(ds.v * 10), sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '64') return [`+${String(Math.max(0, Math.round(EVENTS.p66 - ds.s))).padStart(2, '0').slice(-2)} ${String(Math.round(ds.lpd)).padStart(2, '0')}`, sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '60') return [sgn(ds.vh * 10), sgn(ds.hdot * 10), sgn(ds.h)]; + if (noun === '09') return fail.map((c) => (c ? ' ' + c.toString().padStart(5, '0') : ' 00000')); + if (noun === '36') { + const g = GET0 + s; + return [sgn(Math.floor(g / 3600)), sgn(Math.floor((g % 3600) / 60)), sgn(Math.floor((g % 60) * 100))]; + } + return [' ', ' ', ' ']; +} +export function machineState(log, s, {liveKeys} = {}) { + const d = displayAt(log, s); + const ds = descent(d.shown); + const reg = registers(d.noun, ds, d.fail, d.shown); + const verb = d.mode === 'verb' ? d.vb.padEnd(2, ' ') : d.verb; + const noun = d.mode === 'noun' ? d.nb.padEnd(2, ' ') : d.mode === 'verb' ? d.noun : d.noun; + const i = sampleAt(log, s); + const run = log.S.run[i]; + const lamps = { + PROG: lampAt(log, s, 'PROG'), + 'OPR ERR': lampAt(log, s, 'OPR ERR'), + ALT: s < EVENTS.alt || (s > EVENTS.lrDrop && s < EVENTS.lrBack) ? 1 : 0, + VEL: s < EVENTS.vel || (s > EVENTS.lrDrop && s < EVENTS.lrBack) ? 1 : 0, + }; + // the jobs, and which one has the processor + const sets = jobsAt(log, s); + let runJ = null; + for (const j of sets) if (j && (!runJ || j.prio > runJ.prio || (j.prio === runJ.prio && j.t0 < runJ.t0))) runJ = j; + const setsOut = sets.map((j) => (j ? {kind: j.kind, run: j === runJ && run >= 0, age: `${nf(Math.max(0, s - j.t0), 1)} s`} : null)); + const vacs = []; + for (let k = 0; k < EXEC.vacAreas; k++) vacs.push(!!(log.S.vacs[i] & (1 << k))); + const busy = sets.filter(Boolean).length; + const tl = log.radar ? EXEC.tloss : 0; + const prog = s < EVENTS.p64 ? 63 : s < EVENTS.p66 ? 64 : 66; + const serv = servicerDuty(s); + const lrPart = prog === 63 ? Math.max(0, serv - EXEC.braking) : 0; + const mon = d.monitor ? EXEC.n68 : 0; + const lpd = prog === 64 ? EXEC.approachJobs / EXEC.servicerPeriod : 0; + const parts = [ + {k: 'serv', v: serv - lrPart}, + ...(lrPart > 0.001 ? [{k: 'lr', v: lrPart}] : []), + ...(mon ? [{k: 'n68', v: mon}] : []), + ...(lpd ? [{k: 'lpd', v: lpd}] : []), + ...(tl ? [{k: 'rr', v: tl}] : []), + ]; + const total = parts.reduce((a, p) => a + p.v, 0); + // one millisecond of MCTs, ending now + const n0 = Math.floor(s / MCT); + const strip = []; + const idleP = Math.max(0, 1 - total); + const novac0 = Math.round(NOVAC.req.t0 / MCT); + for (let q = 0; q < 85; q++) { + const n = n0 - 84 + q; + const inNovac = n >= novac0 && n < novac0 + NOVAC.mct; + const h = hash(n); + if (!inNovac && h < tl) strip.push('s'); + else if (inNovac || (run >= 0 && h > tl + idleP)) strip.push('j'); + else strip.push(run < 0 ? 'i' : h < tl + idleP ? 'i' : 'j'); + } + // the alarm, while PROG is lit and a few seconds after + const r = restartAt(log, s); + let alarm = null; + if (r) { + const age = s - r.s; + const k = lamps.PROG ? 1 : 1 - Math.min(1, Math.max(0, (age - 6) / 1.5)); + if (k > 0) { + const counts = {}; + for (const x of r.dropped) counts[x.kind] = (counts[x.kind] || 0) + 1; + const kl = {servicer: tr('job:servicer'), monitor: tr('job:monitor'), lpd: tr('job:lpd'), charin: tr('job:charin')}; + alarm = {code: r.code, k: Math.min(1, age / 0.15) * k, kept: r.kept[0]?.kind, dropped: Object.entries(counts).map(([kk, c]) => `${c} × ${kl[kk]}`).join(', ') || '—'}; + } + } + return { + dsky: {prog: String(prog === 66 && s > EVENTS.contact + 3 ? 68 : prog), verb, noun, reg, compActy: run >= 0 ? 1 : 0, lamps}, + d, + ds, + sets: setsOut, + vacs, + busy, + duty: {parts, total}, + strip, + alarm, + }; +} +export const dskyText = (d, ds) => { + const n = d.mode ? '' : d.noun; + if (n === '68') return tr('dsky:68', nf(ds.slantNm, 1), mmss(ds.tgo).slice(1).replace(' ', ':'), nf(Math.round(ds.dH))); + if (n === '63') return tr('dsky:63', nf(ds.v, 1), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '64') return tr('dsky:64', Math.round(ds.lpd), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '60') return tr('dsky:60', nf(ds.vh, 1), nf(ds.hdot, 1), nf(Math.round(ds.h))); + if (n === '09') return tr('dsky:09'); + if (n === '36') return tr('dsky:36'); + return tr('dsky:keying'); +}; +export const rateText = (r) => { + if (r < 1e-3) return tr('rateSlow', nf(r * 1e6, 1), nf(1 / r)); + if (r < 0.9) return tr('rateSlowS', nf(r, 2)); + return tr('rateFast', nf(r, r < 10 ? 1 : 0)); +}; + +// ------------------------------------------------------------ the subsystem panels +const ROPE_W = ROPE_WORDS.findIndex((w) => w.op === 'CAF NO.CORES'); +const SEL = ROPE_WORDS[ROPE_W]; +export const ROPE_PLACE = ropePlace(SEL.bank, SEL.addr); +export const prioWord = (kind) => (kind ? ({servicer: 0o20, monitor: 0o10, lpd: 0o10, charin: 0o30}[kind] << 9) | 0o154 : 0o77777); +/** rope read inside its MCT: phase 0..1 → {stage, fill} */ +export function ropeRead(phase) { + if (phase < 0) return {stage: -1, fill: 0}; + if (phase >= 1) return {stage: -1, fill: 16}; + const stage = phase < 0.12 ? 0 : phase < 0.45 ? 1 : phase < 0.72 ? 2 : 3; + const fill = Math.max(0, Math.min(16, Math.floor(((phase - 0.5) / 0.3) * 16 + 1))); + return {stage, fill: phase < 0.5 ? 0 : fill}; +} +export function erasRead(phase) { + if (phase < 0) return {stage: -1, fill: 16, state: 'stored'}; + if (phase >= 1) return {stage: -1, fill: 16, state: 'stored'}; + const stage = phase < 0.18 ? 0 : phase < 0.55 ? 1 : 2; + const fill = phase < 0.2 ? 0 : Math.min(16, Math.floor(((phase - 0.2) / 0.22) * 16 + 1)); + return {stage, fill, state: 'read'}; +} +export function detailFor(kind, s, ms, extra = {}) { + if (kind === 'rope') { + const ph = extra.ropePhase ?? -1; + const r = ropeRead(ph); + return {kind, w: extra.ropeW ?? ROPE_W, place: extra.place ?? ROPE_PLACE, fill: r.fill, stage: r.stage}; + } + if (kind === 'eras') { + const ph = extra.erasPhase ?? -1; + const r = erasRead(ph); + const job = extra.erasJob; + return {kind, set: extra.erasSet ?? 0, job, bits: bits16(prioWord(job)), fill: r.fill, stage: r.stage, state: r.state}; + } + if (kind === 'clock') { + const f = (s / MCT) % 1; + return {kind, pulse: Math.floor(f * 12), phase: f, mct: Math.floor(s / MCT)}; + } + if (kind === 'nor') { + const f = (s / MCT) % 1; + const p = Math.floor(f * 12); + return {kind, in: extra.norIn ?? [p % 2 ? 1 : 0, p >= 4 && p < 8 ? 1 : 0, p >= 9 ? 1 : 0]}; + } + if (kind === 'iface') return {kind, rr: !!extra.radar, lr: s > EVENTS.alt}; + if (kind === 'dsky') return {kind, text: tr('dskyKeysNote')}; + return {kind: 'exec'}; +} + +// ------------------------------------------------------------ callouts per view, label spots on the 1600 x 900 layout +export function calloutsFor(view, st, vis, extra = {}) { + const A = st.mac.anchors; + const out = []; + const c = (key, at, label, w, hot = false, o = vis) => { + const [name, sub] = tr(key); + out.push({name, sub, at: st.project(at), label, w, o, hot}); + }; + if (view === 'hero') { + c('cAgc', A.trayB, [380, 150], 230); + c('cDsky', A.dsky, [1230, 150], -200); + } else if (view === 'open' || view === 'exec') { + c('cTrayA', A.trayA, [380, 600], 220); + c('cTrayB', A.xrib, [1230, 112], -230); + if (view === 'exec') c('cConn', A.conn, [1230, 600], -250, !!extra.rrHot); + } else if (view === 'rope') { + const o2 = vis * (extra.inside ?? 0); + c('cRopeMod', A.ropeEnd, [1230, 112], -250, false, vis * (1 - (extra.inside ?? 0))); + c('cCore', A.core, [380, 112], 250, true, o2); + c('cSense', A.coreNext, [1230, 112], -250, false, o2); + c('cInhibit', A.inhib, [380, 640], 250, false, o2); + } else if (view === 'eras') { + const o2 = vis * (extra.inside ?? 0); + c('cErasMod', A.erasMod, [380, 112], 260, false, vis * (1 - (extra.inside ?? 0))); + c('cErasCore', A.erasCore, [380, 112], 260, true, o2); + c('cErasSense', A.erasSense, [1230, 640], -250, false, o2); + } else if (view === 'dsky') { + c('cEl', A.dskyEl, [1230, 112], -240, true); + c('cKeys', A.dskyKeys, [380, 640], 220); + } else if (view === 'logic') { + c('cLogic', A.logic, [380, 112], 240); + c('cGate', A.gate, [1230, 112], -250, true); + } else if (view === 'graze') { + c('cTrayA', A.trayA, [380, 112], 220); + } + return out; +} + +// ------------------------------------------------------------ captions of the film, by beat +const CAPS = [ + [0, 'cap:hero'], + [3.0, 'cap:open'], + [6.8, 'cap:rope'], + [11.0, 'cap:rope2'], + [14.6, 'cap:eras'], + [20.2, 'cap:dsky'], + [24.0, 'cap:exec'], + [TB.alarm, 'cap:alarm'], + [TB.alarm + 2.4, 'cap:restart'], + [TB.ff, 'cap:ff'], + [35.2, 'cap:p64'], + [TB.contact - 0.4, 'cap:contact'], +]; +export const captionAt = (t) => tr(CAPS.filter((c) => c[0] <= t).pop()[1]); + +// ------------------------------------------------------------ the film +export function makeFilm() { + return async ({canvas, capture}) => { + await loadFonts(); + 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'; + window.ROPE = {st, LOG}; + const sR = ROPE_MCT * MCT; + const sE = ERAS_MCT * MCT; + return { + resize, + render(t) { + const s = missionAt(t); + const view = camAtName(t); + const t0 = VIEWS.filter(([a]) => a <= t).pop()[0]; + const since = t - t0; + const ms = machineState(LOG, s); + const acts = actsAt(t); + const ropePhase = (s - sR) / MCT; + const erasPhase = (s - sE) / MCT; + const press = {}; + for (const [ks, k] of LOG.keyEv) { + const dt = s - ks; + if (dt >= 0 && dt < 0.25) press[k] = Math.max(press[k] || 0, 1 - dt / 0.25); + } + const fx = { + ...acts, + read: {phase: ropePhase >= 0 && ropePhase < 1 ? ropePhase : -1}, + erasRead: {phase: erasPhase >= 0 && erasPhase < 1 ? erasPhase : -1, bit: bits16(prioWord(ERAS_JOB?.kind))[1]}, + rr: view === 'exec' || view === 'hero' ? 0.25 + 0.25 * Math.sin(t * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press}, + }; + st.mac.update(fx); + const c = camAt(t, st.mac.anchors); + st.frame(fx, {...c, az: c.az * D2R, el: c.el * D2R}); + const det = + view === 'rope' + ? detailFor('rope', s, ms, {ropePhase}) + : view === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: ERAS_JOB?.kind, erasSet: 0}) + : view === 'dsky' + ? detailFor('dsky', s, ms) + : view === 'exec' + ? detailFor('iface', s, ms, {radar: true}) + : detailFor('clock', s, ms); + const inside = view === 'rope' ? ss(3.0, 4.2, since) : view === 'eras' ? ss(2.0, 3.0, since) : 0; + ui.update({ + get: GET0 + s, + mct: s / MCT, + rateText: rateText(rateAt(t)), + dsky: ms.dsky, + dskyText: dskyText(ms.d, ms.ds), + sets: ms.sets, + vacs: ms.vacs, + busy: ms.busy, + duty: ms.duty, + strip: ms.strip, + stripAt: 84, + alarm: ms.alarm, + execNote: tr('execWait'), + detail: det, + caption: captionAt(t), + sub: {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[view] || '', + cut: acts.open > 0.5 ? 'tray' : '', + view: '', + callouts: calloutsFor(view, st, ss(1.2, 1.9, since) * (t > DURATION - 0.3 ? 0 : 1), {inside, rrHot: true}), + cursor: cursorAt(t), + }); + return {}; + }, + }; + }; +} +void AGC; +void SAMPLE; +void ALARM1; + +export const run = () => runSim(DEFAULTS, makeFilm()); diff --git a/machines/rope/agc-shared/fonts.js b/machines/rope/agc-shared/fonts.js new file mode 100644 index 0000000..b5d545d --- /dev/null +++ b/machines/rope/agc-shared/fonts.js @@ -0,0 +1,26 @@ +// Barlow Condensed (SIL Open Font License, @fontsource/barlow-condensed), the console's technical-drawing capitals: +// the latin subset in three weights, inlined so the page opens offline. Both builds (the site's and the teardown +// skill's) load .woff2 as base64. +import w500 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-500-normal.woff2'; +import w600 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-600-normal.woff2'; +import w700 from '@fontsource/barlow-condensed/files/barlow-condensed-latin-700-normal.woff2'; + +const buf = (b64) => { + const s = atob(b64); + const u = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); + return u.buffer; +}; +export const FAMILY = "'Barlow Condensed', 'Arial Narrow', sans-serif"; +/** registers the three weights; resolves when they are ready to draw with */ +export async function loadFonts() { + const faces = [ + [w500, '500'], + [w600, '600'], + [w700, '700'], + ].map(([b, weight]) => new FontFace('Barlow Condensed', buf(b), {weight, style: 'normal'})); + for (const f of faces) { + document.fonts.add(f); + await f.load().catch(() => {}); + } +} diff --git a/machines/rope/agc-shared/i18n.js b/machines/rope/agc-shared/i18n.js new file mode 100644 index 0000000..dd49315 --- /dev/null +++ b/machines/rope/agc-shared/i18n.js @@ -0,0 +1,223 @@ +// 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 S = { + // ------------------------------------------------------------ the clock and the DSKY + clockTitle: {en: 'MISSION TIME · GET', fr: 'TEMPS DE MISSION · GET', es: 'TIEMPO DE MISIÓN · GET'}, + mctSince: {en: 'MEMORY CYCLES (MCT) SINCE PDI', fr: 'CYCLES MÉMOIRE (MCT) DEPUIS LA PDI', es: 'CICLOS DE MEMORIA (MCT) DESDE LA PDI'}, + rateSlow: {en: (us, x) => `1 S ON SCREEN = ${us} µS · SLOWED ×${x}`, fr: (us, x) => `1 S À L’ÉCRAN = ${us} µS · RALENTI ×${x}`, es: (us, x) => `1 S EN PANTALLA = ${us} µS · RALENTIZADO ×${x}`}, + rateSlowS: {en: (r) => `1 S ON SCREEN = ${r} S OF MISSION`, fr: (r) => `1 S À L’ÉCRAN = ${r} S DE MISSION`, es: (r) => `1 S EN PANTALLA = ${r} S DE MISIÓN`}, + rateFast: {en: (r) => `MISSION RUNS ×${r}`, fr: (r) => `LA MISSION DÉFILE ×${r}`, es: (r) => `LA MISIÓN CORRE ×${r}`}, + dskyTitle: {en: 'DSKY · DISPLAY AND KEYBOARD', fr: 'DSKY · AFFICHEUR ET CLAVIER', es: 'DSKY · PANTALLA Y TECLADO'}, + 'dsky:68': { + en: (r, tg, dh) => `V16 N68 · R1 slant range ${r} nmi · R2 time to high gate ${tg} · R3 Δh radar − computed ${dh} ft`, + fr: (r, tg, dh) => `V16 N68 · R1 distance oblique ${r} nmi · R2 temps jusqu’au high gate ${tg} · R3 Δh radar − calculée ${dh} ft`, + es: (r, tg, dh) => `V16 N68 · R1 distancia oblicua ${r} nmi · R2 tiempo hasta el high gate ${tg} · R3 Δh radar − calculada ${dh} ft`, + }, + 'dsky:63': { + en: (v, hd, h) => `V06 N63 · R1 speed ${v} ft/s · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (v, hd, h) => `V06 N63 · R1 vitesse ${v} ft/s · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (v, hd, h) => `V06 N63 · R1 velocidad ${v} ft/s · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:64': { + en: (a, hd, h) => `V06 N64 · R1 time left to redesignate, LPD angle ${a}° · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (a, hd, h) => `V06 N64 · R1 temps pour redésigner, angle LPD ${a}° · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (a, hd, h) => `V06 N64 · R1 tiempo para redesignar, ángulo LPD ${a}° · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:60': { + en: (v, hd, h) => `V06 N60 · R1 forward speed ${v} ft/s · R2 altitude rate ${hd} ft/s · R3 altitude ${h} ft`, + fr: (v, hd, h) => `V06 N60 · R1 vitesse horizontale ${v} ft/s · R2 vitesse verticale ${hd} ft/s · R3 altitude ${h} ft`, + es: (v, hd, h) => `V06 N60 · R1 velocidad horizontal ${v} ft/s · R2 velocidad vertical ${hd} ft/s · R3 altitud ${h} ft`, + }, + 'dsky:09': {en: 'V05 N09 · the alarm codes: R1 the first, R2 the second, R3 the last', fr: 'V05 N09 · les codes d’alarme : R1 le premier, R2 le deuxième, R3 le dernier', es: 'V05 N09 · los códigos de alarma: R1 el primero, R2 el segundo, R3 el último'}, + 'dsky:36': {en: 'V16 N36 · the computer’s clock: hours, minutes, seconds', fr: 'V16 N36 · l’horloge du calculateur : heures, minutes, secondes', es: 'V16 N36 · el reloj del ordenador: horas, minutos, segundos'}, + 'dsky:keying': {en: 'Keying in a verb and a noun…', fr: 'Saisie d’un verbe et d’un nom…', es: 'Tecleando un verbo y un nombre…'}, + // ------------------------------------------------------------ the Executive + execTitle: {en: 'EXECUTIVE · WAITLIST', fr: 'EXECUTIVE · WAITLIST', es: 'EXECUTIVE · WAITLIST'}, + dutyTitle: {en: 'DUTY ASKED OF THE MACHINE', fr: 'CHARGE DEMANDÉE À LA MACHINE', es: 'CARGA PEDIDA A LA MÁQUINA'}, + 'duty:serv': {en: 'GUIDANCE', fr: 'GUIDAGE', es: 'GUIADO'}, + 'duty:lr': {en: 'RADAR DATA', fr: 'DONNÉES RADAR', es: 'DATOS RADAR'}, + 'duty:n68': {en: 'V16 N68', fr: 'V16 N68', es: 'V16 N68'}, + 'duty:lpd': {en: 'LPD', fr: 'LPD', es: 'LPD'}, + 'duty:rr': {en: 'STOLEN', fr: 'VOLÉS', es: 'ROBADOS'}, + setsTitle: {en: 'CORE SETS · ONE PER JOB', fr: 'CORE SETS · UN PAR TÂCHE', es: 'CORE SETS · UNO POR TAREA'}, + setFree: {en: 'FREE', fr: 'LIBRE', es: 'LIBRE'}, + vacTitle: {en: 'VAC AREAS · INTERPRETED JOBS', fr: 'ZONES VAC · TÂCHES INTERPRÉTÉES', es: 'ZONAS VAC · TAREAS INTERPRETADAS'}, + busyOf: {en: (b, n) => `${b} / ${n} BUSY`, fr: (b, n) => `${b} / ${n} PRIS`, es: (b, n) => `${b} / ${n} OCUPADOS`}, + stripTitle: {en: 'THE LAST MILLISECOND · 85 MCT', fr: 'LA DERNIÈRE MILLISECONDE · 85 MCT', es: 'EL ÚLTIMO MILISEGUNDO · 85 MCT'}, + stripJob: {en: 'JOBS', fr: 'TÂCHES', es: 'TAREAS'}, + stripSteal: {en: 'STOLEN (RADAR)', fr: 'VOLÉS (RADAR)', es: 'ROBADOS (RADAR)'}, + stripIdle: {en: 'IDLE', fr: 'LIBRES', es: 'LIBRES'}, + 'job:servicer': {en: 'SERVICER', fr: 'SERVICER', es: 'SERVICER'}, + 'job:monitor': {en: 'MONITOR V16 N68', fr: 'MONITEUR V16 N68', es: 'MONITOR V16 N68'}, + 'job:lpd': {en: 'LPD DISPLAY', fr: 'AFFICHAGE LPD', es: 'PANTALLA LPD'}, + 'job:charin': {en: 'KEYBOARD', fr: 'CLAVIER', es: 'TECLADO'}, + alarmHead: {en: 'PROGRAM ALARM', fr: 'ALARME PROGRAMME', es: 'ALARMA DE PROGRAMA'}, + 'alarm:1202': {en: 'EXECUTIVE OVERFLOW · NO CORE SETS', fr: 'DÉBORDEMENT DE L’EXECUTIVE · PLUS DE CORE SET', es: 'DESBORDAMIENTO DEL EXECUTIVE · SIN CORE SETS'}, + 'alarm:1201': {en: 'EXECUTIVE OVERFLOW · NO VAC AREAS', fr: 'DÉBORDEMENT DE L’EXECUTIVE · PLUS DE ZONE VAC', es: 'DESBORDAMIENTO DEL EXECUTIVE · SIN ZONAS VAC'}, + restartKept: {en: (k) => `Restart. Kept: ${k}, the newest pass, from its restart point.`, fr: (k) => `Redémarrage. Gardé : ${k}, le passage le plus récent, depuis son point de reprise.`, es: (k) => `Reinicio. Se conserva: ${k}, la pasada más reciente, desde su punto de reanudación.`}, + restartDropped: {en: (d) => `Dropped: ${d}. The landing goes on.`, fr: (d) => `Abandonné : ${d}. L’atterrissage continue.`, es: (d) => `Descartado: ${d}. El alunizaje continúa.`}, + execWait: { + en: 'The Waitlist starts SERVICER every 2 s; a Verb 16 monitor every second. Each job holds a core set until it ends.', + fr: 'La Waitlist lance SERVICER toutes les 2 s ; un moniteur Verb 16 chaque seconde. Chaque tâche garde son core set jusqu’à la fin.', + es: 'La Waitlist lanza SERVICER cada 2 s; un monitor Verb 16 cada segundo. Cada tarea retiene su core set hasta terminar.', + }, + // ------------------------------------------------------------ the detail panel + 'det:rope': {en: 'FIXED MEMORY · ONE WORD', fr: 'MÉMOIRE FIXE · UN MOT', es: 'MEMORIA FIJA · UNA PALABRA'}, + 'det:eras': {en: 'ERASABLE MEMORY · ONE WORD', fr: 'MÉMOIRE EFFAÇABLE · UN MOT', es: 'MEMORIA BORRABLE · UNA PALABRA'}, + 'det:nor': {en: 'LOGIC · ONE NOR GATE', fr: 'LOGIQUE · UNE PORTE NOR', es: 'LÓGICA · UNA PUERTA NOR'}, + 'det:clock': {en: 'CLOCK AND SEQUENCER', fr: 'HORLOGE ET SÉQUENCEUR', es: 'RELOJ Y SECUENCIADOR'}, + 'det:iface': {en: 'INTERFACES · COUNTERS', fr: 'INTERFACES · COMPTEURS', es: 'INTERFACES · CONTADORES'}, + 'det:dsky': {en: 'DSKY · VERB AND NOUN', fr: 'DSKY · VERBE ET NOM', es: 'DSKY · VERBO Y NOMBRE'}, + 'det:exec': {en: 'EXECUTIVE · THE JOBS', fr: 'EXECUTIVE · LES TÂCHES', es: 'EXECUTIVE · LAS TAREAS'}, + ropeAddr: { + en: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · CORE ${c} · STRAND ${st}`, + fr: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · TORE ${c} · BRIN ${st}`, + es: (b, a, m, c, st) => `${String(b).padStart(2, '0')},${a} · B${m} · NÚCLEO ${c} · HEBRA ${st}`, + }, + ropeOctal: {en: 'THE WORD, IN OCTAL (LUMINARY 099)', fr: 'LE MOT, EN OCTAL (LUMINARY 099)', es: 'LA PALABRA, EN OCTAL (LUMINARY 099)'}, + ropeParity: {en: (p) => `ODD PARITY BIT ${p}`, fr: (p) => `BIT DE PARITÉ IMPAIRE ${p}`, es: (p) => `BIT DE PARIDAD IMPAR ${p}`}, + ropePhase: {en: 'INSIDE ONE MEMORY CYCLE, 11.7 µS', fr: 'DANS UN CYCLE MÉMOIRE, 11,7 µS', es: 'DENTRO DE UN CICLO DE MEMORIA, 11,7 µS'}, + 'ph:inhibit': {en: 'INHIBIT', fr: 'INHIBITION', es: 'INHIBICIÓN'}, + 'ph:set': {en: 'SET', fr: 'SET', es: 'SET'}, + 'ph:reset': {en: 'RESET', fr: 'RESET', es: 'RESET'}, + 'ph:strobe': {en: 'STROBE', fr: 'LECTURE', es: 'LECTURA'}, + ropeNote: { + en: (w, m, c, l) => `${w} words in ${m} modules of ${c} cores. ${l} wires pass through or around each core: through is a 1, around is a 0.`, + fr: (w, m, c, l) => `${w} mots dans ${m} modules de ${c} tores. ${l} fils traversent ou contournent chaque tore : dedans, un 1 ; autour, un 0.`, + es: (w, m, c, l) => `${w} palabras en ${m} módulos de ${c} núcleos. ${l} hilos pasan por dentro o por fuera de cada núcleo: dentro, un 1; fuera, un 0.`, + }, + erasAddr: {en: (k) => `PRIORITY OF CORE SET ${k} · READ BY CCS PRIORITY`, fr: (k) => `PRIORITÉ DU CORE SET ${k} · LUE PAR CCS PRIORITY`, es: (k) => `PRIORIDAD DEL CORE SET ${k} · LEÍDA POR CCS PRIORITY`}, + erasJob: {en: (j) => `${j} · BUSY`, fr: (j) => `${j} · OCCUPÉ`, es: (j) => `${j} · OCUPADO`}, + erasFree: {en: '−0 · FREE', fr: '−0 · LIBRE', es: '−0 · LIBRE'}, + erasPlane: {en: 'THE PLANE SHOWN HOLDS BIT 14 OF EVERY WORD', fr: 'LE PLAN MONTRÉ PORTE LE BIT 14 DE CHAQUE MOT', es: 'EL PLANO MOSTRADO GUARDA EL BIT 14 DE CADA PALABRA'}, + 'es:read': {en: 'X + Y READ', fr: 'LECTURE X + Y', es: 'LECTURA X + Y'}, + 'es:sense': {en: 'SENSE', fr: 'DÉTECTION', es: 'DETECCIÓN'}, + 'es:rewrite': {en: 'REWRITE', fr: 'RÉÉCRITURE', es: 'REESCRITURA'}, + erasNote: { + en: (w, b) => `${w} words, ${b} cores. Reading switches the core to 0: a 1 shows as a pulse on the sense line. The same cycle writes it back.`, + fr: (w, b) => `${w} mots, ${b} tores. Lire fait basculer le tore à 0 : un 1 se voit comme une impulsion sur le fil de lecture. Le même cycle le réécrit.`, + es: (w, b) => `${w} palabras, ${b} núcleos. Leer pone el núcleo a 0: un 1 aparece como un pulso en el hilo de lectura. El mismo ciclo lo reescribe.`, + }, + norHead: {en: 'RESISTOR-TRANSISTOR LOGIC, 3 INPUTS', fr: 'LOGIQUE RÉSISTANCE-TRANSISTOR, 3 ENTRÉES', es: 'LÓGICA RESISTENCIA-TRANSISTOR, 3 ENTRADAS'}, + norNote: { + en: (n, g) => `About ${n} flat-packs, two NOR gates in each: ${g} gates of one kind make the whole processor.`, + fr: (n, g) => `Environ ${n} boîtiers plats, deux portes NOR dans chacun : ${g} portes d’un seul type font tout le processeur.`, + es: (n, g) => `Unos ${n} encapsulados planos, dos puertas NOR en cada uno: ${g} puertas de un solo tipo hacen todo el procesador.`, + }, + clockHead: {en: (f, m) => `${f} MHZ · 12 PULSES = 1 MCT = ${m} µS`, fr: (f, m) => `${f} MHZ · 12 IMPULSIONS = 1 MCT = ${m} µS`, es: (f, m) => `${f} MHZ · 12 PULSOS = 1 MCT = ${m} µS`}, + clockPulse: {en: 'THE TIMING PULSE NOW', fr: 'L’IMPULSION EN COURS', es: 'EL PULSO ACTUAL'}, + clockNote: { + en: (n, a, us) => `${n} memory cycles a second. An addition takes 2 MCT (${us} µS): ${a} a second; one-MCT instructions run twice as many.`, + fr: (n, a, us) => `${n} cycles mémoire par seconde. Une addition prend 2 MCT (${us} µS) : ${a} par seconde ; les instructions d’un MCT en font deux fois plus.`, + es: (n, a, us) => `${n} ciclos de memoria por segundo. Una suma tarda 2 MCT (${us} µS): ${a} por segundo; las instrucciones de un MCT, el doble.`, + }, + ifHead: {en: 'CYCLES STOLEN PER SECOND', fr: 'CYCLES VOLÉS PAR SECONDE', es: 'CICLOS ROBADOS POR SEGUNDO'}, + 'if:rr': {en: 'RENDEZVOUS RADAR CDU', fr: 'CDU DU RADAR DE RENDEZ-VOUS', es: 'CDU DEL RADAR DE ENCUENTRO'}, + 'if:imu': {en: 'IMU CDU (STEADY ATTITUDE)', fr: 'CDU DE L’IMU (ATTITUDE STABLE)', es: 'CDU DE LA IMU (ACTITUD ESTABLE)'}, + 'if:lr': {en: 'LANDING RADAR', fr: 'RADAR D’ATTERRISSAGE', es: 'RADAR DE ALUNIZAJE'}, + 'if:lrOn': {en: 'DATA GOOD', fr: 'DONNÉES BONNES', es: 'DATOS BUENOS'}, + ifTloss: {en: 'TIME LOST TO THE RADAR', fr: 'TEMPS PERDU AU RADAR', es: 'TIEMPO PERDIDO POR EL RADAR'}, + ifNote: { + en: (p, m, th) => `Two angles × ${p} pulses/s at most, one MCT each: ${th} % in theory, ${m} % measured after the flight. The model uses 13 %.`, + fr: (p, m, th) => `Deux angles × ${p} impulsions/s au plus, un MCT chacune : ${th} % en théorie, ${m} % mesurés après le vol. Le modèle prend 13 %.`, + es: (p, m, th) => `Dos ángulos × ${p} pulsos/s como máximo, un MCT cada uno: ${th} % en teoría, ${m} % medido tras el vuelo. El modelo usa 13 %.`, + }, + dskyHead: {en: 'VERB = WHAT TO DO · NOUN = ON WHAT', fr: 'VERBE = QUOI FAIRE · NOM = SUR QUOI', es: 'VERBO = QUÉ HACER · NOMBRE = SOBRE QUÉ'}, + dskyKeysNote: { + en: 'Click the keys. VERB 1 6 NOUN 6 8 ENTR: the Δh monitor that costs 3 % (it can bring the 1202 back). VERB 0 6 NOUN 6 3 ENTR: speed and altitude, once. VERB 0 5 NOUN 0 9 ENTR: the alarm codes. VERB 1 6 NOUN 3 6 ENTR: the clock. RSET turns the lamps off; KEY REL hands the display back to the program.', + fr: 'Cliquez les touches. VERB 1 6 NOUN 6 8 ENTR : le moniteur de Δh qui coûte 3 % (il peut faire revenir le 1202). VERB 0 6 NOUN 6 3 ENTR : vitesse et altitude, une fois. VERB 0 5 NOUN 0 9 ENTR : les codes d’alarme. VERB 1 6 NOUN 3 6 ENTR : l’horloge. RSET éteint les voyants ; KEY REL rend l’affichage au programme.', + es: 'Haz clic en las teclas. VERB 1 6 NOUN 6 8 ENTR: el monitor de Δh que cuesta un 3 % (puede traer de vuelta el 1202). VERB 0 6 NOUN 6 3 ENTR: velocidad y altitud, una vez. VERB 0 5 NOUN 0 9 ENTR: los códigos de alarma. VERB 1 6 NOUN 3 6 ENTR: el reloj. RSET apaga las luces; KEY REL devuelve la pantalla al programa.', + }, + exHead: {en: 'PRIORITY DECIDES WHO RUNS', fr: 'LA PRIORITÉ DÉCIDE QUI TOURNE', es: 'LA PRIORIDAD DECIDE QUIÉN CORRE'}, + exNote: { + en: (c, v, b, lr, n, rr) => `${c} core sets, ${v} VAC areas. Braking guidance asks ${b} %, the landing radar's data ${lr} % more, the V16 N68 monitor ${n} %; the radar steals ${rr} %. Past 100 %, the lowest-priority jobs never finish and keep their core sets: when a job finds none free, 1202.`, + fr: (c, v, b, lr, n, rr) => `${c} core sets, ${v} zones VAC. Le guidage du freinage demande ${b} %, les données du radar d’atterrissage ${lr} % de plus, le moniteur V16 N68 ${n} % ; le radar vole ${rr} %. Au-delà de 100 %, les tâches les moins prioritaires ne finissent jamais et gardent leur core set : quand une tâche n’en trouve plus, 1202.`, + es: (c, v, b, lr, n, rr) => `${c} core sets, ${v} zonas VAC. El guiado de frenado pide ${b} %, los datos del radar de alunizaje ${lr} % más, el monitor V16 N68 ${n} %; el radar roba ${rr} %. Por encima del 100 %, las tareas de menor prioridad nunca terminan y retienen su core set: cuando una tarea no encuentra ninguno libre, 1202.`, + }, + // ------------------------------------------------------------ buttons + grpSubs: {en: 'SUBSYSTEMS', fr: 'SOUS-SYSTÈMES', es: 'SUBSISTEMAS'}, + 'sub:rope': {en: 'ROPE', fr: 'ROPE', es: 'ROPE'}, + 'sub:eras': {en: 'ERASABLE', fr: 'EFFAÇABLE', es: 'BORRABLE'}, + 'sub:nor': {en: 'NOR LOGIC', fr: 'LOGIQUE NOR', es: 'LÓGICA NOR'}, + 'sub:clock': {en: 'CLOCK', fr: 'HORLOGE', es: 'RELOJ'}, + 'sub:exec': {en: 'EXECUTIVE', fr: 'EXECUTIVE', es: 'EXECUTIVE'}, + 'sub:dsky': {en: 'DSKY', fr: 'DSKY', es: 'DSKY'}, + 'sub:iface': {en: 'INTERFACES', fr: 'INTERFACES', es: 'INTERFACES'}, + grpCuts: {en: 'SECTIONS', fr: 'COUPES', es: 'CORTES'}, + 'cut:case': {en: 'CASE', fr: 'BOÎTIER', es: 'CAJA'}, + 'cut:tray': {en: 'TRAYS', fr: 'PLATEAUX', es: 'BANDEJAS'}, + 'cut:module': {en: 'MODULE', fr: 'MODULE', es: 'MÓDULO'}, + grpViews: {en: 'VIEWS', fr: 'VUES', es: 'VISTAS'}, + 'view:axo': {en: 'AXONOMETRIC', fr: 'AXONOMÉTRIE', es: 'AXONOMETRÍA'}, + 'view:dskyFace': {en: 'DSKY FACE', fr: 'FACE DSKY', es: 'CARA DSKY'}, + 'view:ropeIn': {en: 'INSIDE ROPE', fr: 'DANS LA ROPE', es: 'DENTRO DE LA ROPE'}, + 'view:graze': {en: 'GRAZING', fr: 'RASANTE', es: 'RASANTE'}, + // ------------------------------------------------------------ title block + title: {en: 'ROPE', fr: 'ROPE', es: 'ROPE'}, + titleSub: {en: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY', fr: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY', es: 'APOLLO GUIDANCE COMPUTER BLOCK II · DSKY'}, + titleMission: {en: 'LM · LUMINARY 099 · APOLLO 11 DESCENT', fr: 'LM · LUMINARY 099 · DESCENTE D’APOLLO 11', es: 'LM · LUMINARY 099 · DESCENSO DEL APOLO 11'}, + titleSources: {en: 'AFTER MIT IL / NASA PUBLIC SOURCES', fr: 'D’APRÈS LES SOURCES PUBLIQUES MIT IL / NASA', es: 'SEGÚN FUENTES PÚBLICAS MIT IL / NASA'}, + titleNot: {en: 'A MODEL · NOT AFFILIATED WITH NASA OR MIT', fr: 'UN MODÈLE · SANS LIEN AVEC LA NASA NI LE MIT', es: 'UN MODELO · SIN RELACIÓN CON LA NASA NI EL MIT'}, + titleSheet: {en: 'SHEET 1 OF 1', fr: 'FEUILLE 1 SUR 1', es: 'HOJA 1 DE 1'}, + titleFigs: {en: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`, fr: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`, es: (m, p) => `${m} KG · ${p} W
61 × 32 × 17 CM`}, + // ------------------------------------------------------------ callouts [name, sub] + cAgc: {en: ['Apollo Guidance Computer', 'BLOCK II · 2 TRAYS · 32 KG · 55 W'], fr: ['Apollo Guidance Computer', 'BLOCK II · 2 PLATEAUX · 32 KG · 55 W'], es: ['Apollo Guidance Computer', 'BLOCK II · 2 BANDEJAS · 32 KG · 55 W']}, + cDsky: {en: ['DSKY', 'DISPLAY AND KEYBOARD'], fr: ['DSKY', 'AFFICHEUR ET CLAVIER'], es: ['DSKY', 'PANTALLA Y TECLADO']}, + cTrayA: {en: ['Tray A · logic', '24 LOGIC + 7 INTERFACE MODULES'], fr: ['Plateau A · logique', '24 MODULES LOGIQUES + 7 D’INTERFACE'], es: ['Bandeja A · lógica', '24 MÓDULOS LÓGICOS + 7 DE INTERFAZ']}, + cTrayB: {en: ['Tray B · memory', 'ROPE, ERASABLE, DRIVERS'], fr: ['Plateau B · mémoire', 'ROPE, EFFAÇABLE, PILOTES'], es: ['Bandeja B · memoria', 'ROPE, BORRABLE, CONTROLADORES']}, + cConn: {en: ['Interface connector', 'RADAR PULSES COME IN HERE'], fr: ['Connecteur d’interface', 'LES IMPULSIONS DU RADAR ARRIVENT ICI'], es: ['Conector de interfaz', 'AQUÍ LLEGAN LOS PULSOS DEL RADAR']}, + cRopeMod: {en: ['Rope module B1', '6,144 WORDS · 512 CORES'], fr: ['Module de rope B1', '6 144 MOTS · 512 TORES'], es: ['Módulo de rope B1', '6144 PALABRAS · 512 NÚCLEOS']}, + cCore: {en: ['The selected core', 'INHIBIT LINES HOLD ALL THE OTHERS'], fr: ['Le tore choisi', 'LES FILS D’INHIBITION BLOQUENT TOUS LES AUTRES'], es: ['El núcleo elegido', 'LOS HILOS DE INHIBICIÓN BLOQUEAN LOS DEMÁS']}, + cSense: {en: ['Sense lines', 'THROUGH = 1 · AROUND = 0'], fr: ['Fils de lecture', 'DEDANS = 1 · AUTOUR = 0'], es: ['Hilos de lectura', 'DENTRO = 1 · FUERA = 0']}, + cInhibit: {en: ['Inhibit lines', 'THE ADDRESS, DECODED'], fr: ['Fils d’inhibition', 'L’ADRESSE, DÉCODÉE'], es: ['Hilos de inhibición', 'LA DIRECCIÓN, DECODIFICADA']}, + cErasMod: {en: ['Erasable memory B12', '2,048 WORDS · FERRITE CORES'], fr: ['Mémoire effaçable B12', '2 048 MOTS · TORES DE FERRITE'], es: ['Memoria borrable B12', '2048 PALABRAS · NÚCLEOS DE FERRITA']}, + cErasCore: {en: ['One core, one bit', 'X AND Y HALF-CURRENTS SWITCH IT'], fr: ['Un tore, un bit', 'LES DEMI-COURANTS X ET Y LE BASCULENT'], es: ['Un núcleo, un bit', 'LAS SEMICORRIENTES X E Y LO CONMUTAN']}, + cErasSense: {en: ['Sense line', 'A 1 FLIPS AND SHOWS'], fr: ['Fil de lecture', 'UN 1 BASCULE ET SE VOIT'], es: ['Hilo de lectura', 'UN 1 CAMBIA Y SE VE']}, + cEl: {en: ['Electroluminescent digits', 'REDRAWN EVERY SECOND BY A JOB'], fr: ['Chiffres électroluminescents', 'REDESSINÉS CHAQUE SECONDE PAR UNE TÂCHE'], es: ['Dígitos electroluminiscentes', 'REDIBUJADOS CADA SEGUNDO POR UNA TAREA']}, + cKeys: {en: ['19 keys', 'VERB · NOUN · ENTR'], fr: ['19 touches', 'VERB · NOUN · ENTR'], es: ['19 teclas', 'VERB · NOUN · ENTR']}, + cLogic: {en: ['Logic module A13', '120 FLAT-PACKS, WELDED'], fr: ['Module logique A13', '120 BOÎTIERS PLATS, SOUDÉS'], es: ['Módulo lógico A13', '120 ENCAPSULADOS PLANOS, SOLDADOS']}, + cGate: {en: ['Dual 3-input NOR', 'THE ONLY GATE IN THE MACHINE'], fr: ['Double NOR à 3 entrées', 'LA SEULE PORTE DE LA MACHINE'], es: ['Doble NOR de 3 entradas', 'LA ÚNICA PUERTA DE LA MÁQUINA']}, + // ------------------------------------------------------------ captions + 'cap:hero': {en: 'Apollo 11’s lunar module, five minutes into powered descent. Aldrin has called up Verb 16 Noun 68.', fr: 'Le module lunaire d’Apollo 11, cinq minutes après le début de la descente. Aldrin a demandé Verb 16 Noun 68.', es: 'El módulo lunar del Apolo 11, cinco minutos después de iniciar el descenso. Aldrin ha pedido Verb 16 Noun 68.'}, + 'cap:open': {en: 'Two magnesium trays: tray A is logic, tray B is memory.', fr: 'Deux plateaux en magnésium : le plateau A, la logique ; le plateau B, la mémoire.', es: 'Dos bandejas de magnesio: la bandeja A, la lógica; la bandeja B, la memoria.'}, + 'cap:rope': {en: 'The program lives in six rope modules, woven by hand. Module B1 comes out.', fr: 'Le programme vit dans six modules de rope, tissés à la main. Le module B1 sort.', es: 'El programa vive en seis módulos de rope, tejidos a mano. Sale el módulo B1.'}, + 'cap:rope2': {en: 'One word, CAF NO.CORES: a wire through the core reads 1, a wire around it reads 0.', fr: 'Un mot, CAF NO.CORES : un fil qui traverse le tore lit 1, un fil qui le contourne lit 0.', es: 'Una palabra, CAF NO.CORES: un hilo que atraviesa el núcleo lee 1, uno que lo rodea lee 0.'}, + 'cap:eras': {en: 'Erasable memory: reading a core erases it, so the same cycle writes it back.', fr: 'Mémoire effaçable : lire un tore l’efface, alors le même cycle le réécrit.', es: 'Memoria borrable: leer un núcleo lo borra, así que el mismo ciclo lo reescribe.'}, + 'cap:dsky': {en: 'Back on the DSKY: every digit comes from the descent the computer is flying.', fr: 'Retour au DSKY : chaque chiffre vient de la descente que le calculateur pilote.', es: 'De vuelta al DSKY: cada dígito sale del descenso que el ordenador pilota.'}, + 'cap:exec': {en: 'The rendezvous radar steals 13 % of the cycles. Low-priority jobs never finish, and keep their core sets.', fr: 'Le radar de rendez-vous vole 13 % des cycles. Les tâches peu prioritaires ne finissent pas et gardent leur core set.', es: 'El radar de encuentro roba el 13 % de los ciclos. Las tareas de baja prioridad no terminan y retienen su core set.'}, + 'cap:alarm': {en: '1202: a job asked for a core set and found all eight taken.', fr: '1202 : une tâche a demandé un core set et les a trouvés tous les huit pris.', es: '1202: una tarea pidió un core set y encontró los ocho ocupados.'}, + 'cap:restart': {en: 'Restart: the newest guidance pass is kept, the rest is dropped. The landing goes on.', fr: 'Redémarrage : le dernier passage du guidage est gardé, le reste abandonné. L’atterrissage continue.', es: 'Reinicio: se conserva la última pasada del guiado, se descarta el resto. El alunizaje continúa.'}, + 'cap:ff': {en: 'Fast forward: a second 1202, then high gate.', fr: 'Accéléré : un second 1202, puis le high gate.', es: 'Avance rápido: un segundo 1202, luego el high gate.'}, + 'cap:p64': {en: 'P64, the approach: more alarms, each one a restart that keeps the landing.', fr: 'P64, l’approche : d’autres alarmes, chacune un redémarrage qui garde l’atterrissage.', es: 'P64, la aproximación: más alarmas, cada una un reinicio que mantiene el alunizaje.'}, + 'cap:contact': {en: 'Contact light, 102:45:40.', fr: 'Contact light, 102:45:40.', es: 'Contact light, 102:45:40.'}, + // ------------------------------------------------------------ the live page + 'cap:live:axo': {en: 'Drag to turn, wheel to zoom. Click a subsystem to open it.', fr: 'Glissez pour tourner, molette pour zoomer. Cliquez un sous-système pour l’ouvrir.', es: 'Arrastra para girar, rueda para el zoom. Haz clic en un subsistema para abrirlo.'}, + 'cap:live:rope': {en: 'NOVAC looks for a free core set; each of its words comes out of the rope, one memory cycle at a time.', fr: 'NOVAC cherche un core set libre ; chacun de ses mots sort de la rope, un cycle mémoire à la fois.', es: 'NOVAC busca un core set libre; cada una de sus palabras sale de la rope, un ciclo de memoria a la vez.'}, + 'cap:live:eras': {en: 'CCS PRIORITY reads a core set’s priority word: a destructive read, then the rewrite.', fr: 'CCS PRIORITY lit le mot de priorité d’un core set : lecture destructive, puis réécriture.', es: 'CCS PRIORITY lee la palabra de prioridad de un core set: lectura destructiva y reescritura.'}, + 'cap:live:nor': {en: 'Click A, B or C: the output is 1 only when all three inputs are 0.', fr: 'Cliquez A, B ou C : la sortie vaut 1 seulement quand les trois entrées sont à 0.', es: 'Haz clic en A, B o C: la salida vale 1 solo cuando las tres entradas son 0.'}, + 'cap:live:clock': {en: 'Twelve timing pulses make one memory cycle; everything in the machine is counted in them.', fr: 'Douze impulsions font un cycle mémoire ; tout, dans la machine, se compte en cycles.', es: 'Doce pulsos forman un ciclo de memoria; todo en la máquina se cuenta en ciclos.'}, + 'cap:live:exec': {en: 'Switch the radar off and the 1202 never comes. Key V16 N68 and it comes back.', fr: 'Coupez le radar et le 1202 ne vient jamais. Tapez V16 N68 et il revient.', es: 'Apaga el radar y el 1202 no llega nunca. Teclea V16 N68 y vuelve.'}, + 'cap:live:dsky': {en: 'The DSKY is the crew’s only way in: a verb, a noun, ENTR.', fr: 'Le DSKY est le seul accès de l’équipage : un verbe, un nom, ENTR.', es: 'El DSKY es la única entrada de la tripulación: un verbo, un nombre, ENTR.'}, + 'cap:live:iface': {en: 'Every radar pulse is a counter increment, and every increment steals one memory cycle.', fr: 'Chaque impulsion du radar est un incrément de compteur, et chaque incrément vole un cycle mémoire.', es: 'Cada pulso del radar es un incremento de contador, y cada incremento roba un ciclo de memoria.'}, + radarOn: {en: 'RADAR STEALS: ON (AS FLOWN)', fr: 'VOL DU RADAR : OUI (COMME EN VOL)', es: 'ROBO DEL RADAR: SÍ (COMO EN VUELO)'}, + radarOff: {en: 'RADAR STEALS: OFF', fr: 'VOL DU RADAR : NON', es: 'ROBO DEL RADAR: NO'}, + speed: {en: 'MISSION SPEED', fr: 'VITESSE DE MISSION', es: 'VELOCIDAD DE MISIÓN'}, + restartMission: {en: '↺ PDI + 5 MIN', fr: '↺ PDI + 5 MIN', es: '↺ PDI + 5 MIN'}, + busy: {en: 'REPLANNING THE EXECUTIVE…', fr: 'REPLANIFICATION DE L’EXECUTIVE…', es: 'REPLANIFICANDO EL EXECUTIVE…'}, + 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 console', fr: 'Tournez le téléphone pour la console complète', es: 'Gira el teléfono para ver la consola completa'}, + docTitle: {en: 'Rope · the Apollo Guidance Computer, taken apart', fr: 'Rope · l’ordinateur de bord d’Apollo, démonté', es: 'Rope · el ordenador de guiado del Apolo, desmontado'}, + footCredit: { + en: 'A model of the AGC after public sources (MIT Instrumentation Lab, NASA), not affiliated with NASA or MIT', + fr: 'Un modèle de l’AGC d’après les sources publiques (MIT Instrumentation Lab, NASA), sans lien avec la NASA ni le MIT', + es: 'Un modelo del AGC según fuentes públicas (MIT Instrumentation Lab, NASA), sin relación con la NASA ni el MIT', + }, +}; + +/** the string for key in the page's language; extra arguments fill a templated one */ +export const T = makeT(S); diff --git a/machines/rope/agc-shared/live.js b/machines/rope/agc-shared/live.js new file mode 100644 index 0000000..be292fa --- /dev/null +++ b/machines/rope/agc-shared/live.js @@ -0,0 +1,569 @@ +// Rope as a page you drive: the film's machine and overlay, in real time. Seven subsystems (rope, erasable, NOR logic, +// clock, Executive, DSKY, interfaces), three sections (case, trays, module), four views, an orbit you drag and a zoom +// on the wheel, the mission speed from one memory cycle in four seconds to sixty times real time. The DSKY's 19 keys +// work: a verb and a noun go into the Executive's log, which is planned again from that moment, so Verb 16 Noun 68 +// can bring the 1202 back; switching the radar's cycle steals off makes it go away. Left alone, the page plays the +// film's script as an auto tour with the operator's cursor; a click, drag, wheel or key hands control over, 25 s of +// quiet hands it back. +import * as THREE from 'three'; +import {warmComposer} from 'teardown-kit/warm.js'; +import {CUTS, SUBS, VIEWS, createConsole} from './console.js'; +import {loadFonts} from './fonts.js'; +import {CREW_KEYS, EVENTS, GET0, MCT, ROPE_WORDS, bits16, ropePlace} from './machine.js'; +import {jobsAt, simulate} from './executive.js'; +import {DURATION, LOG, SCRIPT, actsAt, camAtName, missionAt, rateAt, ERAS_JOB, ERAS_MCT, ROPE_MCT} from './schedule.js'; +import {L} from './agc.js'; +import {VIEW_FN, calloutsFor, camAt, captionAt, detailFor, dskyText, machineState, makeCursor, prioWord, rateText, smoother, ss} from './film.js'; +import {createStage} from './stage.js'; +import {createChrome} from '../../../shared/chrome.js'; +import {LANG, 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 RATES = [{k: 'mct', r: MCT / 4, label: '1 MCT / 4 S'}, {k: '1', r: 1, label: '×1'}, {k: '10', r: 10, label: '×10'}, {k: '60', r: 60, label: '×60'}]; +const VIEW_OF_SUB = {rope: 'rope', eras: 'eras', nor: 'logic', clock: 'open', exec: 'exec', dsky: 'dsky', iface: 'exec'}; + +const CSS = (top) => ` +html,body{margin:0;background:#efe8d8;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{filter:brightness(.96)} +.hit{position:absolute;pointer-events:auto;cursor:pointer} +.tourchip{position:absolute;left:800px;top:${92 + top}px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;height:26px;padding:0 13px;border:1px solid rgba(70,58,40,.55);background:rgba(241,235,221,.95);color:#2b2925;font:600 13px 'Barlow Condensed',sans-serif;letter-spacing:1.8px;white-space:nowrap;cursor:pointer;pointer-events:auto} +.tourchip i{display:block;width:7px;height:7px;border-radius:50%;background:#1f8a4c} +.tourchip.on i{animation:blink 1.2s steps(2) infinite} +@keyframes blink{50%{opacity:.15}} +.turn{position:fixed;left:0;right:0;bottom:40px;text-align:center;font:600 14px/1.4 'Barlow Condensed',sans-serif;letter-spacing:1.5px;color:#7a7062;text-transform:uppercase;display:none} +@media (orientation:portrait) and (max-width:820px){.turn{display:block}} +.busy{position:absolute;left:800px;top:440px;transform:translateX(-50%);padding:8px 14px;background:rgba(241,235,221,.96);border:1px solid rgba(70,58,40,.55);font:600 14px 'Barlow Condensed',sans-serif;letter-spacing:1.6px;color:#2b2925;display:none} +#app .cap{top:732px} +`; + +export async function runLive() { + const style = document.createElement('style'); + style.textContent = CSS(0); + document.head.appendChild(style); + document.title = tr('docTitle'); + document.documentElement.lang = LANG; + await loadFonts(); + 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, shadow: 4096, pr: 2}, + {samples: 4, shadow: 2048, pr: 1.5}, + {samples: 0, shadow: 1024, 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: 'rope', name: 'Rope', 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 toStage = (cx, cy) => { + const r = app.getBoundingClientRect(); + return [(cx - r.left) / scale, (cy - r.top) / scale]; + }; + const tourChip = document.createElement('div'); + tourChip.className = 'tourchip'; + ui.root.appendChild(tourChip); + const busy = document.createElement('div'); + busy.className = 'busy'; + busy.textContent = tr('busy'); + ui.root.appendChild(busy); + const note = document.createElement('div'); + note.className = 'turn'; + note.textContent = tr('turnPhone'); + document.body.appendChild(note); + + // live controls: mission speed, the radar, back to PDI + 5 min + const ctl = {}; + const mk = (key, x, w, html) => { + const n = document.createElement('div'); + n.className = 'tb'; + Object.assign(n.style, {left: `${x}px`, top: '788px', width: `${w}px`, height: '26px', fontSize: '13px'}); + n.innerHTML = html; + ui.root.appendChild(n); + ctl[key] = n; + return n; + }; + { + const lab = document.createElement('div'); + lab.className = 'gl'; + Object.assign(lab.style, {left: '372px', top: '770px'}); + lab.textContent = tr('speed'); + ui.root.appendChild(lab); + } + RATES.forEach((r, i) => mk('rate' + i, 372 + i * 92, 86, r.label)); + mk('radar', 750, 250, tr('radarOn')); + mk('reset', 1010, 208, tr('restartMission')); + + // ------------------------------------------------------------ the mission: the log, re-planned on the visitor's keys + let radar = true; + let userKeys = []; + let cutKeys = Infinity; // the crew's own keys stop where the visitor took over the keyboard + let log = LOG; + const replan = () => { + busy.style.display = 'block'; + requestAnimationFrame(() => + setTimeout(() => { + log = simulate({keys: [...CREW_KEYS.filter((k) => k[0] < cutKeys), ...userKeys], radar}); + busy.style.display = 'none'; + }, 0), + ); + }; + let s = 306.4; + let rateI = 1; + let T = 0; + + // ------------------------------------------------------------ state + let paused = false; + let view = 'hero'; + let viewAt = 0; + let sub = ''; + let cut = ''; + const off = {az: 0, el: 0, k: 1}; + const cam = {...VIEW_FN.hero(0)}; + const mech = {open: 0, rope: 0, ropeCover: 0, eras: 0, logic: 0, gate: 0}; + let ring = {t: -9, x: 0, y: 0}; + let hidden = false; + let norIn = [0, 0, 0]; + const press = {}; + const pulse = (x, y) => (ring = {t: T, x, y}); + const pulseBtn = (k) => pulse(...(ui.spots[k] || [800, 450])); + + // ------------------------------------------------------------ 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}); + if (log !== LOG || radar !== true) { + radar = true; + userKeys = []; + cutKeys = Infinity; + log = LOG; + } + }; + const takeOver = () => { + lastInput = T; + if (!AP.on) return; + AP.on = false; + const cyc = (T - AP.t0) % DURATION; + s = missionAt(cyc); + const name = camAtName(cyc); + view = name; + viewAt = T - (cyc - SCRIPT.filter((e) => e.cam && e.t <= cyc).pop().t); + sub = {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[name] || ''; + const a = actsAt(cyc); + Object.assign(mech, {open: a.open, rope: a.rope, ropeCover: a.ropeCover, eras: a.eras, logic: 0, gate: 0}); + cut = a.open > 0.5 ? 'tray' : ''; + rateI = rateAt(cyc) < 0.01 ? 0 : 1; + }; + tourChip.addEventListener('pointerdown', (e) => { + e.stopPropagation(); + lastInput = T; + if (AP.on) takeOver(); + else startTour(); + }); + addEventListener('pointerdown', (e) => { + if (e.target !== tourChip) takeOver(); + }); + + // ------------------------------------------------------------ controls + const setView = (v) => { + view = v; + viewAt = T; + Object.assign(off, {az: 0, el: 0, k: 1}); + }; + for (const k of SUBS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + sub = sub === k && k !== 'dsky' ? '' : k; + if (sub) { + if (['rope', 'eras', 'nor', 'clock', 'exec', 'iface'].includes(sub)) cut = sub === 'exec' || sub === 'clock' || sub === 'iface' ? 'tray' : 'module'; + setView(VIEW_OF_SUB[sub]); + rateI = sub === 'rope' || sub === 'eras' ? 0 : rateI === 0 ? 1 : rateI; + } else setView(mech.open > 0.5 ? 'open' : 'hero'); + }); + for (const k of CUTS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + cut = cut === k ? '' : k; + if (cut === 'case') setView('hero'); + else if (cut === 'tray') setView(sub === 'nor' || sub === 'rope' || sub === 'eras' ? 'open' : view === 'hero' ? 'open' : view); + else if (cut === 'module') { + if (!['rope', 'eras', 'nor'].includes(sub)) sub = 'rope'; + setView(VIEW_OF_SUB[sub]); + } else setView('hero'); + }); + for (const k of VIEWS) + ui.btn[k].addEventListener('click', () => { + takeOver(); + pulseBtn(k); + if (k === 'axo') setView(mech.open > 0.5 || cut === 'tray' || cut === 'module' ? 'open' : 'hero'); + else if (k === 'dskyFace') { + sub = 'dsky'; + setView('dsky'); + } else if (k === 'ropeIn') { + sub = 'rope'; + cut = 'module'; + rateI = 0; + setView('rope'); + } else if (k === 'graze') { + if (!cut || cut === 'case') cut = 'tray'; + setView('graze'); + } + }); + RATES.forEach((r, i) => + ctl['rate' + i].addEventListener('click', () => { + takeOver(); + rateI = i; + pulse(...centreOf(ctl['rate' + i])); + }), + ); + ctl.radar.addEventListener('click', () => { + takeOver(); + radar = !radar; + pulse(...centreOf(ctl.radar)); + replan(); + }); + ctl.reset.addEventListener('click', () => { + takeOver(); + s = 300; + userKeys = []; + cutKeys = Infinity; + log = LOG; + if (!radar) replan(); + pulse(...centreOf(ctl.reset)); + }); + const centreOf = (n) => [parseFloat(n.style.left) + parseFloat(n.style.width) / 2, parseFloat(n.style.top) + 13]; + // NOR inputs: three hit areas over the schematic's A, B, C + for (let i = 0; i < 3; i++) { + const d = document.createElement('div'); + d.className = 'hit'; + Object.assign(d.style, {left: `${34 + 14 + 60 + i * 60 - 50}px`, top: `${452 + 36 + 124 - 16}px`, width: '36px', height: '32px'}); + d.addEventListener('click', () => { + if (sub !== 'nor') return; + takeOver(); + norIn[i] = norIn[i] ? 0 : 1; + pulse(34 + 14 + 60 + i * 60 - 32, 452 + 36 + 124); + }); + ui.root.appendChild(d); + } + // the DSKY's keys, by raycast on the 3D face + const ray = new THREE.Raycaster(); + const keyMeshes = Object.values(st.mac.dsky.keys); + const keyAt = (cx, cy) => { + const [x, y] = toStage(cx, cy); + ray.setFromCamera(new THREE.Vector2((x / W) * 2 - 1, -(y / H) * 2 + 1), st.camera); + const hit = ray.intersectObjects(keyMeshes, false)[0]; + return hit?.object.userData.key || null; + }; + const pressKey = (k) => { + if (cutKeys === Infinity) cutKeys = s; + userKeys.push([s + 0.001, k]); + press[k] = T; + replan(); + }; + addEventListener('keydown', (e) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const key = e.key.toLowerCase(); + takeOver(); + if (key === ' ') paused = !paused; + else if (key === 'h') hidden = !hidden; + else if (/^[1-7]$/.test(key)) ui.btn[SUBS[+key - 1]].click(); + else return; + e.preventDefault(); + }); + let drag = null; + canvas.addEventListener('pointerdown', (e) => { + const k = keyAt(e.clientX, e.clientY); + if (k) { + takeOver(); + pressKey(k); + return; + } + drag = {x: e.clientX, y: e.clientY}; + canvas.setPointerCapture(e.pointerId); + canvas.classList.add('drag'); + }); + canvas.addEventListener('pointermove', (e) => { + if (!drag) { + canvas.style.cursor = keyAt(e.clientX, e.clientY) ? 'pointer' : ''; + return; + } + off.az += ((e.clientX - drag.x) / scale) * 0.25; + off.el = clamp(off.el + ((e.clientY - drag.y) / scale) * 0.18, -20, 50); + 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.6); + }, + {passive: false}, + ); + + // ------------------------------------------------------------ loops of the live subsystems + const ROPE_LOOP = [0o2625, 0o2626, 0o2627, 0o2630, 0o2631, 0o2632, 0o2633]; + const ropeLoop = (t) => { + const n = Math.floor(t / 3.2); + const addr = ROPE_LOOP[n % ROPE_LOOP.length]; + const w = ROPE_WORDS.findIndex((x) => x.bank === 1 && x.addr === addr); + return {phase: (t / 3.2) % 1, w, core: 4 + (addr - 0o2627), place: ropePlace(1, addr)}; + }; + + // ------------------------------------------------------------ the frame + startTour(); + let last = performance.now(); + const perf = {n: 0, sum: 0, slow: 0, age: 0}; + const ease = (key, want, dt, tau = 0.5) => (mech[key] += (want - mech[key]) * (1 - Math.exp(-dt / tau))); + 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 fx; + let tgt; + let ms; + let det; + let callouts; + let cursor; + let caption; + let rateTxt; + let curView; + let pressNow = {}; + if (AP.on) { + // the film, exactly + const c = T - AP.t0; + const loop = Math.floor(c / DURATION); + const cyc = c - loop * DURATION; + if (loop > AP.loop) AP.loop = loop; + s = missionAt(cyc); + curView = camAtName(cyc); + const t0 = SCRIPT.filter((e) => e.cam && e.t <= cyc).pop().t; + const since = cyc - t0; + ms = machineState(LOG, s); + const acts = actsAt(cyc); + const ropePhase = (s - ROPE_MCT * MCT) / MCT; + const erasPhase = (s - ERAS_MCT * MCT) / MCT; + for (const [ks, k] of LOG.keyEv) { + const d = s - ks; + if (d >= 0 && d < 0.25) pressNow[k] = Math.max(pressNow[k] || 0, 1 - d / 0.25); + } + fx = { + ...acts, + read: {phase: ropePhase >= 0 && ropePhase < 1 ? ropePhase : -1}, + erasRead: {phase: erasPhase >= 0 && erasPhase < 1 ? erasPhase : -1, bit: bits16(prioWord(ERAS_JOB?.kind))[1]}, + rr: curView === 'exec' || curView === 'hero' ? 0.25 + 0.25 * Math.sin(T * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press: pressNow}, + }; + st.mac.update(fx); + tgt = camAt(cyc, st.mac.anchors); + Object.assign(mech, {open: acts.open, rope: acts.rope, ropeCover: acts.ropeCover, eras: acts.eras, logic: 0, gate: 0}); + det = + curView === 'rope' + ? detailFor('rope', s, ms, {ropePhase}) + : curView === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: ERAS_JOB?.kind, erasSet: 0}) + : curView === 'dsky' + ? detailFor('dsky', s, ms) + : curView === 'exec' + ? detailFor('iface', s, ms, {radar: true}) + : detailFor('clock', s, ms); + const inside = curView === 'rope' ? ss(3.0, 4.2, since) : curView === 'eras' ? ss(2.0, 3.0, since) : 0; + callouts = calloutsFor(curView, st, ss(1.2, 1.9, since), {inside, rrHot: true}); + cursor = tourCursor(cyc); + if (AP.loop > 0 && cyc < 0.8) { + const end = tourCursor(DURATION - 0.01); + const k = ss(0, 0.8, cyc); + cursor = {...cursor, x: end.x + (cursor.x - end.x) * k, y: end.y + (cursor.y - end.y) * k}; + } + caption = captionAt(cyc); + rateTxt = rateText(rateAt(cyc)); + } else { + if (T - lastInput > IDLE) startTour(); + const since = T - viewAt; + curView = view; + if (!paused) s += RATES[rateI].r * dt; + if (s > EVENTS.contact + 6) s = 300; + ms = machineState(log, s); + // mechanisms toward the section and the subsystem + const open = cut === 'tray' || cut === 'module' || ['rope', 'eras', 'nor', 'graze'].includes(view) || (sub && sub !== 'dsky') ? 1 : 0; + ease('open', open, dt, 0.55); + const ready = mech.open > 0.97; + ease('rope', ready && (sub === 'rope' || view === 'rope') ? 1 : 0, dt, 0.45); + ease('ropeCover', mech.rope > 0.95 && (sub === 'rope' || view === 'rope') ? 1 : 0, dt, 0.3); + ease('eras', ready && sub === 'eras' ? 1 : 0, dt, 0.45); + ease('logic', ready && sub === 'nor' ? 1 : 0, dt, 0.45); + ease('gate', mech.logic > 0.9 && sub === 'nor' ? 1 : 0, dt, 0.35); + const inRope = mech.ropeCover > 0.97 && view === 'rope'; + const rl = ropeLoop(T); + const eLoopN = Math.floor(T / 4); + const eSet = eLoopN % 8; + const eJob = jobsAt(log, s)[eSet]; + const erasPhase = sub === 'eras' && mech.eras > 0.95 ? (T / 4) % 1 : -1; + for (const [k, t] of Object.entries(press)) { + const d = T - t; + if (d >= 0 && d < 0.25) pressNow[k] = 1 - d / 0.25; + } + for (const [ks, k] of log.keyEv) { + const d = s - ks; + if (d >= 0 && d < 0.25 && RATES[rateI].r <= 1) pressNow[k] = Math.max(pressNow[k] || 0, 1 - d / 0.25); + } + fx = { + ...mech, + ropeDetail: inRope ? 1 : 0, + read: {phase: inRope ? rl.phase : -1, core: rl.core}, + erasRead: {phase: erasPhase, bit: bits16(prioWord(eJob?.kind))[1]}, + cut: cut === 'case' ? 'case' : '', + rr: (sub === 'iface' || sub === 'exec') && radar ? 0.25 + 0.25 * Math.sin(T * 9) : 0, + dsky: {lamps: ms.dsky.lamps, el: ms.dsky, press: pressNow}, + }; + st.mac.update(fx); + const fn = VIEW_FN[curView] || VIEW_FN.hero; + tgt = fn(curView === 'rope' && !inRope && mech.rope < 0.9 ? Math.min(since, 1.0) : since, st.mac.anchors); + tgt.az += off.az; + tgt.el = clamp(tgt.el + off.el, -10, 85); + tgt.d *= off.k; + const kind = sub || 'clock'; + det = + kind === 'rope' + ? detailFor('rope', s, ms, {ropePhase: inRope ? rl.phase : -1, ropeW: rl.w, place: rl.place}) + : kind === 'eras' + ? detailFor('eras', s, ms, {erasPhase, erasJob: eJob?.kind, erasSet: eSet}) + : kind === 'nor' + ? detailFor('nor', s, ms, {norIn}) + : kind === 'iface' + ? detailFor('iface', s, ms, {radar}) + : detailFor(kind, s, ms); + const inside = curView === 'rope' ? (inRope ? 1 : 0) : curView === 'eras' ? ss(2.0, 3.0, since) : 0; + callouts = calloutsFor(curView, st, ss(1.2, 1.9, since), {inside, rrHot: radar}); + 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}; + caption = tr('cap:live:' + (sub || 'axo')); + rateTxt = paused ? '‖' : rateText(RATES[rateI].r); + } + // the camera eases toward its target, logarithmically in distance (metres down to centimetres) + 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] ?? 0) - (cam[key] ?? 0)) * k; + cam.d = Math.exp(Math.log(cam.d) + (Math.log(tgt.d) - Math.log(cam.d)) * k); + st.frame(fx, {...cam, az: cam.az * D2R, el: cam.el * D2R}); + + ui.root.style.display = hidden ? 'none' : ''; + ui.update({ + get: GET0 + s, + mct: s / MCT, + rateText: rateTxt, + dsky: ms.dsky, + dskyText: dskyText(ms.d, ms.ds), + sets: ms.sets, + vacs: ms.vacs, + busy: ms.busy, + duty: ms.duty, + strip: ms.strip, + stripAt: 84, + alarm: ms.alarm, + execNote: tr('execWait'), + detail: det, + caption, + sub: AP.on ? {rope: 'rope', eras: 'eras', dsky: 'dsky', exec: 'exec'}[curView] || '' : sub, + cut: AP.on ? (mech.open > 0.5 ? 'tray' : '') : cut, + view: AP.on ? '' : {open: 'axo', hero: 'axo', dsky: 'dskyFace', rope: 'ropeIn', graze: 'graze'}[curView] || '', + callouts, + cursor, + }); + RATES.forEach((r, i) => ctl['rate' + i].classList.toggle('on', !AP.on && i === rateI && !paused)); + const rt = radar ? tr('radarOn') : tr('radarOff'); + if (ctl.radar.textContent !== rt) ctl.radar.textContent = rt; + 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); + }; + // the first pose before warming, so every program the frame needs exists + st.mac.update({...mech, ropeDetail: 1, dsky: {lamps: {}, el: machineState(LOG, s).dsky, press: {}}}); + warmComposer(st.renderer, st.composer, st.scene, st.camera).then(() => requestAnimationFrame(frame)); + window.LIVE = { + st, + cam, + startTour, + takeOver, + pressKey, + get tier() { + return tier; + }, + get T() { + return T; + }, + get s() { + return s; + }, + get log() { + return log; + }, + }; + void smoother; + void L; +} diff --git a/machines/rope/agc-shared/machine.js b/machines/rope/agc-shared/machine.js new file mode 100644 index 0000000..3d322af --- /dev/null +++ b/machines/rope/agc-shared/machine.js @@ -0,0 +1,285 @@ +// Rope's one source of figures: the Apollo Guidance Computer Block II (the LM's LGC, Luminary 099, Apollo 11), its +// memories, its clock, its Executive and the descent it flew. No three.js here: the geometry (agc.js), the console, +// the score and check.mjs all read these numbers. Letters refer to REFERENCES.md; [E] marks an estimate. +// +// The one input of the machine is the mission time, counted in memory cycles (MCT) since powered descent initiation +// (PDI, 102:33:05 GET). Everything on screen is a function of it: the word in the rope, the core sets, the lamps, +// VERB / NOUN / PROG and R1-R3 on the DSKY. + +// ------------------------------------------------------------ the computer [W] [S] [H] +export const AGC = { + wordBits: 16, // 15 data bits + 1 odd-parity bit [W] [S] + dataBits: 15, + fixedWords: 36864, // core rope [W] + ropeModules: 6, // B1-B6 [S] + coresPerModule: 512, // 2 layers of 256 [S] + wordsPerCore: 12, // 12 strands of 16 sense lines = 192 lines through or around each core [S] + senseLinesPerCore: 192, + bankWords: 1024, // a fixed bank: 36 banks [V] + erasableWords: 2048, // coincident-current ferrite cores [W] + clockHz: 2.048e6, // crystal [W] + pulsesPerMct: 12, // timing pulses T1-T12, at 1.024 MHz [H] + addMct: 2, // AD: 2 MCT [H] + ics: 2800, // ~2,800 flat-packs, dual 3-input NOR gates [W] + gatesPerIc: 2, + norInputs: 3, + logicModules: 24, // A1-A24, ~120 flat-packs each; A25-A31 interface [S] + massKg: 32, // 70 lb [W] + powerW: 55, // [W] + sizeIn: [24, 12.5, 6.5], // [W] + sizeM: [0.61, 0.32, 0.17], // length, width, height + dskyM: [0.203, 0.203, 0.178], // LM DSKY, 8 x 8 x 7 in [D] +}; +/** one memory cycle: 12 pulses of the 1.024 MHz timing chain (the 2.048 MHz clock halved) */ +export const MCT = AGC.pulsesPerMct / (AGC.clockHz / 2); // 11.71875 µs +export const MCT_PER_S = 1 / MCT; // 85,333 +export const ADD_US = AGC.addMct * MCT * 1e6; // 23.4 µs +export const ADDS_PER_S = 1 / (AGC.addMct * MCT); // 42,667 +export const FIXED_BITS = AGC.fixedWords * AGC.wordBits; // 589,824 +export const ERASABLE_BITS = AGC.erasableWords * AGC.wordBits; // 32,768 +export const WORDS_PER_MODULE = AGC.coresPerModule * AGC.wordsPerCore; // 6,144 + +// ------------------------------------------------------------ the Executive and the radar [E1] [L] +export const EXEC = { + coreSets: 8, // "EIGHT SETS OF 12 REGISTERS EACH" (Luminary 099, ERASABLE_ASSIGNMENTS) [L] [E1] + vacAreas: 5, // [L] [E1] + servicerPeriod: 2, // s, average-G navigation cycle (READACCS) [E1] + // the duty cycle each phase needs, of the whole machine [E1]: margin over 15 % in braking until the landing + // radar locks, ~13 % after (converting its data), 10 % or less with Verb 16 Noun 68 on the DSKY; under 10 % in P64 + braking: 0.85, + lrConvert: 0.015, + n68: 0.03, + approach: [0.835, 0.9], // P64, essential functions only, high gate to P66: the margin falls below 10 % [E1] [E] + manual: 0.8, // P66, lighter [E1] [E] + // the rendezvous radar's CDUs: 2 angles x 6,400 pulses/s at most, one MCT stolen per pulse [E1] + rrPulses: 6400, + rrAngles: 2, + tlossMax: 0.1336, // measured after the flight [E1] + tloss: 0.13, // the value MIT used to reproduce the flight [E1] + // the job mix of this model (which jobs hold a core set and how often they come) is simplified [E] + monitorPeriod: 1, // s, a Verb 16 monitor redisplays every second [P] + approachJobs: 0.06, // P64: the landing-point-designator display job, each 2 s, s of CPU [E] +}; +export const TLOSS_THEORY = EXEC.rrAngles * EXEC.rrPulses * MCT; // 15.0 % + +// ------------------------------------------------------------ the descent of Apollo 11 [J] [R] +export const GET0 = 102 * 3600 + 33 * 60 + 5; // PDI, 102:33:05 GET [R] +export const EVENTS = { + vel: 288, // landing radar velocity data good [E] + alt: 296, // "Altitude light's out", 102:38:11 [J] + p64: 507, // high gate, 102:41:32, 7,400 ft [E1] + p66: 603, // ATT HOLD, P66, 102:43:08 [E1] + lrDrop: 668, // "Altitude (and) velocity lights" 102:44:13 [J] + lrBack: 674, + contact: 755, // "Contact light", 102:45:40 [J] +}; +/** the Apollo 11 alarms as flown, s after PDI, to compare with the model's [R] [E1] */ +export const FLOWN_ALARMS = [ + [317, 1202], // 102:38:22 + [357, 1202], // 102:39:02 + [553, 1201], // 102:42:18 + [578, 1202], // 102:42:43 + [593, 1202], // 102:42:58 +]; +// altitude (ft) and horizontal speed (ft/s) against s after PDI: the crew's call-outs [J]; before high gate, where +// nobody called numbers, a smooth braking profile through the published points (33,500 ft at the first alarm, +// 7,400 ft at high gate) [E] +const ALT_KEYS = [ + [0, 50000], [120, 47200], [240, 40400], [317, 33500], [400, 22500], [460, 14000], [507, 7400], [526, 5000], + [545, 3000], [556, 2000], [598, 750], [602, 700], [606, 600], [611, 540], [621, 400], [628, 350], [641, 300], + [662, 250], [671, 220], [679, 200], [686, 160], [695, 120], [700, 100], [709, 75], [732, 40], [736, 30], [755, 5], [760, 0], +]; +const VH_KEYS = [ + [0, 5560], [120, 4610], [240, 3480], [317, 2700], [400, 1640], [460, 960], [507, 470], [545, 230], [598, 90], + [621, 58], [641, 47], [662, 19], [671, 13], [679, 11], [700, 9], [709, 6], [736, 2], [755, 0.5], [760, 0], +]; +const LPD_KEYS = [[507, 47], [560, 40], [602, 33], [611, 30], [640, 30]]; // LPD angle, degrees [J] [E] + +/** Fritsch-Carlson monotone cubic through keys [[x, y]] */ +export function monotone(keys) { + const n = keys.length; + const x = keys.map((k) => k[0]); + const y = keys.map((k) => k[1]); + 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] = d[0]; + m[n - 1] = d[n - 2]; + for (let i = 1; i < n - 1; i++) m[i] = 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]); + const f = (t) => { + if (t <= x[0]) return y[0] + m[0] * (t - x[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]; + }; + return f; +} +const altF = monotone(ALT_KEYS); +const vhF = monotone(VH_KEYS); +const lpdF = monotone(LPD_KEYS); +const END = 760; +// range still to fly, tabulated once: ∫ vh dt from s to the touchdown point +const RNG_DT = 0.25; +const RNG = (() => { + const n = Math.ceil(END / RNG_DT) + 1; + const r = new Float64Array(n); + for (let i = n - 2; i >= 0; i--) r[i] = r[i + 1] + 0.5 * (vhF(i * RNG_DT) + vhF((i + 1) * RNG_DT)) * RNG_DT; + return r; +})(); +const FT_PER_NM = 6076.12; +const clamp = (x, a, b) => Math.min(b, Math.max(a, x)); + +/** the LM's state s seconds after PDI: altitude (ft), its rate (ft/s), horizontal and total speed, range (ft) */ +export function descent(s) { + const t = clamp(s, 0, END); + const h = Math.max(0, altF(t)); + const hdot = t >= END ? 0 : (altF(Math.min(END, t + 0.5)) - altF(Math.max(0, t - 0.5))) / (Math.min(END, t + 0.5) - Math.max(0, t - 0.5)); + const vh = Math.max(0, vhF(t)); + const i = Math.floor(t / RNG_DT); + const k = t / RNG_DT - i; + const range = i >= RNG.length - 1 ? 0 : RNG[i] * (1 - k) + RNG[i + 1] * k; + // Delta-H: landing-radar altitude minus computed altitude, -2,900 ft when the radar locked [J], then incorporated [E] + const dH = t < EVENTS.alt ? -2900 : -2900 * Math.exp(-(t - EVENTS.alt) / 38); + return {s: t, h, hdot, vh, v: Math.hypot(vh, hdot), range, slantNm: Math.hypot(range, h) / FT_PER_NM, dH, lpd: lpdF(t), tgo: Math.max(0, EVENTS.p64 - t)}; +} +export const DESCENT_END = END; + +// ------------------------------------------------------------ the rope: real words of Luminary 099 [L] +// Assembled words of the Executive's NOVAC (find a core set) and of BAILOUT1, from the Virtual AGC listing of +// Luminary 099 (bank, address, octal word, label, instruction). 15 bits each; the 16th, odd parity, is computed. +export const ROPE_WORDS = [ + [1, 0o2625, 0o34755, 'NOVAC2', 'CAF ZERO'], + [1, 0o2626, 0o54064, '', 'TS LOCCTR'], + [1, 0o2627, 0o32634, '', 'CAF NO.CORES'], + [1, 0o2630, 0o54062, 'NOVAC3', 'TS EXECTEM2'], + [1, 0o2631, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2632, 0o10167, '', 'CCS PRIORITY'], + [1, 0o2633, 0o12674, '', 'TCF NEXTCORE'], + [1, 0o2634, 0o00007, 'NO.CORES', 'DEC 7'], + [1, 0o2635, 0o12674, '', 'TCF NEXTCORE'], + [1, 0o2636, 0o30063, 'CORFOUND', 'CA NEWPRIO'], + [1, 0o2637, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2640, 0o54167, '', 'TS PRIORITY'], + [1, 0o2641, 0o75004, '', 'MASK LOW9'], + [1, 0o2642, 0o50064, '', 'INDEX LOCCTR'], + [1, 0o2643, 0o54166, '', 'TS PUSHLOC'], + [1, 0o2674, 0o33033, 'NEXTCORE', 'CAF COREINC'], + [1, 0o2675, 0o26064, '', 'ADS LOCCTR'], + [1, 0o2676, 0o10062, '', 'CCS EXECTEM2'], + [1, 0o2677, 0o12630, '', 'TCF NOVAC3'], + [1, 0o2700, 0o22061, '', 'LXCH EXECTEM1'], + [1, 0o2701, 0o30002, '', 'CA Q'], + [1, 0o2702, 0o05716, '', 'TC BAILOUT1'], + [1, 0o2703, 0o01202, '', 'OCT 1202'], + [2, 0o5716, 0o00004, 'BAILOUT1', 'INHINT'], + [2, 0o5717, 0o53364, '', 'DXCH ALMCADR'], + [2, 0o5720, 0o35742, '', 'CAF ADR40400'], + [2, 0o5721, 0o54061, 'BOTHABRT', 'TS ITEMP1'], + [2, 0o5722, 0o50002, '', 'INDEX Q'], + [2, 0o5723, 0o30000, '', 'CAF 0'], +].map(([bank, addr, word, label, op]) => ({bank, addr, word, label, op, parity: parityBit(word), bits: bits16(word)})); +// the rest of bank 01 around NOVAC2, for the cores either side of the ones above [L] +export const ROPE_NEIGHBOURS = {0o2616: 0o05716, 0o2617: 0o01201, 0o2620: 0o64752, 0o2621: 0o22007, 0o2622: 0o50000, 0o2623: 0o21777, 0o2624: 0o26063}; + +/** odd parity: the 16th bit makes the count of ones odd */ +export function parityBit(w) { + let n = 0; + for (let b = 0; b < 15; b++) n += (w >> b) & 1; + return n % 2 === 0 ? 1 : 0; +} +/** the 16 bits as stored, most significant data bit first, parity last */ +export function bits16(w) { + const out = []; + for (let b = 14; b >= 0; b--) out.push((w >> b) & 1); + out.push(parityBit(w)); + return out; +} +export const octal = (w, n = 5) => w.toString(8).padStart(n, '0'); +/** a fixed address to its place in the ropes: absolute word, module B1-B6, core and strand (the strand mapping is + * this model's: word w of a module sits on core w mod 512, strand ⌊w / 512⌋) [S] [E] */ +export function ropePlace(bank, addr) { + const off = bank >= 2 && addr >= 0o4000 ? addr - (addr >= 0o6000 ? 0o6000 : 0o4000) : addr - 0o2000; + const abs = bank * AGC.bankWords + off; + const module = Math.floor(abs / WORDS_PER_MODULE); + const w = abs % WORDS_PER_MODULE; + return {abs, module, core: w % AGC.coresPerModule, strand: Math.floor(w / AGC.coresPerModule)}; +} +/** the word stored at a bank-01 address of the excerpt, or a neighbour's */ +export function wordAt(bank, addr) { + const r = ROPE_WORDS.find((x) => x.bank === bank && x.addr === addr); + if (r) return r.word; + return ROPE_NEIGHBOURS[addr] ?? null; +} + +// instruction times in MCT [H] +const TIME = {CAF: 2, CA: 2, CS: 2, TS: 2, INDEX: 2, CCS: 2, TCF: 1, TC: 1, ADS: 2, LXCH: 2, DXCH: 3, MASK: 2, INHINT: 1, DEC: 1, OCT: 1}; +/** + * The instructions NOVAC2 runs to find a core set, given how many are busy (the search starts at core set 0 and + * walks up): [{w (index in ROPE_WORDS), mct (its first MCT, from the call), n (its MCTs), erasable?}]. With all 8 + * busy it falls through to BAILOUT1, whose INDEX Q / CAF 0 fetches the word after TC BAILOUT1: 01202, from the rope. + */ +export function novacTrace(busy) { + const idx = (addr, bank = 1) => ROPE_WORDS.findIndex((x) => x.bank === bank && x.addr === addr); + const out = []; + let t = 0; + const run = (addr, bank = 1, extra = {}) => { + const w = idx(addr, bank); + const op = ROPE_WORDS[w].op.split(' ')[0]; + out.push({w, mct: t, n: TIME[op], ...extra}); + t += TIME[op]; + }; + run(0o2625); + run(0o2626); + run(0o2627); + for (let k = 0; k < EXEC.coreSets; k++) { + run(0o2630); + run(0o2631); + run(0o2632, 1, {erasable: {set: k}}); + if (k >= busy) { + // -0 in PRIORITY: the core set is free, CCS skips three to CORFOUND + for (const a of [0o2636, 0o2637, 0o2640, 0o2641, 0o2642, 0o2643]) run(a); + return {trace: out, found: k, mct: t}; + } + run(0o2633); + run(0o2674); + run(0o2675); + run(0o2676); + if (k < EXEC.coreSets - 1) run(0o2677); + } + for (const a of [0o2700, 0o2701, 0o2702]) run(a); + for (const a of [0o5716, 0o5717, 0o5720, 0o5721, 0o5722]) run(a, 2); + run(0o5723, 2, {fetch: idx(0o2703)}); + return {trace: out, found: -1, mct: t}; +} + +// ------------------------------------------------------------ the sound [Q] +/** Quindar tones: the ground's transmitter keyed with 2,525 Hz, released with 2,475 Hz, 250 ms each */ +export const QUINDAR = {intro: 2525, outro: 2475, ms: 250}; + +// ------------------------------------------------------------ the DSKY [D] [P] +export const DSKY_KEYS = ['VERB', 'NOUN', '+', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'CLR', 'PRO', 'KEY REL', 'ENTR', 'RSET']; +// LM DSKY lamps, left and right columns [D] +export const LAMPS = [ + ['UPLINK ACTY', 'TEMP'], + ['NO ATT', 'GIMBAL LOCK'], + ['STBY', 'PROG'], + ['KEY REL', 'RESTART'], + ['OPR ERR', 'TRACKER'], + ['', 'ALT'], + ['', 'VEL'], +]; +// the crew's keystrokes, s after PDI [J] [E]: Aldrin calls up the Delta-H monitor, reads the alarm code, resets, +// calls the monitor again before the second alarm, then leaves it to Houston +export const CREW_KEYS = [ + [302.6, 'VERB'], [303.0, '1'], [303.4, '6'], [303.9, 'NOUN'], [304.3, '6'], [304.7, '8'], [305.3, 'ENTR'], + [319.6, 'VERB'], [320.0, '0'], [320.4, '5'], [320.9, 'NOUN'], [321.3, '0'], [321.7, '9'], [322.3, 'ENTR'], + [323.6, 'RSET'], + [347.6, 'VERB'], [348.0, '1'], [348.4, '6'], [348.9, 'NOUN'], [349.3, '6'], [349.7, '8'], [350.3, 'ENTR'], + [362.0, 'RSET'], + [581.0, 'RSET'], [590.5, 'RSET'], [601.0, 'RSET'], +]; diff --git a/machines/rope/agc-shared/make-timeline.mjs b/machines/rope/agc-shared/make-timeline.mjs new file mode 100644 index 0000000..878b43f --- /dev/null +++ b/machines/rope/agc-shared/make-timeline.mjs @@ -0,0 +1,32 @@ +// Writes ../agc-film/timeline.json (the film's beats, for review sheets and the score) and ../agc-film/sfx.json (every +// sound event), from the same schedule and Executive log the film draws: the DSKY key presses, the alarms, the +// Quindar tones of the radio calls. node agc-shared/make-timeline.mjs (from machines/rope) +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {QUINDAR} from './machine.js'; +import {DURATION, LOG, SCRIPT, T, filmAt, missionAt, rateAt} from './schedule.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const r = (x) => +x.toFixed(3); +const inFilm = (t) => t >= 0 && t < DURATION - 0.1; +// key clicks: every press of the log heard at a film pace slow enough to hear it as a press +const keys = LOG.keyEv.filter(([s]) => s > missionAt(0)).map(([s]) => filmAt(s)).filter((t) => inFilm(t) && rateAt(t) < 6).map(r); +const alarms = LOG.alarms.map((a) => ({t: r(filmAt(a.s)), code: a.code})).filter((a) => inFilm(a.t)); +// Quindar tones: the start and end of the radio exchanges about the alarm (Armstrong's call, Houston's "go") [J] +const calls = [ + [T.alarm + 0.9, T.alarm + 2.3], + [T.alarm + 3.6, T.alarm + 4.6], + [T.contact + 0.2, T.contact + 1.4], +].map(([a, b]) => [r(a), r(b)]); +const beats = { + views: SCRIPT.filter((e) => e.t > 0).map((e) => e.t), + ropeRead: T.ropeIn, + erasRead: T.erasIn, + alarm: alarms[0].t, + ff: T.ff, + contact: T.contact, +}; +fs.writeFileSync(path.join(HERE, '../agc-film/timeline.json'), JSON.stringify({duration: DURATION, beats})); +fs.writeFileSync(path.join(HERE, '../agc-film/sfx.json'), JSON.stringify({keys, alarms, calls, quindar: QUINDAR})); +console.log(beats, keys.length, 'key presses', alarms.length, 'alarms'); diff --git a/machines/rope/agc-shared/schedule.js b/machines/rope/agc-shared/schedule.js new file mode 100644 index 0000000..3f840ef --- /dev/null +++ b/machines/rope/agc-shared/schedule.js @@ -0,0 +1,87 @@ +// The film's operator script and its one input: the mission time s (seconds after PDI, counted on screen in MCT) as +// a monotone function of film time t. It crawls through two single memory cycles (a word read from the rope, a word +// read and rewritten in erasable memory), runs at about twice real time into the first 1202, then fast-forwards +// through P64 to touchdown. No three.js, so the timeline and the score can import it in node. +import {EVENTS, MCT, ROPE_WORDS, novacTrace} from './machine.js'; +import {jobsAt, simulate} from './executive.js'; + +export const DURATION = 40; +/** the flown log: the crew's keys, the radar's steals on */ +export const LOG = simulate(); +export const ALARM1 = LOG.alarms[0]; + +// the NOVAC call the film slows down on: the first monitor-job request after 307.6 s +const REQ = LOG.jobs.find((j) => j.kind === 'monitor' && j.t0 >= 307.6); +export const NOVAC = {req: REQ, ...novacTrace(REQ.set)}; +const base = Math.round(REQ.t0 / MCT); +const iCaf = NOVAC.trace.findIndex((x) => ROPE_WORDS[x.w].op === 'CAF NO.CORES'); +const iCcs = NOVAC.trace.findIndex((x) => x.erasable); +/** the MCT in which the rope gives CAF NO.CORES (fetched in the last MCT before it runs) */ +export const ROPE_MCT = base + NOVAC.trace[iCaf].mct - 1; +/** the MCT in which CCS PRIORITY reads core set 0's PRIORITY register (and rewrites it) */ +export const ERAS_MCT = base + NOVAC.trace[iCcs].mct + 1; +export const ERAS_JOB = jobsAt(LOG, REQ.t0)[0]; +const sR = ROPE_MCT * MCT; +const sE = ERAS_MCT * MCT; +const R_SLOW = MCT / 4; // one MCT in 4 s of film +export const T = {ropeIn: 10.2, erasIn: 16.0, alarm: 27.6, ff: 32.4, contact: 37.4}; + +const cubicOut = (u) => 1 - (1 - u) ** 3; +const cubicIn = (u) => u ** 3; +const smoother = (u) => u * u * u * (u * (u * 6 - 15) + 10); +// [t0, t1, s0, s1, ease] +const SEGS = [ + [0, 9.6, 306.4, sR + (9.6 - T.ropeIn) * R_SLOW, cubicOut], + [9.6, 14.8, sR + (9.6 - T.ropeIn) * R_SLOW, sR + (14.8 - T.ropeIn) * R_SLOW, (u) => u], + [14.8, 15.6, sR + (14.8 - T.ropeIn) * R_SLOW, sE + (15.6 - T.erasIn) * R_SLOW, (u) => u], + [15.6, 20.2, sE + (15.6 - T.erasIn) * R_SLOW, sE + (20.2 - T.erasIn) * R_SLOW, (u) => u], + [20.2, T.alarm, sE + (20.2 - T.erasIn) * R_SLOW, ALARM1.s, cubicIn], + [T.alarm, T.ff, ALARM1.s, 324.0, (u) => u], + [T.ff, T.contact, 324.0, EVENTS.contact + 0.4, smoother], + [T.contact, DURATION + 1, EVENTS.contact + 0.4, EVENTS.contact + 3.2, (u) => u], +]; +/** mission seconds after PDI at film time t */ +export function missionAt(t) { + const sg = SEGS.find((x) => t < x[1]) ?? SEGS[SEGS.length - 1]; + const u = Math.min(1, Math.max(0, (t - sg[0]) / (sg[1] - sg[0]))); + return sg[2] + (sg[3] - sg[2]) * sg[4](u); +} +/** film time at which the mission reaches s (binary search: s is monotone in t) */ +export function filmAt(s) { + let lo = 0; + let hi = DURATION + 1; + for (let k = 0; k < 50; k++) { + const mid = (lo + hi) / 2; + if (missionAt(mid) < s) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} +/** mission seconds per film second at t */ +export const rateAt = (t) => (missionAt(t + 0.004) - missionAt(Math.max(0, t - 0.004))) / (t > 0.004 ? 0.008 : 0.004); + +// operator moves: camera views and the button pressed for each; `act` are the mechanical states +export const SCRIPT = [ + {t: 0, cam: 'hero'}, + {t: 3.0, cam: 'open', btn: 'tray'}, + {t: 6.8, cam: 'rope', btn: 'rope'}, + {t: 14.6, cam: 'eras', btn: 'eras'}, + {t: 20.2, cam: 'dsky', btn: 'dsky'}, + {t: 24.0, cam: 'exec', btn: 'exec'}, + {t: 32.4, cam: 'hero', btn: 'case'}, +]; +const ramp = (a, b, t) => smoother(Math.min(1, Math.max(0, (t - a) / (b - a)))); +/** the machine's mechanical states at film time t */ +export function actsAt(t) { + return { + open: ramp(3.2, 6.4, t) * (1 - ramp(32.8, 36.4, t)), + rope: ramp(7.0, 8.6, t) * (1 - ramp(16.0, 17.4, t)), + ropeCover: ramp(8.4, 9.2, t) * (1 - ramp(15.6, 16.0, t)), + ropeDetail: t > 8.7 && t < 15.6 ? 1 : 0, + eras: ramp(15.0, 16.0, t) * (1 - ramp(20.4, 21.4, t)), + logic: 0, + gate: 0, + cut: '', + }; +} +export const camAtName = (t) => SCRIPT.filter((e) => e.cam && e.t <= t).pop().cam; diff --git a/machines/rope/agc-shared/score.py b/machines/rope/agc-shared/score.py new file mode 100644 index 0000000..612653b --- /dev/null +++ b/machines/rope/agc-shared/score.py @@ -0,0 +1,71 @@ +"""Score for Rope (the Apollo Guidance Computer, after public sources): the AGC itself makes no sound, so there is no +hum. A very low, dry bed in D the whole way; a dry click on every DSKY key press (from sfx.json, computed by +make-timeline.mjs from the Executive's log); Quindar tones at the start (2,525 Hz) and end (2,475 Hz) of each radio +exchange; the master alarm tone at the first 1202 (a square wave alternating 750 and 2,000 Hz, after the Apollo +Operations Handbook's caution-and-warning description), shorter for the alarms of the fast forward; a soft tick +on each button the operator presses; a low thud at contact. + + node agc-shared/make-timeline.mjs && python3 "$SKILL_DIR/run_sfx.py" agc-shared/score.py +""" + +import json +import math +import os + +from sfx.score_kit import beep, hiss, hz, thud +from sfx.synth import SR, click, pad + +TIMELINE = "../agc-film/timeline.json" +OUT = os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), "rope-score.wav") +TARGET_LUFS = -14.0 +SEED = 1969 +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def square_alarm(dur, f_a=750.0, f_b=2000.0, rate=2.5, level=1.0): + """The master alarm: a soft-edged square wave switching between two pitches `rate` times a second.""" + n = int(dur * SR) + out = [] + ph = 0.0 + for k in range(n): + t = k / SR + f = f_a if int(t * rate * 2) % 2 == 0 else f_b + ph += 2 * math.pi * f / SR + s = math.tanh(3.0 * math.sin(ph)) * 0.6 + env = min(1.0, t / 0.01, (dur - t) / 0.04) + out.append(level * s * env) + return out + + +def score(mix, tl): + dur = tl["duration"] + b = tl["beats"] + with open(os.path.join(HERE, "../agc-film/sfx.json"), encoding="utf8") as f: + fx = json.load(f) + # the bed: one key, D, low and dry (no tremolo, no drums) + chords = [(0.0, [hz("D2"), hz("A2"), hz("D3")]), (12.0, [hz("D2"), hz("A2"), hz("E3")]), (24.0, [hz("D2"), hz("A2"), hz("D3")]), (33.0, [hz("D2"), hz("A2"), hz("F#3")])] + left, right = pad(dur, chords, detune=0.25, tremolo=0.0, xfade=1.2) + mix.put_stereo(0, left, right, 0.05) + # the operator's buttons + for t in b["views"]: + mix.put(t, click(2200, 0.022), 0.04) + # the DSKY keys: a dry mechanical click + for i, t in enumerate(fx["keys"]): + mix.put(t, click(1300 + 60 * (i % 3), 0.03), 0.16, pan=0.25) + mix.put(t + 0.004, click(4200, 0.01), 0.05, pan=0.25) + # Quindar tones round each radio exchange, with a little band noise under the open channel + q = fx["quindar"] + for t0, t1 in fx["calls"]: + mix.put(t0, beep(q["intro"], q["ms"] / 1000, 1.0), 0.05) + mix.put(t0 + 0.25, hiss(max(0.1, t1 - t0 - 0.25), 2600, 1.0), 0.006) + mix.put(t1, beep(q["outro"], q["ms"] / 1000, 1.0), 0.05) + # the alarms: the first in full, the fast-forward ones as short bursts + for i, a in enumerate(fx["alarms"]): + if a["t"] >= dur - 0.2: + continue + if i == 0: + mix.put(a["t"], square_alarm(1.6), 0.05) + elif all(abs(a["t"] - x["t"]) > 0.35 for x in fx["alarms"][:i]): + mix.put(a["t"], square_alarm(0.4), 0.03) + # contact + mix.put(b["contact"], thud(70, 0.25, 0.8), 0.22) diff --git a/machines/rope/agc-shared/seg7.js b/machines/rope/agc-shared/seg7.js new file mode 100644 index 0000000..7c9d78a --- /dev/null +++ b/machines/rope/agc-shared/seg7.js @@ -0,0 +1,69 @@ +// Seven-segment digits drawn by code, for the DSKY's electroluminescent panel (a canvas texture in 3D) and for every +// figure of the console (SVG). Segments a-g, plus the DSKY's sign (a vertical bar crossing g makes +). +// +// aaa +// f b +// ggg +// e c +// ddd +const SEG = { + 0: 'abcdef', 1: 'bc', 2: 'abdeg', 3: 'abcdg', 4: 'bcfg', 5: 'acdfg', 6: 'acdefg', 7: 'abc', 8: 'abcdefg', 9: 'abcdfg', + '-': 'g', '+': 'g+', ' ': '', E: 'adefg', r: 'eg', o: 'cdeg', P: 'abefg', A: 'abcefg', L: 'def', H: 'bcefg', F: 'aefg', C: 'adef', +}; +/** the segment polygons of one digit in a w x h box at (x, y), as [[x, y], ...] lists; slant in fractions of w */ +export function digitPolys(ch, x, y, w, h, slant = 0.08) { + const segs = SEG[ch] ?? ''; + const t = Math.max(1, w * 0.17); + const g = t * 0.22; + const sk = (px, py) => [x + px + (h / 2 - py) * slant * (w / h) * 2, y + py]; + const H = (px0, px1, py) => [sk(px0 + g, py), sk(px0 + t / 2 + g, py - t / 2), sk(px1 - t / 2 - g, py - t / 2), sk(px1 - g, py), sk(px1 - t / 2 - g, py + t / 2), sk(px0 + t / 2 + g, py + t / 2)]; + const V = (px, py0, py1) => [sk(px, py0 + g), sk(px + t / 2, py0 + t / 2 + g), sk(px + t / 2, py1 - t / 2 - g), sk(px, py1 - g), sk(px - t / 2, py1 - t / 2 - g), sk(px - t / 2, py0 + t / 2 + g)]; + const P = { + a: H(t / 2, w - t / 2, t / 2), + g: H(t / 2, w - t / 2, h / 2), + d: H(t / 2, w - t / 2, h - t / 2), + f: V(t / 2, t / 2, h / 2), + b: V(w - t / 2, t / 2, h / 2), + e: V(t / 2, h / 2, h - t / 2), + c: V(w - t / 2, h / 2, h - t / 2), + '+': V(w / 2, h / 2 - w * 0.36, h / 2 + w * 0.36), + }; + const all = 'abcdefg+'.split(''); + return all.map((k) => ({k, on: segs.includes(k), pts: P[k]})).filter((s) => s.k !== '+' || s.on); +} +/** draws a string of digits on a 2D canvas context; off segments in `off` (null: not drawn) */ +export function drawDigits(ctx, str, x, y, w, h, gap, on, off = null, slant = 0.08) { + [...str].forEach((ch, i) => { + for (const s of digitPolys(ch, x + i * (w + gap), y, w, h, slant)) { + if (!s.on && !off) continue; + ctx.fillStyle = s.on ? on : off; + ctx.beginPath(); + s.pts.forEach(([px, py], j) => (j ? ctx.lineTo(px, py) : ctx.moveTo(px, py))); + ctx.closePath(); + ctx.fill(); + } + }); +} +/** the same as SVG paths (one string), for the console; '.' and ':' take a narrow cell of their own */ +export function svgDigits(str, x, y, w, h, gap, on, off = null, slant = 0.08) { + let out = ''; + let cx = x; + const r = Math.max(1, w * 0.1); + const dot = (px, py) => ``; + for (const ch of str) { + if (ch === '.' || ch === ':') { + const px = cx + w * 0.12; + out += ch === '.' ? dot(px, y + h - r) : dot(px, y + h * 0.3) + dot(px, y + h * 0.7); + cx += w * 0.24 + gap; + continue; + } + for (const s of digitPolys(ch, cx, y, w, h, slant)) { + if (!s.on && !off) continue; + out += ``; + } + cx += w + gap; + } + return out; +} +/** width of a string drawn by svgDigits */ +export const digitsWidth = (str, w, gap) => [...str].reduce((a, ch) => a + (ch === '.' || ch === ':' ? w * 0.24 : w) + gap, 0) - gap; diff --git a/machines/rope/agc-shared/stage.js b/machines/rope/agc-shared/stage.js new file mode 100644 index 0000000..c8bc45b --- /dev/null +++ b/machines/rope/agc-shared/stage.js @@ -0,0 +1,169 @@ +// Rope's stage: a sheet of the MIT Instrumentation Lab's documentation rather than a studio. Warm ivory paper with a +// faint millimetre grid and a drawing frame (a screen-space background, so the grid stays put like the sheet it is), +// one warm desk lamp low on the left for long soft shadows, a pale fill. The camera is a long lens, close to an +// axonometric projection. frame(fx, cam) poses the machine and draws one frame. +import * as THREE from 'three'; +import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js'; +import {OutputPass} from 'three/addons/postprocessing/OutputPass.js'; +import {RenderPass} from 'three/addons/postprocessing/RenderPass.js'; +import {buildMachine} from './agc.js'; + +export const PAPER = {bg: '#efe8d8', minor: 'rgba(120,96,58,0.075)', major: 'rgba(120,96,58,0.14)', frame: 'rgba(70,58,40,0.55)'}; +const V3 = THREE.Vector3; + +/** the drawing sheet: grid every 6 px ("1 mm"), heavier every 60, a frame with zone letters; 1600 x 900 layout */ +export function paperCanvas(scale = 2) { + const W = 1600; + const H = 900; + const c = document.createElement('canvas'); + c.width = W * scale; + c.height = H * scale; + const g = c.getContext('2d'); + g.scale(scale, scale); + g.fillStyle = PAPER.bg; + g.fillRect(0, 0, W, H); + // a faint warm vignette, like a lamp on paper + const rg = g.createRadialGradient(W * 0.36, H * 0.3, 80, W * 0.5, H * 0.5, W * 0.75); + rg.addColorStop(0, 'rgba(255,250,236,0.5)'); + rg.addColorStop(1, 'rgba(160,130,80,0.10)'); + g.fillStyle = rg; + g.fillRect(0, 0, W, H); + const line = (x0, y0, x1, y1, col, w) => { + g.strokeStyle = col; + g.lineWidth = w; + g.beginPath(); + g.moveTo(x0, y0); + g.lineTo(x1, y1); + g.stroke(); + }; + const m = 14; + for (let x = m; x <= W - m; x += 6) line(x + 0.25, m, x + 0.25, H - m, (x - m) % 60 === 0 ? PAPER.major : PAPER.minor, (x - m) % 60 === 0 ? 0.7 : 0.45); + for (let y = m; y <= H - m; y += 6) line(m, y + 0.25, W - m, y + 0.25, (y - m) % 60 === 0 ? PAPER.major : PAPER.minor, (y - m) % 60 === 0 ? 0.7 : 0.45); + g.strokeStyle = PAPER.frame; + g.lineWidth = 1.2; + g.strokeRect(m, m, W - 2 * m, H - 2 * m); + g.lineWidth = 0.6; + g.strokeRect(m - 5, m - 5, W - 2 * m + 10, H - 2 * m + 10); + return c; +} + +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.PCFShadowMap; + renderer.localClippingEnabled = true; + const scene = new THREE.Scene(); + const bgTex = new THREE.CanvasTexture(paperCanvas(2)); + bgTex.colorSpace = THREE.SRGBColorSpace; + scene.background = bgTex; + const camera = new THREE.PerspectiveCamera(18, 16 / 9, 0.001, 30); + const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(4, 4, {type: THREE.HalfFloatType, samples: 8})); + composer.addPass(new RenderPass(scene, camera)); + composer.addPass(new OutputPass()); + + // a warm room for the metal to reflect + { + const env = new THREE.Scene(); + env.background = new THREE.Color('#d8cdb5'); + const box = (w, h, pos, col, k) => { + const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({color: new THREE.Color(col).multiplyScalar(k), side: THREE.DoubleSide})); + m.position.copy(pos); + m.lookAt(0, 0.5, 0); + env.add(m); + }; + box(10, 10, new V3(-3, 9, 4), '#fff3dc', 2.2); + box(4, 8, new V3(-9, 3, 2), '#ffe9c4', 1.8); + box(4, 8, new V3(9, 3, -3), '#f2efe6', 1.1); + const pm = new THREE.PMREMGenerator(renderer); + scene.environment = pm.fromScene(env, 0.03).texture; + scene.environmentIntensity = 0.75; + pm.dispose(); + } + // the desk lamp: warm, low on the left and in front, long shadows + const lamp = new THREE.DirectionalLight('#ffe2b8', 2.6); + lamp.position.set(-1.6, 1.25, 1.35); + lamp.target.position.set(0, 0, -0.05); + lamp.castShadow = true; + Object.assign(lamp.shadow.camera, {left: -0.9, right: 0.9, top: 0.75, bottom: -0.75, near: 0.5, far: 4.5}); + lamp.shadow.mapSize.set(4096, 4096); + lamp.shadow.radius = 5; + lamp.shadow.bias = -0.0003; + lamp.shadow.normalBias = 0.0015; + scene.add(lamp, lamp.target); + scene.add(new THREE.HemisphereLight('#fff8ea', '#cdbf9f', 1.05)); + const fill = new THREE.DirectionalLight('#e8eef5', 0.45); + fill.position.set(1.4, 0.9, 1.6); + scene.add(fill); + // a lamp that follows the camera when it is inside a module, so the cores read + const inner = new THREE.PointLight('#fff1da', 0, 0.3, 1.5); + scene.add(inner); + + const shade = new THREE.Mesh( + new THREE.PlaneGeometry(8, 8), + new THREE.ShadowMaterial({color: '#5a4630', opacity: 0.3, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -8}), + ); + shade.rotation.x = -Math.PI / 2; + shade.receiveShadow = true; + scene.add(shade); + + const mac = buildMachine(); + scene.add(mac.root); + mac.root.traverse((o) => { + if (o.isMesh && !o.isInstancedMesh) { + o.castShadow = o.castShadow ?? true; + o.receiveShadow = true; + } + }); + + 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(); + }; + // cam: target (tx, ty, tz), azimuth from +z toward +x, elevation, distance (radians, metres), fov, sx/sy screen shift + const frame = (fx, cam) => { + const {tx, ty, tz, az, el, d} = cam; + camera.fov = cam.fov ?? 18; + 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.0005, d * 0.05); + camera.far = Math.max(6, d * 20); + camera.updateProjectionMatrix(); + camera.setViewOffset(size.w, size.h, -(cam.sx ?? 0), -(cam.sy ?? 0), size.w, size.h); + camera.updateMatrixWorld(); + const close = Math.max(0, Math.min(1, (0.3 - d) / 0.2)); + inner.intensity = close * 0.25; + inner.position.copy(camera.position).add(new V3(-0.01, 0.02, 0.01)); + mac.update(fx); + 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, shadow}) => { + if (samples !== undefined) + for (const t of [composer.renderTarget1, composer.renderTarget2]) { + t.samples = samples; + t.dispose(); + } + if (shadow !== undefined) { + lamp.shadow.mapSize.set(shadow, shadow); + lamp.shadow.map?.dispose(); + lamp.shadow.map = null; + } + }; + return {renderer, scene, camera, mac, composer, lamp, resize, frame, project, size, setQuality}; +} diff --git a/machines/rope/check.mjs b/machines/rope/check.mjs new file mode 100644 index 0000000..a661f56 --- /dev/null +++ b/machines/rope/check.mjs @@ -0,0 +1,88 @@ +// Recomputes, from the same functions the page draws with, every figure Rope shows, and fails if one leaves the range +// its source gives. node machines/rope/check.mjs (CI runs it on every push). +import * as M from './agc-shared/machine.js'; +import {simulate} from './agc-shared/executive.js'; + +let bad = 0; +const near = (name, v, lo, hi, unit = '') => { + const ok = v >= lo && v <= hi; + if (!ok) bad++; + const f = (x) => (unit === 'octal' ? `0${x.toString(8)}` : Number(Number(x).toPrecision(7))); + console.log(`${ok ? 'ok ' : 'BAD'} ${name.padEnd(50)} ${f(v)} ${unit === 'octal' ? '' : unit} [${f(lo)} … ${f(hi)}]`); +}; + +// the clock [W] [H]: 2.048 MHz halved, 12 timing pulses per memory cycle +near('memory cycle (MCT)', M.MCT * 1e6, 11.71, 11.72, 'µs'); +near('MCT per second', M.MCT_PER_S, 85333, 85334); +near('addition, 2 MCT', M.ADD_US, 23.43, 23.44, 'µs'); +near('additions per second', M.ADDS_PER_S, 42666, 42667); +// the word [W]: 15 data bits + odd parity +near('word', M.AGC.dataBits + 1, 16, 16, 'bits'); +// the memories [W] [S]: 6 ropes x 512 cores x 12 words = 36,864 words; 2,048 erasable words +near('rope words per module (512 cores x 12)', M.WORDS_PER_MODULE, 6144, 6144); +near('fixed words (6 modules)', M.AGC.ropeModules * M.WORDS_PER_MODULE, 36864, 36864); +near('fixed words = 36 banks of 1,024', 36 * M.AGC.bankWords, 36864, 36864); +near('fixed memory in bits', M.FIXED_BITS, 589824, 589824); +near('sense lines per core (12 strands of 16)', M.AGC.wordsPerCore * 16, M.AGC.senseLinesPerCore, M.AGC.senseLinesPerCore); +near('erasable memory in bits', M.ERASABLE_BITS, 32768, 32768); +// one kind of gate: ~2,800 dual 3-input NOR flat-packs [W] +near('NOR gates', M.AGC.ics * M.AGC.gatesPerIc, 5600, 5600); +// the box [W]: 24 x 12.5 x 6.5 in = 61 x 32 x 17 cm +M.AGC.sizeIn.forEach((x, i) => near(` box dimension ${i + 1}`, x * 0.0254, M.AGC.sizeM[i] - 0.005, M.AGC.sizeM[i] + 0.005, 'm')); +// odd parity on every word of the excerpt [L]: the count of ones, parity included, is odd +let parityBad = 0; +for (const w of M.ROPE_WORDS) if (w.bits.reduce((a, b) => a + b, 0) % 2 !== 1) parityBad++; +near('words with even parity in the excerpt', parityBad, 0, 0); +// the word the film reads, CAF NO.CORES at 01,2627 = 32634 octal [L] +const caf = M.ROPE_WORDS.find((w) => w.op === 'CAF NO.CORES'); +near('CAF NO.CORES at 01,2627', caf.word, 0o32634, 0o32634, 'octal'); +near('its place: module B1', M.ropePlace(caf.bank, caf.addr).module + 1, 1, 1); +// NOVAC2 with all 8 core sets busy falls through to BAILOUT1 and fetches 01202 from the rope +const full = M.novacTrace(8); +near('NOVAC with 8 busy: found', full.found, -1, -1); +near(' the word it fetches from the rope: OCT 1202', M.ROPE_WORDS[full.trace[full.trace.length - 1].fetch].word, 0o1202, 0o1202, 'octal'); +near(' MCTs from NOVAC to the alarm code', full.mct, 60, 140); +// the radar [E1]: 2 angles x 6,400 pulses/s, one MCT each +near('radar steal, theoretical maximum', M.TLOSS_THEORY * 100, 14.9, 15.1, '%'); +near('radar steal used by the model', M.EXEC.tloss * 100, 13, M.EXEC.tlossMax * 100, '%'); +// the duty at the first alarm [E1]: braking 85 % + radar data + V16 N68 3 % + 13 % stolen > 100 % +const duty = M.EXEC.braking + M.EXEC.lrConvert + M.EXEC.n68 + M.EXEC.tloss; +near('duty asked at the 1202', duty * 100, 100.01, 104, '%'); +near('duty without the radar', (duty - M.EXEC.tloss) * 100, 85, 91, '%'); +// the log as flown, against Apollo 11's alarms [R] +const L = simulate(); +const a = L.alarms; +near('first alarm is a 1202', a[0].code, 1202, 1202); +near(' at, s after PDI (flown 317, 102:38:22)', a[0].s, 314, 320, 's'); +near(' core sets busy at it', a[0].busy, 8, 8); +near('second alarm is a 1202', a[1].code, 1202, 1202); +near(' at (flown 357, 102:39:02)', a[1].s, 352, 362, 's'); +const p64 = a.filter((x) => x.s > M.EVENTS.p64 && x.s < M.EVENTS.p66); +near('alarms in P64 (flown 3)', p64.length, 2, 4); +near('alarms in P66 and after (flown 0)', a.filter((x) => x.s >= M.EVENTS.p66).length, 0, 0); +near('alarms between the 2nd and P64 (flown 0)', a.filter((x) => x.s > a[1].s + 1 && x.s < M.EVENTS.p64).length, 0, 0); +for (const r of L.restarts) { + if (r.kept.length !== 1 || r.kept[0].kind !== 'servicer') { + bad++; + console.log(`BAD restart at ${r.s.toFixed(2)} kept ${JSON.stringify(r.kept)}`); + } +} +near('restarts that keep exactly the newest SERVICER', L.restarts.filter((r) => r.kept.length === 1 && r.kept[0].kind === 'servicer').length, L.restarts.length, L.restarts.length); +// the radar off: no alarm at all +near('alarms with the radar steals off', simulate({radar: false}).alarms.length, 0, 0); +// the descent [J] [E1]: the published points +near('altitude at the first alarm (~33,500 ft)', M.descent(a[0].s).h, 32500, 34500, 'ft'); +near('altitude at high gate (7,400 ft)', M.descent(M.EVENTS.p64).h, 7300, 7500, 'ft'); +near('slant range at high gate (~7.8 km [G])', M.descent(M.EVENTS.p64).slantNm * 1852, 7000, 8600, 'm'); +near('altitude at contact', M.descent(M.EVENTS.contact).h, 0, 8, 'ft'); +// Quindar tones [Q] +// the Quindar tones the score plays [Q]: 2,525 Hz to key the ground transmitter, 2,475 Hz to release it, 250 ms +near('Quindar intro tone', M.QUINDAR.intro, 2525, 2525, 'Hz'); +near('Quindar release tone', M.QUINDAR.outro, 2475, 2475, 'Hz'); +near('Quindar tone length', M.QUINDAR.ms, 250, 250, 'ms'); + +if (bad) { + console.error(`\n${bad} figure(s) out of range`); + process.exit(1); +} +console.log('\nevery figure in range'); diff --git a/machines/rope/og.png b/machines/rope/og.png new file mode 100644 index 0000000..be1f7bb Binary files /dev/null and b/machines/rope/og.png differ diff --git a/package-lock.json b/package-lock.json index b4c6f1b..17297ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "@fontsource-variable/inter": "5.3.0", + "@fontsource/barlow-condensed": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/saira-stencil-one": "5.3.0", "esbuild": "0.28.1", "three": "0.186.1" } @@ -441,6 +443,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/barlow-condensed": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/barlow-condensed/-/barlow-condensed-5.3.0.tgz", + "integrity": "sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/instrument-serif": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.3.0.tgz", @@ -459,6 +470,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/saira-stencil-one": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/saira-stencil-one/-/saira-stencil-one-5.3.0.tgz", + "integrity": "sha512-b7ILM7KrAWTIUSWKuzV29lTQZV5QOGhlmXXEt3Dsg/rJ4lo1kjUjuo6dBG9Iq+pR7wRZCVXftinVHDof+i9nSA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", diff --git a/package.json b/package.json index 12c5ea9..7fbcbff 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,16 @@ "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/rope/check.mjs && node machines/radial/check.mjs && 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" }, "dependencies": { "@fontsource-variable/inter": "5.3.0", + "@fontsource/barlow-condensed": "5.3.0", "@fontsource/instrument-serif": "^5.3.0", "@fontsource/jetbrains-mono": "5.3.0", + "@fontsource/saira-stencil-one": "5.3.0", "esbuild": "0.28.1", "three": "0.186.1" } diff --git a/scripts/build.mjs b/scripts/build.mjs index 0e186a3..0664b2d 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -57,6 +57,8 @@ const bundle = async (entry) => { write: false, target: 'es2022', legalComments: 'none', + // a machine may inline its own type (Rope's and Radial's fonts) + loader: {'.woff2': 'base64'}, logLevel: 'warning', }); return res.outputFiles[0].text.replace(/<\/script/g, '<\\/script'); diff --git a/scripts/machines.mjs b/scripts/machines.mjs index a73ce2c..1a95186 100644 --- a/scripts/machines.mjs +++ b/scripts/machines.mjs @@ -1,6 +1,93 @@ // 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: 'rope', + name: 'Rope', + entry: 'machines/rope/agc-live/main.js', + og: 'machines/rope/og.png', + meta: { + en: { + title: 'Rope · the Apollo Guidance Computer, taken apart', + line: 'The Apollo Guidance Computer and its DSKY: a program woven into core rope, one kind of gate, and the 1202 alarm of Apollo 11.', + description: + 'The Apollo Guidance Computer you can take apart in the browser: a word read from the core rope bit by bit, an erasable core read and rewritten, the Executive running out of core sets as the radar steals cycles, the 1202 of Apollo 11. A model after public sources (MIT Instrumentation Lab, NASA); not affiliated with NASA or MIT.', + locale: 'en_US', + }, + fr: { + title: 'Rope · l’ordinateur de bord d’Apollo, démonté', + line: 'L’Apollo Guidance Computer et son DSKY : un programme tissé dans la mémoire à tores, un seul type de porte, et l’alarme 1202 d’Apollo 11.', + description: + 'L’Apollo Guidance Computer à démonter dans le navigateur : un mot lu bit par bit dans la core rope, un tore effaçable lu puis réécrit, l’Executive à court de core sets quand le radar vole des cycles, le 1202 d’Apollo 11. Un modèle d’après les sources publiques (MIT Instrumentation Lab, NASA) ; sans lien avec la NASA ni le MIT.', + locale: 'fr_FR', + }, + es: { + title: 'Rope · el ordenador de guiado del Apolo, desmontado', + line: 'El Apollo Guidance Computer y su DSKY: un programa tejido en memoria de núcleos, un solo tipo de puerta y la alarma 1202 del Apolo 11.', + description: + 'El Apollo Guidance Computer para desmontar en el navegador: una palabra leída bit a bit en la core rope, un núcleo borrable leído y reescrito, el Executive sin core sets mientras el radar roba ciclos, el 1202 del Apolo 11. Un modelo según fuentes públicas (MIT Instrumentation Lab, NASA); sin relación con la NASA ni el MIT.', + locale: 'es_ES', + }, + }, + }, + { + slug: 'radial', + name: 'Radial', + entry: 'machines/radial/r2800-live/main.js', + og: 'machines/radial/og.png', + meta: { + en: { + title: 'Radial · the R-2800 Double Wasp, taken apart', + line: 'An 18-cylinder twin-row radial: the starter, the firing order round the star, a master rod and its eight articulated rods, the cam rings, take-off rpm.', + description: + 'The Pratt & Whitney R-2800 Double Wasp you can take apart in the browser: 18 cylinders in two rows, a master rod and eight articulated rods per row, cam rings instead of a camshaft, the run from the starter to 2,700 rpm. A C-series R-2800 after public sources; not affiliated with Pratt & Whitney.', + locale: 'en_US', + }, + fr: { + title: 'Radial · le R-2800 Double Wasp, démonté', + line: 'Un moteur en étoile à 18 cylindres sur deux rangées : le démarreur, l’ordre d’allumage autour de l’étoile, une bielle maîtresse et ses huit biellettes, les anneaux à cames, le régime de décollage.', + description: + 'Le Pratt & Whitney R-2800 Double Wasp à démonter dans le navigateur : 18 cylindres sur deux rangées, une bielle maîtresse et huit biellettes par rangée, des anneaux à cames au lieu d’un arbre à cames, la marche du démarreur à 2 700 tr/min. Un R-2800 de série C d’après les sources publiques ; sans lien avec Pratt & Whitney.', + locale: 'fr_FR', + }, + es: { + title: 'Radial · el R-2800 Double Wasp, desmontado', + line: 'Un motor radial de 18 cilindros en dos filas: el arranque, el orden de encendido alrededor de la estrella, una biela maestra y sus ocho articuladas, los anillos de levas, el régimen de despegue.', + description: + 'El Pratt & Whitney R-2800 Double Wasp para desmontar en el navegador: 18 cilindros en dos filas, una biela maestra y ocho articuladas por fila, anillos de levas en lugar de árbol de levas, la marcha del arranque a 2700 rpm. Un R-2800 de la serie C según fuentes públicas; sin relación con Pratt & Whitney.', + locale: 'es_ES', + }, + }, + }, + { + 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 +154,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: the Apollo Guidance Computer, an 18-cylinder radial engine, a Curta mechanical calculator, a hard drive, a photonic quantum computer.', locale: 'en_US', }, fr: { @@ -75,7 +162,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 : l’ordinateur de guidage d’Apollo, un moteur en étoile à 18 cylindres, une calculatrice mécanique Curta, un disque dur, un ordinateur quantique photonique.', locale: 'fr_FR', }, es: { @@ -83,7 +170,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: el ordenador de guiado del Apolo, un motor radial de 18 cilindros, una calculadora mecánica Curta, un disco duro, un ordenador cuántico fotónico.', locale: 'es_ES', }, }; diff --git a/static/og.png b/static/og.png index ae9e5c4..91920e4 100644 Binary files a/static/og.png and b/static/og.png differ