archivision 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2976 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Iso 3D</title>
7
+ <style>
8
+ :root { --bg:#121314; --panel:rgba(22,23,24,.85); --line:#26292b; --ink:#c9d0db; --dim:#7d8796; --accent:#ff9d3c; }
9
+ :root { color-scheme:dark; }
10
+ :root[data-theme="light"] { color-scheme:light; --bg:#e9eaea; --panel:rgba(246,246,245,.9); --line:#d6d8d8; --ink:#2b2e30; --dim:#6b7075; }
11
+ * { box-sizing:border-box; }
12
+ @media (max-width:900px) { .build { display:none; } }
13
+ .tops { display:flex; gap:8px; align-items:center; min-width:0; max-width:100%; }
14
+ #theme { all:unset; cursor:pointer; width:28px; height:28px; display:grid; place-items:center; border:1px solid var(--line); border-radius:999px; background:var(--panel); color:var(--ink); font-size:14px; }
15
+ #theme:hover, #spin:hover { border-color:var(--dim); }
16
+ .seg[hidden], .seg button[hidden] { display:none !important; }
17
+ .swatches { display:flex; align-items:center; gap:2px; padding:2px; border:1px solid var(--line); border-radius:999px; background:var(--panel); pointer-events:auto; }
18
+ .swatches button { all:unset; pointer-events:auto; cursor:pointer; width:22px; height:22px; border-radius:50%; display:grid; place-items:center; }
19
+ .swatches button i { width:10px; height:10px; border-radius:50%; background:var(--sw); transition:transform .2s; }
20
+ .swatches button:hover i { transform:scale(1.18); }
21
+ .swatches button[aria-checked="true"] { box-shadow:0 0 0 1px var(--sw) inset; }
22
+ #spin { all:unset; cursor:pointer; height:28px; padding:0 11px 0 9px; display:flex; align-items:center; gap:6px; border:1px solid var(--line); border-radius:999px; background:var(--panel); color:var(--dim); font-size:14px; }
23
+ #spin span { font-size:11px; letter-spacing:.08em; text-transform:uppercase; }
24
+ #spin[aria-pressed=true] { color:var(--accent); border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
25
+ .seg { display:flex; min-width:0; max-width:100%; overflow-x:auto; scrollbar-width:none; padding:3px; gap:2px; border:1px solid var(--line); border-radius:999px; background:var(--panel); }
26
+ .seg button { all:unset; flex:none; white-space:nowrap; cursor:pointer; padding:5px 12px; border-radius:999px; color:var(--dim); font-size:11px; letter-spacing:.06em; }
27
+ .seg button:hover { color:var(--ink); }
28
+ .seg button[aria-selected=true] { color:var(--ink); background:var(--bg); box-shadow:0 0 0 1px var(--accent) inset; }
29
+ .ctl.hide { display:none !important; }
30
+ html, body { margin:0; height:100%; background:var(--bg); color:var(--ink); font:12px/1.45 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow:hidden; }
31
+ canvas { display:block; touch-action:none; }
32
+ .ui { position:fixed; pointer-events:none; }
33
+ .ui > *, a.ui { pointer-events:auto; }
34
+ #top { top:16px; left:16px; right:16px; display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap; }
35
+ .brand { flex:none; letter-spacing:.14em; text-transform:uppercase; font-size:11px; display:flex; align-items:center; gap:8px; }
36
+ .brand i { width:7px; height:7px; border-radius:50%; background:var(--accent); box-shadow:0 0 10px var(--accent); }
37
+ .sw { display:flex; gap:8px; padding:6px 8px; border:1px solid var(--line); border-radius:999px; background:var(--panel); }
38
+ .sw button { width:16px; height:16px; border-radius:50%; border:1px solid rgba(255,255,255,.25); cursor:pointer; padding:0; }
39
+ .sw button[aria-pressed=true] { outline:2px solid var(--ink); outline-offset:2px; }
40
+ #ctl { top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px; max-height:calc(100vh - 140px); overflow:auto; }
41
+ .ctl button { all:unset; pointer-events:auto; cursor:pointer; /* all:unset would otherwise inherit pointer-events:none from .ui */
42
+ padding:7px 10px; border:1px solid var(--line); border-radius:8px; background:var(--panel); display:flex; justify-content:space-between; color:var(--dim); }
43
+ .ctl button:hover { color:var(--ink); }
44
+ .ctl button.on { color:var(--ink); border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
45
+ .ctl button kbd { font:inherit; opacity:.7; }
46
+ .ctl .sep { height:6px; flex:none; }
47
+ .rng { display:flex; flex-wrap:wrap; justify-content:space-between; gap:6px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:var(--panel); color:var(--dim); }
48
+ .rng input { width:100%; accent-color:var(--accent); margin:0; }
49
+ .rng output { color:var(--ink); }
50
+ #info { left:16px; bottom:62px; max-width:min(400px, calc(100vw - 32px)); padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:var(--panel); }
51
+ #info h2 { margin:0 0 4px; font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:var(--accent); font-weight:600; }
52
+ #info p { margin:0; }
53
+ #hint { left:16px; bottom:34px; color:var(--dim); }
54
+ #attr { left:16px; bottom:14px; right:220px; color:var(--dim); font-size:10px; }
55
+ #attr a { color:var(--dim); }
56
+ #read { right:16px; bottom:14px; text-align:right; }
57
+ #credit { right:16px; bottom:34px; font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--dim); text-decoration:none; }
58
+ #load { left:50%; top:50%; transform:translate(-50%,-50%); color:var(--dim); letter-spacing:.12em; text-transform:uppercase; }
59
+ @media (max-width:1100px) { #hint { display:none; } #info { bottom:56px; } #credit { bottom:auto; top:44px; right:16px; } #ctl { top:72px; } }
60
+ @media (max-width:640px) { #ctl { top:auto; bottom:170px; width:150px; max-height:40vh; } #info { bottom:92px; max-width:calc(100vw - 190px); } #credit, #hint { display:none; } }
61
+ </style>
62
+ <script type="importmap">
63
+ { "imports": {
64
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
65
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
66
+ } }
67
+ </script>
68
+ </head>
69
+ <body>
70
+ <div class="ui" id="top">
71
+ <div class="tops"><div class="brand"><i></i>Iso 3D</div><div class="seg" role="tablist" aria-label="Figure"><button data-fig="tw" role="tab">Typewriter</button><button data-fig="dr" role="tab">Drum machine</button><button data-fig="cs" role="tab">Cassette</button><button data-fig="tm" role="tab">Timer</button><button data-fig="vm" role="tab">Vending</button></div></div>
72
+ <div class="tops"><div class="swatches" id="swatches" role="radiogroup" aria-label="Glow colour"></div><button id="spin" title="Slowly turn the camera around the object" aria-label="Auto revolve around the object" aria-pressed="false">⟳<span>Revolve</span></button><button id="theme" title="Light / dark (T)" aria-label="Switch light or dark theme">◐</button></div>
73
+ </div>
74
+ <div class="ui ctl" id="ctl">
75
+ <button data-act="lights">Lights<kbd>L</kbd></button>
76
+ <button data-act="engines">Engines<kbd>E</kbd></button>
77
+ <button data-act="cabin">Cabin lights<kbd>C</kbd></button>
78
+ <button data-act="door">Cabin doors<kbd>D</kbd></button>
79
+ <div class="sep"></div>
80
+ <button data-act="iso">Iso view<kbd>I</kbd></button>
81
+ <label class="rng">Edge detail <output id="edv">30°</output><input id="ed" type="range" min="12" max="70" step="1" value="30"></label>
82
+ </div>
83
+ <div class="ui ctl hide" id="ctl-tw" style="top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;">
84
+ <button data-tw="demo">Demo<kbd>▶</kbd></button>
85
+ <button data-tw="Enter">Return carriage<kbd>↵</kbd></button>
86
+ <div class="sep"></div>
87
+ <button data-tw="iso">Iso view<kbd></kbd></button>
88
+ </div>
89
+ <div class="ui ctl hide" id="ctl-dr" style="top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;">
90
+ <button data-dr="demo">Demo pattern<kbd>▶</kbd></button>
91
+ <button data-dr="play">Play / stop<kbd>space</kbd></button>
92
+ <div class="sep"></div>
93
+ <button data-dr="iso">Iso view<kbd></kbd></button>
94
+ </div>
95
+ <div class="ui ctl hide" id="ctl-cs" style="top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;">
96
+ <button data-cs="demo">Demo<kbd>▶</kbd></button>
97
+ <button data-cs="play">Play / stop<kbd>space</kbd></button>
98
+ <button data-cs="stop">Stop<kbd>S</kbd></button>
99
+ <div class="sep"></div>
100
+ <button data-cs="iso">Iso view<kbd></kbd></button>
101
+ </div>
102
+ <div class="ui ctl hide" id="ctl-tm" style="top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;">
103
+ <button data-tm="demo">Demo<kbd>▶</kbd></button>
104
+ <button data-tm="start">Start / stop<kbd>space</kbd></button>
105
+ <button data-tm="reset">Reset<kbd>R</kbd></button>
106
+ <div class="sep"></div>
107
+ <button data-tm="iso">Iso view<kbd></kbd></button>
108
+ </div>
109
+ <div class="ui ctl hide" id="ctl-vm" style="top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;">
110
+ <button data-vm="demo">Demo<kbd>▶</kbd></button>
111
+ <button data-vm="coin">Insert coin<kbd>space</kbd></button>
112
+ <button data-vm="clear">Clear / refund<kbd>esc</kbd></button>
113
+ <div class="sep"></div>
114
+ <button data-vm="iso">Iso view<kbd></kbd></button>
115
+ </div>
116
+ <div class="ui" id="info"><h2>Click any part</h2><p>Drag to orbit, scroll to zoom, right-drag to pan. ↑ ↓ climb and descend once the rotor is at speed.</p></div>
117
+ <div class="ui" id="hint">L lights · E engines · ↑↓ climb · C cabin lights · D doors · I iso · T theme</div>
118
+ <a class="ui" id="credit" href="https://iso-glow.vercel.app" target="_blank" rel="noopener">Made with ISO-GLOW ↗</a>
119
+ <div class="ui" id="read"></div>
120
+
121
+ <script type="module">
122
+ import * as THREE from 'three';
123
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
124
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
125
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
126
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
127
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
128
+ import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
129
+ import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
130
+ import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
131
+ import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
132
+ import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
133
+
134
+ // Source model units are cm, Z-up, nose toward −y, starboard toward −x (after mirroring).
135
+ // Three.js: X = x, Y = z, Z = −y (metres).
136
+ const V = (x, y, z) => new THREE.Vector3(x / 100, z / 100, -y / 100);
137
+ const themeHooks = []; // per-figure theme updates
138
+ let active = 'heli'; // which figure is on screen
139
+ let camAnim = null; // iso-view snap animation
140
+
141
+ // ── renderer, scene, camera ──
142
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
143
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
144
+ renderer.setSize(innerWidth, innerHeight);
145
+ document.body.prepend(renderer.domElement);
146
+ const scene = new THREE.Scene();
147
+ scene.background = new THREE.Color(0x121314);
148
+ const persp = new THREE.PerspectiveCamera(28, innerWidth / innerHeight, 0.1, 400);
149
+ // true isometric: an orthographic camera (parallel edges stay parallel, like the iso figures); Iso view toggles it
150
+ const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 400);
151
+ let orthoH = 10; // frustum height (m) at zoom 1
152
+ let camera = persp;
153
+ const TARGET = new THREE.Vector3(0, 2.0, 0.5), ISO = new THREE.Vector3(-1, 1, 1).normalize();
154
+ const fitDist = () => 40 * Math.max(1, 1.45 / persp.aspect);
155
+ camera.position.copy(TARGET).addScaledVector(ISO, fitDist());
156
+ const controls = new OrbitControls(camera, renderer.domElement);
157
+ controls.target.copy(TARGET); controls.enableDamping = true; controls.maxPolarAngle = Math.PI * 0.495; controls.minDistance = 4; controls.maxDistance = 120;
158
+
159
+ const hemi = new THREE.HemisphereLight(0xdde4ff, 0x080808, 0.7); scene.add(hemi);
160
+ const ambient = new THREE.AmbientLight(0xffffff, 0); scene.add(ambient);
161
+ const sun = new THREE.DirectionalLight(0xffffff, 0.7); sun.position.set(6, 14, 10); scene.add(sun);
162
+ const fill = new THREE.DirectionalLight(0x8899bb, 0.2); fill.position.set(-10, 4, -8); scene.add(fill);
163
+
164
+ // screen-space soft gradients + dither, so glows and light pools stay smooth instead of banded or blocky
165
+ const NOISE = `float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }`;
166
+ const composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(innerWidth, innerHeight, { samples: 4, type: THREE.HalfFloatType }));
167
+ const renderPass = new RenderPass(scene, camera); composer.addPass(renderPass);
168
+ composer.addPass(new OutputPass());
169
+ composer.addPass(new ShaderPass({
170
+ uniforms: { tDiffuse: { value: null } },
171
+ vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
172
+ fragmentShader: `uniform sampler2D tDiffuse; varying vec2 vUv; ${NOISE}
173
+ void main() { vec4 c = texture2D(tDiffuse, vUv); c.rgb += (hash(gl_FragCoord.xy) - 0.5) / 255.0; gl_FragColor = c; }`,
174
+ }));
175
+ composer.setSize(innerWidth, innerHeight); // render targets at device resolution (a custom target starts at CSS size → blurry on Retina)
176
+
177
+ // ── palette & materials ──
178
+ const PALETTE = [['Sodium', '#ff9d3c'], ['Tungsten', '#ffd27a'], ['Neon', '#ff4fa3'], ['Signal', '#3ee08f'], ['Dusk', '#7b8cff'], ['Moon', '#cfe3ff']];
179
+ const accent = new THREE.Color(PALETTE[0][1]);
180
+ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1b1c1f, roughness: 0.9, metalness: 0.05, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
181
+ const bladeMat = new THREE.MeshStandardMaterial({ color: 0x1d1e21, roughness: 0.8, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
182
+ const padMat = new THREE.MeshBasicMaterial({ color: 0x18191b }); // unlit, so cabin lights don't leak through the hull onto the deck
183
+ // tinted see-through glass: the lit cockpit and cabin show through it, it doesn't glow by itself
184
+ const glassMat = new THREE.MeshStandardMaterial({ color: 0x0b0d10, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.55, depthWrite: false, side: THREE.DoubleSide, emissive: accent.clone(), emissiveIntensity: 0 });
185
+ const devRes = () => renderer.getDrawingBufferSize(new THREE.Vector2());
186
+ // two tiers like the iso figures: silhouette outline (--line) and crease/detail edges (--detail); always 1 CSS px
187
+ // three tiers, as in the iso figures: silhouette (--line) > major folds > minor detail (--detail, barely above the face fill)
188
+ const LINE_COL = new THREE.Color(0x4f5357), MAJOR_COL = new THREE.Color(0x393c3f), EDGE_COL = new THREE.Color(0x27292c), BG_COL = new THREE.Color(0x121314);
189
+ const MAJOR_ANGLE = 60;
190
+ // lines are pulled slightly toward the camera so the surface they sit on never hides parts of them
191
+ const lineOpts = { linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -6 };
192
+ const lineMat = new LineMaterial({ color: EDGE_COL, ...lineOpts });
193
+ const majorMat = new LineMaterial({ color: MAJOR_COL, ...lineOpts });
194
+ const outlineMat = new THREE.ShaderMaterial({
195
+ uniforms: { color: { value: LINE_COL.clone() }, thickness: { value: 1 }, res: { value: devRes() } },
196
+ vertexShader: `uniform float thickness; uniform vec2 res;
197
+ void main() {
198
+ vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
199
+ vec4 n = projectionMatrix * vec4(normalize(normalMatrix * normal), 0.0);
200
+ vec2 d = length(n.xy) > 1e-5 ? normalize(n.xy) : vec2(0.0);
201
+ p.xy += d * thickness * 2.0 / res * p.w;
202
+ gl_Position = p;
203
+ }`,
204
+ fragmentShader: `uniform vec3 color; void main() { gl_FragColor = vec4(color, 1.0); }`,
205
+ side: THREE.BackSide,
206
+ // push the silhouette shell behind the hull: seen from inside the cabin it would otherwise sit exactly on the inner walls and z-fight
207
+ polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24,
208
+ });
209
+ const accentMats = [];
210
+ const glowMat = (base = 0x1a1d22) => { const m = new THREE.MeshStandardMaterial({ color: base, emissive: accent.clone(), emissiveIntensity: 0, roughness: 0.35 }); accentMats.push(m); return m; };
211
+ const navMat = glowMat(), beaconMat = glowMat(), padLightMat = glowMat();
212
+ // lamp halos: one screen-facing point per lamp, shaded per pixel (no full-screen bloom, so no blocky upsampled halos)
213
+ const glowMats = [];
214
+ function glowPoints(positions, size) {
215
+ const g = new THREE.BufferGeometry().setFromPoints(positions);
216
+ const m = new THREE.ShaderMaterial({
217
+ uniforms: { color: { value: accent.clone() }, intensity: { value: 0 }, size: { value: size }, scale: { value: 1 }, light: { value: 0 }, ortho: { value: 0 } },
218
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false,
219
+ // the sprite is pulled half its size toward the camera so the deck it sits on can't clip its lower half
220
+ vertexShader: `uniform float size, scale, ortho;
221
+ void main() {
222
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
223
+ gl_PointSize = ortho > 0.5 ? size * scale : size * scale / -mv.z;
224
+ mv.xyz -= normalize(mv.xyz) * size * 0.5;
225
+ gl_Position = projectionMatrix * mv;
226
+ }`,
227
+ fragmentShader: `uniform vec3 color; uniform float intensity, light; ${NOISE}
228
+ void main() {
229
+ float d = length(gl_PointCoord - 0.5) * 2.0; if (d > 1.0) discard;
230
+ float halo = exp(-d * d * 9.0) * (1.0 - d);
231
+ float core = 1.0 - smoothstep(0.05, 0.12, d);
232
+ float a = (halo * 0.7 + core * 0.9) * intensity + (hash(gl_FragCoord.xy) - 0.5) / 255.0;
233
+ vec3 c = mix(color, vec3(1.0), core * 0.6 * (1.0 - light));
234
+ // dark theme adds light; light theme lays the halo over the pale deck as a tint
235
+ gl_FragColor = light > 0.5 ? vec4(c, clamp(a, 0.0, 1.0)) : vec4(c * max(a, 0.0), 1.0);
236
+ }`,
237
+ });
238
+ if (theme === 'light') { m.blending = THREE.NormalBlending; m.uniforms.light.value = 1; } // lamps created after a theme switch
239
+ glowMats.push(m);
240
+ return new THREE.Points(g, m);
241
+ }
242
+ // soft radial pool (light on the deck, or the aircraft's shadow), computed per pixel and dithered
243
+ const softGlows = []; // additive halos / light pools / haze: shown in the dark theme only
244
+ const softMat = (color, additive) => { const m = softMat0(color, additive); if (additive) { softGlows.push(m); } return m; };
245
+ function softMat0(color, additive) { return new THREE.ShaderMaterial({
246
+ uniforms: { color: { value: new THREE.Color(color) }, opacity: { value: 0 } },
247
+ transparent: true, depthWrite: false, blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, toneMapped: false,
248
+ vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
249
+ fragmentShader: `uniform vec3 color; uniform float opacity; varying vec2 vUv; ${NOISE}
250
+ void main() {
251
+ float d = length(vUv - 0.5) * 2.0;
252
+ float a = smoothstep(1.0, 0.0, d); a *= a;
253
+ a = a * opacity + (hash(gl_FragCoord.xy) - 0.5) / 255.0;
254
+ gl_FragColor = vec4(color, max(a, 0.0));
255
+ }`,
256
+ }); }
257
+ const spotMat = softMat(0xffffff, true), spillMat = softMat(0xffffff, true), shadowMat = softMat(0x000000, false);
258
+ const cabinLight = new THREE.PointLight(0xffffff, 0, 2.3, 2), cockpitLight = new THREE.PointLight(0xffffff, 0, 1.9, 2);
259
+ // ── themes (same tokens as the iso figures' light and dark themes) ──
260
+ const THEMES = {
261
+ dark: { bg: 0x121314, body: 0x1b1c1f, blade: 0x1d1e21, pad: 0x18191b, lamp: 0x1a1d22, line: 0x4f5357, major: 0x393c3f, edge: 0x27292c,
262
+ sky: 0xdde4ff, ground: 0x080808, hemi: 0.7, amb: 0, sun: 0.7, fill: 0.2, shadow: 0.7, fadeMin: 0.45 },
263
+ light: { bg: 0xe9eaea, body: 0xf1f1f0, blade: 0xececeb, pad: 0xf6f6f5, lamp: 0xd8dadb, line: 0x5d6266, major: 0x8a8f93, edge: 0xb9bcbf,
264
+ sky: 0xffffff, ground: 0xe6e6e6, hemi: 0.9, amb: 1.6, sun: 0.35, fill: 0.15, shadow: 0.16, fadeMin: 0.8 },
265
+ };
266
+ let theme = 'dark', accentHex = PALETTE[0][1];
267
+ function setAccent(hex) {
268
+ accentHex = hex;
269
+ accent.set(hex);
270
+ accentMats.forEach(m => m.emissive.copy(accent));
271
+ glassMat.emissive.copy(accent);
272
+ glowMats.forEach(m => m.uniforms.color.value.copy(accent));
273
+ [spotMat, spillMat].forEach(m => m.uniforms.color.value.copy(accent));
274
+ cabinLight.color.copy(accent); cockpitLight.color.copy(accent);
275
+ document.documentElement.style.setProperty('--accent', '#' + accent.getHexString());
276
+ }
277
+
278
+ // ── line helpers ──
279
+ const fatLines = [];
280
+ const fat = (arr, mat = lineMat) => { const l = new LineSegments2(new LineSegmentsGeometry().setPositions(arr), mat); fatLines.push(l); return l; };
281
+ const loopL = (pts, mat = majorMat) => { const out = []; for (let i = 0; i < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; out.push(a.x, a.y, a.z, b.x, b.y, b.z); } return fat(out, mat); };
282
+ function glassPane(ps) {
283
+ const v = ps.map(p => V(...p)), g = new THREE.BufferGeometry().setFromPoints([v[0], v[1], v[2], v[0], v[2], v[3]]);
284
+ g.computeVertexNormals(); const m = new THREE.Mesh(g, glassMat); m.renderOrder = 2; return m;
285
+ }
286
+ function addOutline(m) { const o = new THREE.Mesh(m.geometry, outlineMat); o.userData.outline = true; m.add(o); }
287
+
288
+ // ── part catalogue (names match nodes in the embedded parts file) ──
289
+ const INFO = {
290
+ fuselage: ['Airframe & cabin', 'Aluminium and composite semi-monocoque airframe. The SH-60B flies with a crew of three: pilot, airborne tactical officer and sensor operator, who works from the cabin.'],
291
+ mainRotor: ['Main rotor', 'Four composite blades on titanium spars, 16.4 m diameter, on an elastomeric-bearing hub that needs no lubrication. Turns counter-clockwise seen from above at about 258 rpm; on the Seahawk the blades fold aft for ship stowage.'],
292
+ tailRotor: ['Tail rotor', 'Four-blade bearingless tail rotor, 3.4 m diameter, on the starboard side of the pylon. Canted about 20° on the real aircraft so it adds a little lift as well as anti-torque thrust.'],
293
+ tailCone: ['Tail cone & pylon', 'Carries the tail drive shaft, the 42° intermediate gearbox at the pylon base and the 90° tail gearbox on top. On the Seahawk the pylon folds forward so the aircraft fits a frigate hangar.'],
294
+ stabilator: ['Stabilator', 'All-moving horizontal tail. A computer sets its angle from airspeed and collective, nose-down in the hover so downwash does not pitch the aircraft up.'],
295
+ engines: ['Engine exhausts & inlet fairings', 'External parts of the two General Electric T700 turboshafts (about 1,400 kW / 1,900 shp each on the SH-60B), mounted side by side on the cabin roof aft of the main transmission.'],
296
+ door: ['Starboard cabin door', 'Sliding cabin door; it runs aft on external rails to open the cabin, where the sensor operator rides.'],
297
+ doorPort: ['Port cabin door', 'Sliding cabin door on the port side, running aft on external rails like the starboard one.'],
298
+ gearL: ['Port main gear', 'Main gear leg with a two-stage oleo: the second stage absorbs heavy landings on a pitching deck. On the Seahawk the main wheels sit well forward, under the cockpit.'],
299
+ gearR: ['Starboard main gear', 'Main gear leg with a two-stage oleo: the second stage absorbs heavy landings on a pitching deck. On the Seahawk the main wheels sit well forward, under the cockpit.'],
300
+ tailGear: ['Twin tail wheel', 'Moved forward on the SH-60B compared with the Black Hawk, shortening the wheelbase to fit small ship decks.'],
301
+ };
302
+
303
+ // ── scene graph ──
304
+ const heli = new THREE.Group(); scene.add(heli);
305
+ const parts = {}, modelMeshes = [];
306
+ let navGlow, beaconGlow, padGlow;
307
+ let doorPortMesh;
308
+ let mainRotor, tailRotor, mrAxis = new THREE.Vector3(0, 1, 0), trAxis = new THREE.Vector3(1, 0, 0), doorMesh;
309
+ let edgeAngle = 30;
310
+
311
+ function makePart(key) {
312
+ const [name, text] = INFO[key];
313
+ const g = new THREE.Group(); g.userData = { info: { name, text } };
314
+ heli.add(g); parts[key] = g; return g;
315
+ }
316
+ // Crease lines that stay continuous: an edge sharper than the threshold starts a line, and the line is then followed
317
+ // through softer neighbouring edges (down to half the threshold) as long as it keeps going straight on. Tiny isolated
318
+ // fragments are dropped. Testing each edge on its own (EdgesGeometry) chops gently curving panel lines into dashes.
319
+ function creaseInfo(geo) {
320
+ if (geo.userData.crease) return geo.userData.crease;
321
+ const src = new THREE.BufferGeometry().setAttribute('position', geo.attributes.position);
322
+ if (geo.index) src.setIndex(geo.index); // keep the triangle list, or vertices get paired up wrongly
323
+ const g = mergeVertices(src, 1e-4);
324
+ const p = g.attributes.position, ix = g.index.array, nV = p.count;
325
+ const P = i => new THREE.Vector3().fromBufferAttribute(p, i);
326
+ const faceN = [], edgeFaces = new Map();
327
+ for (let f = 0; f < ix.length / 3; f++) {
328
+ const a = ix[3 * f], b = ix[3 * f + 1], c = ix[3 * f + 2];
329
+ const n = new THREE.Vector3().crossVectors(P(b).sub(P(a)), P(c).sub(P(a)));
330
+ faceN.push(n.lengthSq() > 1e-14 ? n.normalize() : null);
331
+ for (const [u, v] of [[a, b], [b, c], [c, a]]) {
332
+ if (u === v) continue;
333
+ const k = Math.min(u, v) * nV + Math.max(u, v);
334
+ (edgeFaces.get(k) || edgeFaces.set(k, []).get(k)).push(f);
335
+ }
336
+ }
337
+ const edges = [];
338
+ for (const [k, fs] of edgeFaces) {
339
+ const a = Math.floor(k / nV), b = k % nV;
340
+ const ns = fs.map(f => faceN[f]).filter(Boolean);
341
+ let ang = 180; // open border: always a line
342
+ if (ns.length >= 2) { ang = 0; for (let i = 1; i < ns.length; i++) ang = Math.max(ang, THREE.MathUtils.radToDeg(ns[0].angleTo(ns[i]))); }
343
+ else if (ns.length === 0) continue;
344
+ const dir = P(b).sub(P(a)), len = dir.length();
345
+ if (len > 0) edges.push({ a, b, ang, dir: dir.divideScalar(len), len });
346
+ }
347
+ const byVert = new Map();
348
+ edges.forEach((e, i) => { for (const v of [e.a, e.b]) (byVert.get(v) || byVert.set(v, []).get(v)).push(i); });
349
+ return (geo.userData.crease = { p, edges, byVert });
350
+ }
351
+ const GROW = 0.55, STRAIGHT = 0.9; // follow a crease through edges ≥ 55 % of the threshold that carry on within ~25°
352
+ function creaseLines(geo, thr) {
353
+ const { p, edges, byVert } = creaseInfo(geo), low = thr * GROW;
354
+ const on = new Uint8Array(edges.length), queue = [];
355
+ edges.forEach((e, i) => { if (e.ang >= thr) { on[i] = 1; queue.push(i); } });
356
+ while (queue.length) { // grow lines through softer, straight-on neighbours
357
+ const i = queue.pop(), e = edges[i];
358
+ for (const [v, out] of [[e.a, -1], [e.b, 1]]) {
359
+ for (const j of byVert.get(v)) {
360
+ if (on[j]) continue; const f = edges[j]; if (f.ang < low) continue;
361
+ const d = f.dir.dot(e.dir) * (f.a === v ? 1 : -1) * out; // > 0 when f carries on in the same direction
362
+ if (d > STRAIGHT) { on[j] = 1; queue.push(j); }
363
+ }
364
+ }
365
+ }
366
+ // drop fragments shorter than 6 cm (union-find over shared vertices)
367
+ const parent = new Map(), find = x => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
368
+ edges.forEach((e, i) => { if (!on[i]) return; for (const v of [e.a, e.b]) if (!parent.has(v)) parent.set(v, v); parent.set(find(e.a), find(e.b)); });
369
+ const total = new Map(); edges.forEach((e, i) => { if (on[i]) { const r = find(e.a); total.set(r, (total.get(r) || 0) + e.len); } });
370
+ const out = [];
371
+ edges.forEach((e, i) => { if (on[i] && total.get(find(e.a)) >= 0.06) out.push(p.getX(e.a), p.getY(e.a), p.getZ(e.a), p.getX(e.b), p.getY(e.b), p.getZ(e.b)); });
372
+ return out;
373
+ }
374
+ function rebuildEdges() {
375
+ for (const m of modelMeshes) {
376
+ if (m.material === glassMat) continue;
377
+ for (const e of m.userData.edges || []) { m.remove(e); e.geometry.dispose(); fatLines.splice(fatLines.indexOf(e), 1); }
378
+ const minor = fat(creaseLines(m.geometry, edgeAngle), lineMat);
379
+ const major = fat(creaseLines(m.geometry, Math.max(edgeAngle, MAJOR_ANGLE)), majorMat);
380
+ major.renderOrder = 1; // drawn after, so a fold that is in both sets shows in the brighter tier
381
+ m.userData.edges = [minor, major]; m.add(minor, major);
382
+ }
383
+ }
384
+
385
+ function buildFromModel(gltf) {
386
+ const root = gltf.scene;
387
+ root.updateMatrixWorld(true);
388
+ const nodes = {};
389
+ root.traverse(o => { if (o.isMesh || (o.children.length && o.name)) nodes[o.name] = nodes[o.name] || o; });
390
+ for (const key of Object.keys(INFO)) {
391
+ const src = nodes[key]; if (!src) continue;
392
+ const g = makePart(key);
393
+ const holder = new THREE.Group(); // keeps the node's pivot (rotors) in metres
394
+ holder.position.copy(src.getWorldPosition(new THREE.Vector3())).multiplyScalar(0.01);
395
+ g.add(holder);
396
+ src.traverse(o => {
397
+ if (!o.isMesh) return;
398
+ const geo = o.geometry.clone();
399
+ geo.applyMatrix4(new THREE.Matrix4().makeScale(0.01, 0.01, 0.01));
400
+ geo.computeVertexNormals();
401
+ const glass = key === 'glass';
402
+ const m = new THREE.Mesh(geo, glass ? glassMat : /Rotor$/.test(key) ? bladeMat : bodyMat);
403
+ if (glass) m.renderOrder = 2; else addOutline(m);
404
+ modelMeshes.push(m); holder.add(m);
405
+ });
406
+ const ax = src.userData.axis; // Blender-space spin axis → Three.js
407
+ if (key === 'mainRotor') { mainRotor = holder; if (ax) mrAxis.set(ax[0], ax[2], -ax[1]).normalize(); }
408
+ if (key === 'tailRotor') { tailRotor = holder; if (ax) trAxis.set(ax[0], ax[2], -ax[1]).normalize(); }
409
+ if (key === 'door') doorMesh = holder;
410
+ if (key === 'doorPort') doorPortMesh = holder;
411
+ }
412
+ rebuildEdges();
413
+ if (mrAxis.y < 0) mrAxis.negate();
414
+
415
+ // exterior lights placed from the geometry: beacons on the pylon top and cabin roof, position lights at the stabilator tips
416
+ const extreme = (key, f) => { let best = null, bv = -Infinity; parts[key].traverse(o => { if (!o.isMesh || o.userData.outline) return; const p = o.geometry.attributes.position, w = new THREE.Vector3();
417
+ for (let i = 0; i < p.count; i += 3) { w.fromBufferAttribute(p, i).applyMatrix4(o.matrixWorld); const s = f(w); if (s > bv) { bv = s; best = w.clone(); } } }); return best; };
418
+ heli.updateMatrixWorld(true);
419
+ const beacon = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.12, 32), beaconMat); beacon.position.copy(extreme('tailCone', v => v.y)).add(new THREE.Vector3(0, 0.06, 0)); parts.tailCone.add(beacon);
420
+ const navPos = [v => v.x, v => -v.x].map(f => { const n = new THREE.Mesh(new THREE.SphereGeometry(0.06, 32, 16), navMat); n.position.copy(extreme('stabilator', f)); parts.stabilator.add(n); return n.position.clone(); });
421
+ const roof = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.1, 32), beaconMat); roof.position.copy(extreme('fuselage', v => v.y - Math.abs(v.z + 0.5) * 0.5)).add(new THREE.Vector3(0, 0.05, 0)); parts.fuselage.add(roof);
422
+ navGlow = glowPoints(navPos, 0.9); parts.stabilator.add(navGlow);
423
+ beaconGlow = glowPoints([beacon.position, roof.position], 1.6); heli.add(beaconGlow);
424
+
425
+ // interior lights: a dome light in the cabin and a dim panel glow on the flight deck; they light the inside of the
426
+ // hull and show through the windows and the open door
427
+ cabinLight.position.copy(V(-20, -260, 140)); parts.fuselage.add(cabinLight);
428
+ cockpitLight.position.copy(V(0, -660, 130)); parts.fuselage.add(cockpitLight);
429
+ document.getElementById('load').remove();
430
+ }
431
+
432
+ // ── deck (world) ──
433
+ const pad = new THREE.Group(); scene.add(pad);
434
+ { const b = new THREE.Mesh(new THREE.BoxGeometry(17, 0.12, 24), padMat); b.position.set(0, -0.07, -0.5); pad.add(b);
435
+ b.add(fat(new THREE.EdgesGeometry(b.geometry, 30).attributes.position.array, majorMat));
436
+ const circ = (r, cz) => loopL(Array.from({ length: 160 }, (_, i) => { const t = i / 160 * Math.PI * 2; return new THREE.Vector3(r * Math.cos(t), 0.005, cz + r * Math.sin(t)); }));
437
+ pad.add(circ(6.2, 2.0), circ(5.9, 2.0));
438
+ pad.add(loopL([[-8.2, -12.2], [8.2, -12.2], [8.2, 11.2], [-8.2, 11.2]].map(([x, z]) => new THREE.Vector3(x, 0.005, z))));
439
+ const L = [];
440
+ for (let z = -11.5; z <= 10.6; z += 2.2) L.push([-8, z], [8, z]);
441
+ for (let x = -6; x <= 6; x += 2) L.push([x, 11], [x, -12]);
442
+ const lampGeo = new THREE.SphereGeometry(0.07, 32, 16);
443
+ L.forEach(([x, z]) => { const m = new THREE.Mesh(lampGeo, padLightMat); m.position.set(x, 0.04, z); pad.add(m); });
444
+ padGlow = glowPoints(L.map(([x, z]) => new THREE.Vector3(x, 0.06, z)), 1.3); pad.add(padGlow);
445
+ }
446
+ const flat = (w, h, mat, x, z, y = 0.01) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat); m.rotation.x = -Math.PI / 2; m.position.set(x, y, z); pad.add(m); return m; };
447
+ flat(5.2, 19, shadowMat, 0, 0);
448
+ flat(3.6, 5.2, spotMat, 0, 11.2, 0.02); // searchlight pool ahead of the nose
449
+ flat(2.6, 2.4, spillMat, -2.5, 2.55, 0.02); // light falling out of the open cabin doors
450
+ flat(2.4, 2.2, spillMat, 2.5, 3.1, 0.02);
451
+
452
+ // ══ Typewriter (Fig 3 of the iso figures, rebuilt in 3D from the same box measurements) ══
453
+ // Iso figure units; iso x → X, iso z → Y (up), iso y → Z (toward the front). 1 unit = 1 cm.
454
+ const TS = 0.01;
455
+ const W = (x, y, z) => new THREE.Vector3(x * TS, z * TS, y * TS);
456
+ let twShadowMat;
457
+ const tw = new THREE.Group(); tw.visible = false; scene.add(tw);
458
+ const twMeshes = [], twKeys = new Map(), twFlashes = [];
459
+ const recessMat = new THREE.MeshStandardMaterial({ color: 0x0f1011, roughness: 0.95, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
460
+ const capMat = () => new THREE.MeshStandardMaterial({ color: 0x1b1c1f, roughness: 0.9, side: THREE.DoubleSide, emissive: accent.clone(), emissiveIntensity: 0, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
461
+ const twCaps = [];
462
+
463
+ function roundRect(sh, x, y, w, h, r) {
464
+ sh.moveTo(x + r, y); sh.lineTo(x + w - r, y); sh.quadraticCurveTo(x + w, y, x + w, y + r);
465
+ sh.lineTo(x + w, y + h - r); sh.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
466
+ sh.lineTo(x + r, y + h); sh.quadraticCurveTo(x, y + h, x, y + h - r);
467
+ sh.lineTo(x, y + r); sh.quadraticCurveTo(x, y, x + r, y);
468
+ }
469
+ // iso box(x,y,z,w,d,h,r): rounded in plan, sharp top and bottom rims — exactly how the figure draws a box
470
+ function rbox(x, y, z, w, d, h, r = 0, mat = bodyMat, parent = tw, { lines = true } = {}) {
471
+ r = Math.max(0.01, Math.min(r, w / 2 - 0.01, d / 2 - 0.01));
472
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, r);
473
+ const g = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: 10 });
474
+ g.rotateX(-Math.PI / 2); g.translate(0, z, 0); g.scale(TS, TS, TS);
475
+ const m = new THREE.Mesh(g, mat);
476
+ if (lines) { addOutline(m); twMeshes.push(m); }
477
+ parent.add(m); return m;
478
+ }
479
+ // flat recess panel lying on a top face (the figure's .face.recess insets)
480
+ function inset(x, y, z, w, d, r, parent = tw) {
481
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, Math.min(r, w / 2, d / 2));
482
+ const g = new THREE.ShapeGeometry(sh, 12); g.rotateX(-Math.PI / 2); g.translate(0, z + 0.05, 0); g.scale(TS, TS, TS);
483
+ const m = new THREE.Mesh(g, recessMat); parent.add(m);
484
+ const pts = sh.getPoints(12).map(p => W(p.x, -p.y, z + 0.06));
485
+ parent.add(loopL(pts, lineMat)); return m;
486
+ }
487
+ const seg = (arr, parent = tw, mat = lineMat) => parent.add(fat(arr.flatMap(([a, b]) => [...W(...a).toArray(), ...W(...b).toArray()]), mat));
488
+ // slits on a FRONT face (y = const): vertical hairlines from z0 down to z1 every `step` along x
489
+ const slitsFront = (x0, x1, step, y, z0, z1, parent = tw) => { const s = []; for (let x = x0; x <= x1 + 1e-6; x += step) s.push([[x, y + 0.08, z0], [x, y + 0.08, z1]]); seg(s, parent); };
490
+ const slitsFrontH = (z0, z1, step, y, x0, x1, parent = tw) => { const s = []; for (let z = z0; z >= z1 - 1e-6; z -= step) s.push([[x0, y + 0.08, z], [x1, y + 0.08, z]]); seg(s, parent); };
491
+ const slitsSide = (y0, y1, step, x, z0, z1, parent = tw) => { const s = []; for (let y = y0; y >= y1 - 1e-6; y -= step) s.push([[x + 0.08, y, z0], [x + 0.08, y, z1]]); seg(s, parent); };
492
+ const slitsTop = (y0, y1, step, z, x0, x1, parent = tw) => { const s = []; for (let y = y0; y <= y1 + 1e-6; y += step) s.push([[x0, y, z + 0.08], [x1, y, z + 0.08]]); seg(s, parent); };
493
+
494
+ // text printed on a surface (key legends, the housing label): a small canvas texture, redrawn on theme change
495
+ const twLabels = [];
496
+ function label(text, w, h, size, place, parent = tw, { spacing = 0, align = 'center' } = {}) {
497
+ const P = 24, c = document.createElement('canvas'); c.width = Math.ceil(w * P); c.height = Math.ceil(h * P);
498
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
499
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false }));
500
+ const draw = col => { const x = c.getContext('2d'); x.clearRect(0, 0, c.width, c.height); x.fillStyle = col;
501
+ x.font = `${size * P}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`; x.textAlign = align; x.textBaseline = 'middle';
502
+ if (spacing) x.letterSpacing = `${spacing * P}px`; x.fillText(text, align === 'left' ? 0 : c.width / 2, c.height / 2 + size * P * 0.06); tex.needsUpdate = true; };
503
+ twLabels.push(draw); place(m); parent.add(m); return m;
504
+ }
505
+ const onTop = (cx, cy, z) => m => { m.rotation.x = -Math.PI / 2; m.position.copy(W(cx, cy, z + 0.12)); };
506
+ const onFront = (cx, y, cz) => m => { m.position.copy(W(cx, y + 0.12, cz)); };
507
+
508
+ // ── geometry (same numbers as figs/03-typewriter.js) ──
509
+ const CW = 3.5, MAXCOL = 32, MID = 16, HOME = MID * CW, LH = 7.5, SHOW = 6;
510
+ const PLZ = 7, HX = 30, HY = 40, HW = 240, HD = 55, HH = 40, HT = PLZ + HH, HF = HY + HD;
511
+ const BY = HF, BD = 125, BH = 18, BT = PLZ + BH, BF = BY + BD;
512
+ const PX = 30 + HOME, PY = 52, PW = 240, PD = 20, PZ = HT, PH = 20, PT = PZ + PH;
513
+ const SX = 85 + HOME, SY = 61, SW = 130, SZ0 = 50, STOP = 150, MARGIN = 8;
514
+ const STRIKE = SX + MARGIN, BASE = STOP - 78;
515
+
516
+ // plate
517
+ rbox(0, 0, 0, 300, 240, PLZ, 14);
518
+ { const sh = new THREE.Shape(); roundRect(sh, 9, -(9 + 222), 282, 222, 10); tw.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, PLZ + 0.06)), lineMat)); }
519
+ [[18, 18], [282, 18], [18, 222], [282, 222]].forEach(([x, y]) => {
520
+ const s = new THREE.Mesh(new THREE.CylinderGeometry(3 * TS, 3 * TS, 0.1 * TS, 24), recessMat); s.position.copy(W(x, y, PLZ + 0.05)); tw.add(s);
521
+ seg([[[x - 1.8, y, PLZ + 0.2], [x + 1.8, y, PLZ + 0.2]]]);
522
+ });
523
+ inset(222, 226, PLZ, 56, 5, 2.5);
524
+ // rear housing (carriage bed)
525
+ rbox(HX, HY, PLZ, HW, HD, HH, 8);
526
+ inset(HX + 6, HY + 4, HT, HW - 12, 8, 4);
527
+ [60, 180].forEach(cx => {
528
+ const k = new THREE.Mesh(new THREE.CylinderGeometry(8 * TS, 8 * TS, 1.2 * TS, 40), bodyMat); k.position.copy(W(HX + cx, HY + 45, HT + 0.6)); addOutline(k); twMeshes.push(k); tw.add(k);
529
+ tw.add(loopL(Array.from({ length: 40 }, (_, i) => { const t = i / 40 * Math.PI * 2; return W(HX + cx + 4.5 * Math.cos(t), HY + 45 + 4.5 * Math.sin(t), HT + 1.25); }), lineMat));
530
+ const d = new THREE.Mesh(new THREE.CylinderGeometry(1.6 * TS, 1.6 * TS, 0.1 * TS, 16), recessMat); d.position.copy(W(HX + cx, HY + 45, HT + 1.25)); tw.add(d);
531
+ });
532
+ slitsFront(HX + 10, HX + 70, 3, HF, HT - 4, HT - 14);
533
+ slitsFront(HX + 170, HX + 230, 3, HF, HT - 4, HT - 14);
534
+ label('TYPE · 03', 70, 9, 6.5, onFront(HX + HW / 2, HF, HT - 11), tw, { spacing: 2 });
535
+ slitsSide(HF - 8, HF - 44, 3, HX + HW, HT - 6, HT - 30);
536
+ // margin bell on the housing's side face, near the front
537
+ const bellPos = W(HX + HW + 0.15, HF - (HD - 10), HT - 13);
538
+ { const r = new THREE.Mesh(new THREE.CircleGeometry(6.5 * TS, 40), recessMat); r.rotation.y = Math.PI / 2; r.position.copy(bellPos); tw.add(r); }
539
+ const bellMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0, side: THREE.DoubleSide });
540
+ { const b = new THREE.Mesh(new THREE.CircleGeometry(4.2 * TS, 40), bellMat); b.rotation.y = Math.PI / 2; b.position.copy(bellPos).add(new THREE.Vector3(0.0008, 0, 0)); tw.add(b); }
541
+ // key body
542
+ rbox(HX, BY, PLZ, HW, BD, BH, 8);
543
+ inset(HX + 6, BY + 3, BT, HW - 12, BD - 10, 5);
544
+ slitsFrontH(BT - 4, BT - 14, 3, BF, HX + 14, HX + 226);
545
+
546
+ // keys: 4 staggered rows and the space bar; each key = stem + cap, grouped so it can sink
547
+ const ROWS = [
548
+ ['1','2','3','4','5','6','7','8','9','0','-','Backspace'],
549
+ ['q','w','e','r','t','y','u','i','o','p'],
550
+ ['a','s','d','f','g','h','j','k','l',';','Enter'],
551
+ ['z','x','c','v','b','n','m',',','.','/'],
552
+ ];
553
+ const KP = 18, KS = 14, R0Y = BY + 8, RP = 21, SHOWN = { Backspace: '←', Enter: '↵' };
554
+ function makeKey(k, x, y, w, d, stems, capZ, capH, r) {
555
+ const g = new THREE.Group(); g.userData = { key: k, rest: 0 }; tw.add(g);
556
+ stems.forEach(([sx, sy, sw, sd, sh]) => rbox(sx, sy, BT, sw, sd, sh, 1, bodyMat, g));
557
+ const mat = capMat(); twCaps.push(mat);
558
+ rbox(x, y, capZ, w, d, capH, r, mat, g);
559
+ twKeys.set(k, g); return g;
560
+ }
561
+ ROWS.forEach((row, r) => {
562
+ const y = R0Y + r * RP; let x = HX + 14 + r * 6;
563
+ row.forEach(k => {
564
+ const w = k === 'Enter' ? 24 : KS;
565
+ const g = makeKey(k, x, y, w, KS, [[x + w / 2 - 2.5, y + KS / 2 - 2.5, 5, 5, 6]], BT + 6, 3, 7);
566
+ label(SHOWN[k] || k, w, KS, 6.5, onTop(x + w / 2, y + KS / 2, BT + 9), g);
567
+ x += w === KS ? KP : w + 4;
568
+ });
569
+ });
570
+ const SBY = R0Y + 4 * RP + 1;
571
+ makeKey(' ', 80, SBY, 140, 10, [[110, SBY + 3, 5, 4, 5], [185, SBY + 3, 5, 4, 5]], BT + 5, 3.5, 4);
572
+
573
+ // carriage: paper, knobs, platen, return lever (slides along x)
574
+ const carriage = new THREE.Group(); tw.add(carriage);
575
+ const paperCanvas = document.createElement('canvas'), PP = 10; // 10 px per unit → crisp type
576
+ paperCanvas.width = SW * PP; paperCanvas.height = (STOP - SZ0) * PP;
577
+ const paperTex = new THREE.CanvasTexture(paperCanvas); paperTex.colorSpace = THREE.SRGBColorSpace; paperTex.anisotropy = 8;
578
+ const paper = new THREE.Mesh(new THREE.PlaneGeometry(SW * TS, (STOP - SZ0) * TS), new THREE.MeshBasicMaterial({ map: paperTex, toneMapped: false, side: THREE.DoubleSide }));
579
+ paper.position.copy(W(SX + SW / 2, SY, (STOP + SZ0) / 2)); carriage.add(paper);
580
+ carriage.add(loopL([W(SX, SY + 0.1, STOP), W(SX + SW, SY + 0.1, STOP), W(SX + SW, SY + 0.1, SZ0), W(SX, SY + 0.1, SZ0)], majorMat));
581
+ rbox(PX - 10, PY + 3, PZ + 2, 10, PD - 6, PH - 4, 3, bodyMat, carriage);
582
+ rbox(PX, PY, PZ, PW, PD, PH, 6, bodyMat, carriage);
583
+ slitsFrontH(PT - 4, PT - PH + 4, 2.4, PY + PD, PX + 6, PX + PW - 6, carriage);
584
+ slitsTop(PY + 4, PY + PD - 4, 2.4, PT, PX + 6, PX + PW - 6, carriage);
585
+ rbox(PX + PW, PY + 3, PZ + 2, 10, PD - 6, PH - 4, 3, bodyMat, carriage);
586
+ const lever = new THREE.Group(); lever.userData = { key: 'Enter', lever: true, rest: 0 }; carriage.add(lever);
587
+ const leverMat = capMat(); twCaps.push(leverMat);
588
+ rbox(PX - 18, PY + 8, PT - 3, 6, 4, 4, 1, leverMat, lever);
589
+ rbox(PX - 18, PY + 12, PT - 2, 4, 40, 3, 1.5, leverMat, lever);
590
+ rbox(PX - 21, PY + 50, PT - 3, 10, 8, 6, 3, leverMat, lever);
591
+ // type guide at the strike point (fixed) with its LED
592
+ rbox(STRIKE - 5, PY + PD + 1, HT, 10, 5, 7, 1.5);
593
+ seg([[[STRIKE, PY + PD + 1, HT + 7.1], [STRIKE, PY + PD + 6, HT + 7.1]]]);
594
+ const guideMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0, side: THREE.DoubleSide });
595
+ { const l = new THREE.Mesh(new THREE.CircleGeometry(1.4 * TS, 24), guideMat); l.position.copy(W(STRIKE, PY + PD + 6.12, HT + 3.5)); tw.add(l); }
596
+ const twGlow = glowPoints([bellPos.clone().add(new THREE.Vector3(0.02, 0, 0)), W(STRIKE, PY + PD + 6.5, HT + 3.5)], 0.22);
597
+ tw.add(twGlow);
598
+ // warm haze on the plate while typing (the figure's power glow)
599
+ const hazeMat = softMat(0xffffff, true);
600
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(4.2, 3.6), hazeMat); h.rotation.x = -Math.PI / 2; h.position.copy(W(150, 120, -0.5)); tw.add(h); }
601
+ { const sh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 3.0), softMat(0x000000, false)); sh.rotation.x = -Math.PI / 2; sh.position.copy(W(150, 125, -0.8)); sh.material.uniforms.opacity.value = 0.5; tw.add(sh); twShadowMat = sh.material; }
602
+ // crease lines for every solid part (fixed tiers: folds ≥ 60° bright, softer ones dim)
603
+ for (const m of twMeshes) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); }
604
+
605
+ // ── typewriter state and behaviour (as in the figure) ──
606
+ const tws = { lines: [[]], col: 0, key: null, last: null, lastAt: -1e9, rang: false, carX: 0, returning: false, feed: 0, dirty: true };
607
+ function paperColors() { const light = theme === 'light'; return { paper: light ? '#f3f3f2' : '#1b1d1f', ink: light ? '#16181a' : '#e6e8e9', margin: light ? '#c3c6c8' : '#2b2e30', accent: '#' + accent.getHexString() }; }
608
+ function drawPaper() {
609
+ const c = paperCanvas.getContext('2d'), C = paperColors(), n = tws.lines.length, first = Math.max(0, n - SHOW - 1);
610
+ c.fillStyle = C.paper; c.fillRect(0, 0, paperCanvas.width, paperCanvas.height);
611
+ c.strokeStyle = C.margin; c.lineWidth = 1.2; c.beginPath(); c.moveTo((MARGIN - 2) * PP, 6 * PP); c.lineTo((MARGIN - 2) * PP, (STOP - SZ0 - 4) * PP); c.stroke();
612
+ c.font = `${6 * PP}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`; c.textBaseline = 'alphabetic';
613
+ const vOf = i => (BASE + i * LH - tws.feed) * PP;
614
+ c.fillStyle = C.ink;
615
+ for (let i = first; i < n; i++) for (const t of tws.lines[i]) {
616
+ if (t.ch === ' ' || (tws.last && tws.last.line === i && tws.last.idx === t.idx)) continue;
617
+ c.fillText(t.ch, (MARGIN + t.col * CW) * PP, vOf(i));
618
+ }
619
+ if (tws.last && tws.last.ch !== ' ') {
620
+ const x = (MARGIN + tws.last.col * CW) * PP, v = vOf(tws.last.line);
621
+ c.save(); c.fillStyle = C.accent; // newest character in the accent colour, no glow c.font = `500 ${6 * PP}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`; c.fillText(tws.last.ch, x, v); c.restore();
622
+ }
623
+ paperTex.needsUpdate = true; tws.dirty = false;
624
+ }
625
+ function twReadout() {
626
+ if (active !== 'tw') return;
627
+ document.getElementById('read').textContent = [`line ${tws.lines.length}`, `col ${tws.col + 1}`, tws.key ? `key ${tws.key}` : 'ready'].join(' · ');
628
+ }
629
+ function flash(g, ms = 110) { // press: sink 3 units and glow, then fade (api.flash)
630
+ const mats = []; g.traverse(o => { if (o.isMesh && o.material.emissive && twCaps.includes(o.material)) mats.push(o.material); });
631
+ twFlashes.push({ g, mats, t0: performance.now(), ms });
632
+ }
633
+ let audio = null, noise = null;
634
+ const ac = () => { if (!audio) { audio = new (window.AudioContext || window.webkitAudioContext)(); } if (audio.state === 'suspended') audio.resume(); return audio; };
635
+ function click() {
636
+ try { const ctx = ac(), t = ctx.currentTime;
637
+ if (!noise) { noise = ctx.createBuffer(1, ctx.sampleRate * 0.04 | 0, ctx.sampleRate); const d = noise.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (d.length * 0.18)); }
638
+ const src = ctx.createBufferSource(), bp = ctx.createBiquadFilter(), g = ctx.createGain();
639
+ src.buffer = noise; bp.type = 'bandpass'; bp.frequency.value = 2600 + Math.random() * 600; bp.Q.value = 1.4;
640
+ g.gain.value = 0.55; src.connect(bp); bp.connect(g); g.connect(ctx.destination); src.start(t);
641
+ } catch (e) {}
642
+ }
643
+ function ding() {
644
+ try { const ctx = ac(), t = ctx.currentTime;
645
+ [[2200, 0.35], [5600, 0.08]].forEach(([f, a]) => {
646
+ const o = ctx.createOscillator(), g = ctx.createGain(); o.type = 'sine'; o.frequency.value = f;
647
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(a, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + (f > 3000 ? 0.35 : 0.9));
648
+ o.connect(g); g.connect(ctx.destination); o.start(t); o.stop(t + 1);
649
+ });
650
+ } catch (e) {}
651
+ }
652
+ // small shared voices for the vending machine, computer, lamp and logic board
653
+ function tone(f, dur, peak, { type = 'sine', to = 0, at = 0 } = {}) {
654
+ try { const ctx = ac(), t = ctx.currentTime + at, o = ctx.createOscillator(), g = ctx.createGain();
655
+ o.type = type; o.frequency.setValueAtTime(f, t); if (to) o.frequency.exponentialRampToValueAtTime(to, t + dur * 0.8);
656
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(peak, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
657
+ o.connect(g); g.connect(ctx.destination); o.start(t); o.stop(t + dur + 0.02);
658
+ } catch (e) {}
659
+ }
660
+ function tap(freq, gain, q = 1.4) {
661
+ try { const ctx = ac(), t = ctx.currentTime; click.noise ||= (() => { const b = ctx.createBuffer(1, ctx.sampleRate * 0.04 | 0, ctx.sampleRate), d = b.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (d.length * 0.18)); return b; })();
662
+ const src = ctx.createBufferSource(), bp = ctx.createBiquadFilter(), g = ctx.createGain();
663
+ src.buffer = click.noise; bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = q; g.gain.value = gain;
664
+ src.connect(bp); bp.connect(g); g.connect(ctx.destination); src.start(t);
665
+ } catch (e) {}
666
+ }
667
+ const SFX = {
668
+ coin() { tone(2637, 0.25, 0.16); tone(3951, 0.32, 0.1, { at: 0.07 }); tap(5200, 0.25, 3); },
669
+ button() { tap(1800, 0.45); },
670
+ thunk() { tone(120, 0.22, 0.5, { to: 48 }); tap(380, 0.9, 0.8); },
671
+ key() { tap(3000 + Math.random() * 500, 0.35); },
672
+ switch() { tap(1200, 0.7, 2); tone(900, 0.04, 0.05, { type: 'square' }); },
673
+ detent() { tap(4200, 0.25, 4); },
674
+ bit(on) { tone(on ? 1320 : 660, 0.12, 0.07, { type: 'triangle' }); },
675
+ };
676
+ const SHIFTED = { '!':'1','@':'2','#':'3','$':'4','%':'5','^':'6','&':'7','*':'8','(':'9',')':'0','_':'-',':':';','<':',','>':'.','?':'/' };
677
+ const keyFor = k => twKeys.get(k.length === 1 ? (SHIFTED[k] || k.toLowerCase()) : k);
678
+ function strike(ch) {
679
+ tws.returning = false;
680
+ const line = tws.lines.length - 1, arr = tws.lines[line], t = { ch, col: tws.col, idx: arr.length };
681
+ arr.push(t); tws.last = { line, ...t };
682
+ if (tws.col < MAXCOL) tws.col++;
683
+ if (tws.col >= 28 && !tws.rang) { tws.rang = true; ding(); }
684
+ }
685
+ function twPress(k) {
686
+ if (k === 'Enter') {
687
+ flash(lever, 260); const kk = twKeys.get('Enter'); if (kk) flash(kk);
688
+ tws.returning = true; tws.lines.push([]); tws.col = 0; tws.rang = false; tws.last = null; tws.key = 'return';
689
+ } else if (k === 'Backspace') {
690
+ flash(twKeys.get('Backspace')); if (tws.col > 0) tws.col--; tws.key = 'backspace';
691
+ } else if (k.length === 1 && k >= ' ' && k !== '\x7f') {
692
+ const el = keyFor(k); if (el) flash(el); strike(k); tws.key = k === ' ' ? 'space' : k;
693
+ } else return false;
694
+ click(); tws.lastAt = performance.now(); tws.dirty = true; twReadout(); return true;
695
+ }
696
+ function twDemo() { [...'dear claude,', 'Enter', ...'the quick brown fox'].forEach((c, i) => setTimeout(() => twPress(c), i * 34)); }
697
+ // press flashes for any figure: sink 3 units, glow, then fade back to the part's resting glow (latched buttons keep theirs)
698
+ function tickFlashes(now) {
699
+ for (let i = twFlashes.length - 1; i >= 0; i--) {
700
+ const f = twFlashes[i], t = now - f.t0, down = t < f.ms, k = Math.max(0, 1 - t / (f.ms + 260));
701
+ const ax = f.g.userData.pressAxis || 'y'; // 'y': top buttons sink; 'z': front-facing buttons push in (toward −Z)
702
+ f.g.position[ax] = down ? -(f.g.userData.pressDepth || 3) * TS : 0;
703
+ f.mats.forEach(m => m.emissiveIntensity = Math.max(m.userData.base || 0, 1.4 * k));
704
+ if (t > f.ms + 260) { f.g.position[f.g.userData.pressAxis || 'y'] = 0; f.mats.forEach(m => m.emissiveIntensity = m.userData.base || 0); twFlashes.splice(i, 1); }
705
+ }
706
+ }
707
+ function twTick(now, dt) {
708
+ // carriage: quick step per character, slower glide on return
709
+ const tx = -tws.col * CW * TS;
710
+ carriage.position.x += (tx - carriage.position.x) * Math.min(1, dt * (tws.returning ? 9 : 45));
711
+ if (tws.returning && Math.abs(tx - carriage.position.x) < 1e-4) tws.returning = false;
712
+ // paper feed
713
+ const tf = (tws.lines.length - 1) * LH;
714
+ if (Math.abs(tf - tws.feed) > 0.01) { tws.feed += (tf - tws.feed) * Math.min(1, dt * 14); tws.dirty = true; } else if (tws.feed !== tf) { tws.feed = tf; tws.dirty = true; }
715
+ if (tws.dirty) drawPaper();
716
+ tickFlashes(now);
717
+ // live: guide LED, bell, warm haze
718
+ const live = now - tws.lastAt < 1500, bell = tws.col >= 28;
719
+ guideMat.emissiveIntensity = live ? 1.2 : 0; bellMat.emissiveIntensity = bell ? 1.2 : 0;
720
+ twGlow.material.uniforms.intensity.value = live || bell ? 0.9 : 0;
721
+ hazeMat.uniforms.opacity.value += ((live ? 0.22 : 0) - hazeMat.uniforms.opacity.value) * Math.min(1, dt * 4);
722
+ }
723
+ themeHooks.push(t => {
724
+ const light = t === 'light';
725
+ recessMat.color.set(light ? 0xdcdddd : 0x0f1011);
726
+ twCaps.forEach(m => m.color.set(THEMES[t].body));
727
+ bellMat.color.set(light ? 0xc3c6c8 : 0x2b2e30); guideMat.color.set(light ? 0xc3c6c8 : 0x2b2e30);
728
+ hazeMat.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; hazeMat.needsUpdate = true;
729
+ hazeMat.uniforms.color.value.copy(accent);
730
+ if (twShadowMat) twShadowMat.uniforms.opacity.value = light ? 0.12 : 0.5;
731
+ twLabels.forEach(d => d(light ? '#6a6f73' : '#868b8f'));
732
+ tws.dirty = true;
733
+ });
734
+ // click a key or the return lever
735
+ function twClick(hit) {
736
+ let o = hit?.object; while (o && o.userData.key === undefined) o = o.parent;
737
+ if (o) twPress(o.userData.key);
738
+ }
739
+
740
+ // ══ Drum machine (Fig 2 of the iso figures, rebuilt in 3D from the same box measurements) ══
741
+ // Uses the typewriter's helpers (rbox, inset, seg, label, W …); own scope so its names don't clash.
742
+ const accentLineMat = new LineMaterial({ color: accent.clone(), linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -8 });
743
+ const DR = (() => {
744
+ const dr = new THREE.Group(); dr.visible = false; scene.add(dr);
745
+ const first = twMeshes.length;
746
+ const PZ = 7, BX = 20, BY = 20, BW = 260, BD = 244, DZ = 29, BF = BY + BD, HZ = 66, HF = 62;
747
+ const PADX = 30, PADY = 74, PS = 34, PG = 6, PH = 7;
748
+ const SX = 34, SP = 14.5, SY = 241, SW = 11, SD = 12, SH = 4, LEDY = 235;
749
+ const NAMES = ['kick','snare','c.hat','o.hat', 'clap','rim','tom lo','tom mid', 'tom hi','cowbell','clave','shaker', 'crash','conga','zap','sub'];
750
+ const KEYS = ['1','2','3','4','q','w','e','r','a','s','d','f','z','x','c','v'];
751
+ const vLines = (x0, x1, step, yz) => { const s = []; for (let x = x0; x <= x1 + 1e-6; x += step) s.push(yz(x)); seg(s, dr); };
752
+ // flat recess on a vertical face: front (y = const, faces +Z) or side (x = const, faces +X)
753
+ function faceInset(kind, a, b, z0, w, h, r) {
754
+ const sh = new THREE.Shape(); roundRect(sh, 0, 0, w, h, Math.min(r, w / 2, h / 2));
755
+ const g = new THREE.ShapeGeometry(sh, 12); g.scale(TS, TS, TS);
756
+ const m = new THREE.Mesh(g, recessMat); dr.add(m);
757
+ const pts = sh.getPoints(12);
758
+ if (kind === 'front') { m.position.copy(W(a, b + 0.06, z0 - h)); dr.add(loopL(pts.map(p => W(a + p.x, b + 0.07, z0 - h + p.y)), lineMat)); }
759
+ else { m.rotation.y = Math.PI / 2; m.position.copy(W(a + 0.06, b, z0 - h)); dr.add(loopL(pts.map(p => W(a + 0.07, b - p.x, z0 - h + p.y)), lineMat)); }
760
+ return m;
761
+ }
762
+ const disc = (pos, r, mat, rotY = 0, rotX = 0) => { const d = new THREE.Mesh(new THREE.CircleGeometry(r * TS, 32), mat); d.position.copy(pos); d.rotation.set(rotX, rotY, 0); dr.add(d); return d; };
763
+
764
+ // plate
765
+ rbox(0, 0, 0, 300, 284, PZ, 14, bodyMat, dr);
766
+ { const sh = new THREE.Shape(); roundRect(sh, 8, -(8 + 268), 284, 268, 10); dr.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, PZ + 0.06)), lineMat)); }
767
+ [[14, 14], [286, 14], [14, 270], [286, 270]].forEach(([x, y]) => { disc(W(x, y, PZ + 0.05), 3, recessMat, 0, -Math.PI / 2); seg([[[x - 1.8, y, PZ + 0.2], [x + 1.8, y, PZ + 0.2]]], dr); });
768
+ // body (deck)
769
+ rbox(BX, BY, PZ, BW, BD, DZ - PZ, 8, bodyMat, dr);
770
+ { const sh = new THREE.Shape(); roundRect(sh, BX + 4, -(BY + 46 + 194), 252, 194, 5); dr.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, DZ + 0.06)), lineMat)); }
771
+ inset(BX + 176, BY + 54, DZ, 74, 104, 4, dr);
772
+ vLines(BX + 184, BX + 242, 4, x => [[x, BY + 62, DZ + 0.12], [x, BY + 150, DZ + 0.12]]);
773
+ seg([[[BX + 10, BY + 208, DZ + 0.06], [BX + BW - 10, BY + 208, DZ + 0.06]]], dr);
774
+ [0, 4, 8, 12].forEach(i => label(String(i + 1), 6, 6, 4.5, onTop(SX + i * SP + 3, BY + 210.5, DZ), dr, { align: 'left' }));
775
+ label('PLAY/STOP', 34, 6, 4.5, onTop(BX + 190 + 17, BY + 159.5, DZ), dr, { align: 'left', spacing: 0.8 });
776
+ // body front: vents, model name, USB port
777
+ vLines(BX + 8, BX + 60, 3, x => [[x, BF + 0.08, DZ - 5], [x, BF + 0.08, DZ - 17]]);
778
+ label('DR-16', 30, 8, 6, onFront(BX + BW - 60 + 15, BF, DZ - 11), dr, { align: 'left', spacing: 1.2 });
779
+ faceInset('front', BX + BW - 80, BF, DZ - 8, 12, 5, 2.5);
780
+ // body side: vents, two output jacks
781
+ { const s = []; for (let u = 20; u <= 70 + 1e-6; u += 3.2) s.push([[BX + BW + 0.08, BF - u, DZ - 5], [BX + BW + 0.08, BF - u, DZ - 17]]); seg(s, dr); }
782
+ [200, 216].forEach(u => { disc(W(BX + BW + 0.06, BF - u, DZ - 11), 4, recessMat, Math.PI / 2);
783
+ dr.add(loopL(Array.from({ length: 32 }, (_, i) => { const t = i / 32 * Math.PI * 2; return W(BX + BW + 0.07, BF - u + 4 * Math.cos(t), DZ - 11 + 4 * Math.sin(t)); }), lineMat)); });
784
+ label('OUT', 12, 5, 3.4, m => { m.rotation.y = Math.PI / 2; m.position.copy(W(BX + BW + 0.12, BF - 190 - 6, DZ - 20)); }, dr, { align: 'left' });
785
+ // back block with the display
786
+ rbox(BX, BY, DZ, BW, HF - BY, HZ - DZ, 6, bodyMat, dr);
787
+ vLines(BX + 196, BX + 250, 3.2, x => [[x, BY + 8, HZ + 0.08], [x, BY + 34, HZ + 0.08]]);
788
+ inset(BX + 10, BY + 16, HZ, 60, 10, 5, dr);
789
+ faceInset('front', BX + 10, HF, HZ - 3, 178, 31, 6);
790
+ vLines(BX + 200, BX + 248, 3, x => [[x, HF + 0.08, HZ - 20], [x, HF + 0.08, HZ - 30]]);
791
+ label('RUN', 16, 6, 5, onFront(BX + 210 + 8, HF, HZ - 12.5), dr, { align: 'left', spacing: 0.8 });
792
+ { const s = []; for (let u = 6; u <= 36 + 1e-6; u += 3) s.push([[BX + BW + 0.08, HF - u, HZ - 8], [BX + BW + 0.08, HF - u, HZ - 30]]); seg(s, dr); }
793
+ const runMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 });
794
+ disc(W(BX + 204, HF + 0.1, HZ - 12), 2, runMat);
795
+ // display: lit glass with the screen drawn into a canvas (crisp at 12 px per unit)
796
+ const GW = 170, GH = 25, GP = 12, scr = document.createElement('canvas'); scr.width = GW * GP; scr.height = GH * GP;
797
+ const scrTex = new THREE.CanvasTexture(scr); scrTex.colorSpace = THREE.SRGBColorSpace; scrTex.anisotropy = 8;
798
+ const screen = new THREE.Mesh(new THREE.PlaneGeometry(GW * TS, GH * TS), new THREE.MeshBasicMaterial({ map: scrTex, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 }));
799
+ screen.position.copy(W(BX + 14 + GW / 2, HF + 0.3, HZ - 6 - GH / 2)); dr.add(screen); // in front of the recess (which is itself pulled forward)
800
+ const haloMat = softMat(0xffffff, true);
801
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(GW * TS * 1.25, GH * TS * 2.6), haloMat); h.position.copy(screen.position).add(new THREE.Vector3(0, 0, 0.004)); dr.add(h); }
802
+
803
+ // pads (rows back to front), each a group so it can sink; selection ring in the accent colour
804
+ const pads = [];
805
+ for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
806
+ const i = r * 4 + c, x = PADX + c * (PS + PG), y = PADY + r * (PS + PG);
807
+ const g = new THREE.Group(); g.userData = { pad: i }; dr.add(g);
808
+ const mat = capMat(); twCaps.push(mat);
809
+ rbox(x, y, DZ, PS, PS, PH, 3, mat, g);
810
+ label(NAMES[i], 26, 7, 5, onTop(x + 5 + 13, y + PS - 7.7, DZ + PH), g, { align: 'left' });
811
+ label(KEYS[i], 6, 7, 5, onTop(x + PS - 9 + 3, y + 8.3, DZ + PH), g, { align: 'left' });
812
+ const sh = new THREE.Shape(); roundRect(sh, x + 3, -(y + 3 + PS - 6), PS - 6, PS - 6, 2);
813
+ const ring = fat(sh.getPoints(8).flatMap((p, k, a) => { const q = a[(k + 1) % a.length]; return [...W(p.x, -p.y, DZ + PH + 0.15).toArray(), ...W(q.x, -q.y, DZ + PH + 0.15).toArray()]; }), accentLineMat);
814
+ ring.visible = false; g.add(ring);
815
+ pads.push({ g, mat, ring });
816
+ }
817
+ // play / stop with its glyph
818
+ const play = new THREE.Group(); play.userData = { play: true }; dr.add(play);
819
+ const playMat = capMat(); twCaps.push(playMat);
820
+ rbox(206, 186, DZ, 56, 40, 6, 4, playMat, play);
821
+ const glyphC = document.createElement('canvas'); glyphC.width = 56 * 16; glyphC.height = 40 * 16;
822
+ const glyphTex = new THREE.CanvasTexture(glyphC); glyphTex.colorSpace = THREE.SRGBColorSpace;
823
+ const glyph = new THREE.Mesh(new THREE.PlaneGeometry(56 * TS, 40 * TS), new THREE.MeshBasicMaterial({ map: glyphTex, transparent: true, depthWrite: false, toneMapped: false }));
824
+ glyph.rotation.x = -Math.PI / 2; glyph.position.copy(W(206 + 28, 186 + 20, DZ + 6.12)); play.add(glyph);
825
+ const drawGlyph = col => { const x = glyphC.getContext('2d'), u = 16; x.clearRect(0, 0, glyphC.width, glyphC.height); x.fillStyle = col;
826
+ x.beginPath(); x.moveTo(18 * u, 13 * u); x.lineTo(18 * u, 27 * u); x.lineTo(29 * u, 20 * u); x.closePath(); x.fill();
827
+ x.beginPath(); x.roundRect(33 * u, 14 * u, 11 * u, 12 * u, u); x.fill(); glyphTex.needsUpdate = true; };
828
+ // step LEDs (on the deck) and step buttons
829
+ const ledMat = [], steps = [];
830
+ for (let i = 0; i < 16; i++) {
831
+ const m = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 }); ledMat.push(m);
832
+ disc(W(SX + i * SP + SW / 2, LEDY, DZ + 0.06), 1.8, m, 0, -Math.PI / 2);
833
+ const g = new THREE.Group(); g.userData = { step: i }; dr.add(g);
834
+ const mat = capMat(); twCaps.push(mat);
835
+ rbox(SX + i * SP, SY, DZ, SW, SD, SH, 1.5, mat, g);
836
+ steps.push({ g, mat });
837
+ }
838
+ const ledGlow = Array.from({ length: 16 }, (_, i) => { const p = glowPoints([W(SX + i * SP + SW / 2, LEDY, DZ + 0.4)], 0.14); dr.add(p); return p; });
839
+ const runGlow = glowPoints([W(BX + 204, HF + 0.6, HZ - 12)], 0.16); dr.add(runGlow);
840
+ const haze = softMat(0xffffff, true);
841
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(4.4, 4.2), haze); h.rotation.x = -Math.PI / 2; h.position.copy(W(150, 142, -0.5)); dr.add(h); }
842
+ const shadow = softMat(0x000000, false);
843
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(3.8, 3.6), shadow); h.rotation.x = -Math.PI / 2; h.position.copy(W(150, 148, -0.8)); dr.add(h); }
844
+ // crease lines for the drum machine's solids
845
+ for (const m of twMeshes.slice(first)) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); }
846
+
847
+ // ── state, sound and behaviour (as in the figure) ──
848
+ const BPM = 112, STEP_MS = 60000 / BPM / 4;
849
+ const st = { playing: false, sel: 0, step: -1, pat: NAMES.map(() => Array(16).fill(false)), timer: null };
850
+ const hits = () => st.pat.reduce((n, row) => n + row.filter(Boolean).length, 0);
851
+ function drawScreen() {
852
+ const c = scr.getContext('2d'), u = GP, light = theme === 'light', A = '#' + accent.getHexString(), row = st.pat[st.sel];
853
+ c.clearRect(0, 0, scr.width, scr.height);
854
+ c.fillStyle = light ? '#f6ece2' : '#22170f'; c.beginPath(); c.roundRect(0, 0, scr.width, scr.height, 4 * u); c.fill();
855
+ c.strokeStyle = A; c.lineWidth = 0.35 * u; c.beginPath(); c.roundRect(0.2 * u, 0.2 * u, scr.width - 0.4 * u, scr.height - 0.4 * u, 3.8 * u); c.stroke();
856
+ c.shadowColor = A; c.shadowBlur = light ? 0 : 0.9 * u; c.fillStyle = A; c.textBaseline = 'alphabetic';
857
+ c.font = `${8 * u}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`;
858
+ c.fillText(st.playing ? `${BPM} BPM ▶ ${String(st.step + 1).padStart(2, '0')}/16` : `${BPM} BPM ■`, 6 * u, 11 * u);
859
+ c.font = `${5.5 * u}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`;
860
+ c.fillText(`PAD ${NAMES[st.sel].toUpperCase()} · ${row.filter(Boolean).length} STEPS`, 6 * u, 21 * u);
861
+ for (let i = 0; i < 16; i++) {
862
+ const x = (82 + i * 5.3) * u, y = (i % 4 ? 8 : 7) * u, h = (i % 4 ? 8 : 10) * u, cur = st.playing && i === st.step;
863
+ c.globalAlpha = row[i] || cur ? 1 : 0.45; c.lineWidth = 0.3 * u; c.strokeStyle = cur ? (light ? '#7a3a00' : '#ffd9a8') : A;
864
+ c.beginPath(); c.roundRect(x, y, 3.8 * u, h, 0.6 * u); if (row[i]) c.fill(); c.stroke();
865
+ }
866
+ c.globalAlpha = 1;
867
+ if (st.playing && st.step >= 0) c.fillRect((82 + st.step * 5.3) * u, 19.5 * u, 3.8 * u, 1.6 * u);
868
+ scrTex.needsUpdate = true;
869
+ }
870
+ function render() {
871
+ pads.forEach((p, i) => p.ring.visible = i === st.sel);
872
+ const row = st.pat[st.sel];
873
+ steps.forEach((s, i) => s.mat.userData.base = row[i] ? 0.55 : 0); // latched steps stay lit
874
+ playMat.userData.base = st.playing ? 0.55 : 0;
875
+ [...steps.map(s => s.mat), playMat].forEach(m => { if (!twFlashes.some(f => f.mats.includes(m))) m.emissiveIntensity = m.userData.base || 0; });
876
+ ledMat.forEach((m, i) => m.emissiveIntensity = st.playing && i === st.step ? 1.4 : 0);
877
+ ledGlow.forEach((p, i) => p.material.uniforms.intensity.value = st.playing && i === st.step ? 0.9 : 0);
878
+ runMat.emissiveIntensity = st.playing ? 1.4 : 0; runGlow.material.uniforms.intensity.value = st.playing ? 0.8 : 0;
879
+ haloMat.uniforms.opacity.value = st.playing ? 0.28 : 0;
880
+ drawScreen(); readout();
881
+ }
882
+ function readout() {
883
+ if (active !== 'dr') return;
884
+ document.getElementById('read').textContent = st.playing ? `playing · step ${st.step + 1}/16 · ${hits()} hits` : `stopped · ${BPM} bpm · pad ${NAMES[st.sel]}`;
885
+ }
886
+ // synthesis (same voices as the figure)
887
+ let A = null, noiseBuf = null;
888
+ function au() { try { if (!A) { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const out = ctx.createGain(); out.gain.value = 0.8; out.connect(ctx.destination); A = { ctx, out }; } else if (A.ctx.state === 'suspended') A.ctx.resume(); } catch (e) { A = null; } return A; }
889
+ function noise(ctx) {
890
+ if (!noiseBuf) { noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
891
+ const s = ctx.createBufferSource(); s.buffer = noiseBuf; return s;
892
+ }
893
+ const env = (ctx, dest, t, peak, dec, att = 0.002) => { const g = ctx.createGain();
894
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(peak, t + att);
895
+ g.gain.exponentialRampToValueAtTime(0.0001, t + att + dec); g.connect(dest); return g; };
896
+ const filt = (ctx, type, f, Qv = 1) => { const b = ctx.createBiquadFilter(); b.type = type; b.frequency.value = f; b.Q.value = Qv; return b; };
897
+ function osc(ctx, type, f0, f1, t, dur, dest) { const o = ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(f0, t);
898
+ if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.6); o.connect(dest); o.start(t); o.stop(t + dur + 0.05); }
899
+ function hiss(ctx, t, dur, chain, dest) { const n = noise(ctx); let node = n; chain.forEach(f => { node.connect(f); node = f; }); node.connect(dest); n.start(t); n.stop(t + dur + 0.05); }
900
+ const VOICES = {
901
+ 'kick': (c, t, o) => osc(c, 'sine', 160, 42, t, .45, env(c, o, t, 1, .45)),
902
+ 'snare': (c, t, o) => { hiss(c, t, .2, [filt(c, 'highpass', 1200)], env(c, o, t, .6, .18)); osc(c, 'triangle', 190, 160, t, .12, env(c, o, t, .5, .1)); },
903
+ 'c.hat': (c, t, o) => hiss(c, t, .06, [filt(c, 'highpass', 7000), filt(c, 'bandpass', 10000, .8)], env(c, o, t, .5, .05)),
904
+ 'o.hat': (c, t, o) => hiss(c, t, .4, [filt(c, 'highpass', 7000), filt(c, 'bandpass', 9000, .8)], env(c, o, t, .45, .35)),
905
+ 'clap': (c, t, o) => { const g = c.createGain(); g.connect(o);
906
+ [0, .012, .024].forEach(d => { g.gain.setValueAtTime(.7, t + d); g.gain.exponentialRampToValueAtTime(.08, t + d + .01); });
907
+ g.gain.setValueAtTime(.6, t + .036); g.gain.exponentialRampToValueAtTime(.0001, t + .25);
908
+ hiss(c, t, .26, [filt(c, 'bandpass', 1400, 1.2)], g); },
909
+ 'rim': (c, t, o) => { osc(c, 'triangle', 1700, 1700, t, .03, env(c, o, t, .5, .03)); osc(c, 'square', 450, 450, t, .02, env(c, o, t, .15, .02)); },
910
+ 'tom lo': (c, t, o) => osc(c, 'sine', 100, 65, t, .4, env(c, o, t, .9, .38)),
911
+ 'tom mid': (c, t, o) => osc(c, 'sine', 145, 95, t, .35, env(c, o, t, .9, .32)),
912
+ 'tom hi': (c, t, o) => osc(c, 'sine', 200, 135, t, .3, env(c, o, t, .9, .28)),
913
+ 'cowbell': (c, t, o) => { const g = env(c, o, t, .35, .3), b = filt(c, 'bandpass', 800, 1.5); b.connect(g); osc(c, 'square', 540, 540, t, .3, b); osc(c, 'square', 800, 800, t, .3, b); },
914
+ 'clave': (c, t, o) => osc(c, 'sine', 2500, 2500, t, .06, env(c, o, t, .5, .05)),
915
+ 'shaker': (c, t, o) => hiss(c, t, .12, [filt(c, 'highpass', 5000)], env(c, o, t, .35, .08, .02)),
916
+ 'crash': (c, t, o) => hiss(c, t, 1.3, [filt(c, 'highpass', 4000)], env(c, o, t, .4, 1.2)),
917
+ 'conga': (c, t, o) => osc(c, 'sine', 340, 280, t, .2, env(c, o, t, .8, .18)),
918
+ 'zap': (c, t, o) => osc(c, 'square', 1500, 60, t, .16, env(c, o, t, .25, .15)),
919
+ 'sub': (c, t, o) => osc(c, 'sine', 70, 50, t, .7, env(c, o, t, .9, .65)),
920
+ };
921
+ function sound(i) { const a = au(); if (!a) return; try { VOICES[NAMES[i]](a.ctx, a.ctx.currentTime + 0.005, a.out); } catch (e) {} }
922
+ // actions
923
+ function hitPad(i) { st.sel = i; flash(pads[i].g); sound(i); render(); }
924
+ function toggleStep(s) { au(); const row = st.pat[st.sel]; row[s] = !row[s]; flash(steps[s].g); if (row[s] && !st.playing) sound(st.sel); render(); }
925
+ function tick() { st.step = (st.step + 1) % 16; st.pat.forEach((row, i) => { if (row[st.step]) { flash(pads[i].g, 120); sound(i); } }); render(); }
926
+ function setPlay(on) {
927
+ if (on === st.playing) return;
928
+ st.playing = on; flash(play);
929
+ if (on) { au(); st.step = -1; tick(); st.timer = setInterval(tick, STEP_MS); }
930
+ else { clearInterval(st.timer); st.timer = null; st.step = -1; }
931
+ render();
932
+ }
933
+ function key(e) {
934
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
935
+ if (k === ' ') { if (!e.repeat) setPlay(!st.playing); return true; }
936
+ const i = KEYS.indexOf(k); if (i < 0) return false;
937
+ if (!e.repeat) hitPad(i); return true;
938
+ }
939
+ function click(hit) {
940
+ let o = hit?.object; while (o && !('pad' in o.userData || 'step' in o.userData || 'play' in o.userData)) o = o.parent;
941
+ if (!o) return; const u = o.userData;
942
+ if ('pad' in u) hitPad(u.pad); else if ('step' in u) toggleStep(u.step); else setPlay(!st.playing);
943
+ }
944
+ function demo() {
945
+ const seq = [() => hitPad(2), ...[0, 2, 4, 6, 8, 10, 12, 14].map(s => () => toggleStep(s)),
946
+ () => hitPad(1), () => toggleStep(4), () => toggleStep(12),
947
+ () => hitPad(0), () => toggleStep(0), () => toggleStep(6), () => toggleStep(8), () => setPlay(true)];
948
+ seq.forEach((f, i) => setTimeout(f, 20 + i * 22));
949
+ }
950
+ function tickFrame(now, dt) { haze.uniforms.opacity.value += ((st.playing ? 0.22 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4); }
951
+ function stop() { setPlay(false); }
952
+ themeHooks.push(t => {
953
+ const light = t === 'light';
954
+ [runMat, ...ledMat].forEach(m => m.color.set(light ? 0xc3c6c8 : 0x2b2e30));
955
+ [haze, haloMat].forEach(m => { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.needsUpdate = true; m.uniforms.color.value.copy(accent); });
956
+ shadow.uniforms.opacity.value = light ? 0.12 : 0.5;
957
+ accentLineMat.color.copy(accent);
958
+ drawGlyph(light ? '#6a6f73' : '#868b8f'); drawScreen();
959
+ });
960
+ render();
961
+ return { group: dr, key, click, demo, tick: tickFrame, readout, stop, setPlay, toggle: () => setPlay(!st.playing) };
962
+ })();
963
+
964
+ // ══ Cassette deck (Fig 7 of the iso figures, rebuilt in 3D from the same box measurements) ══
965
+ const CS = (() => {
966
+ const cs = new THREE.Group(); cs.visible = false; scene.add(cs);
967
+ const first = twMeshes.length;
968
+ const PLZ = 7, DX = 20, DY = 30, DZ = 11, DW = 280, DD = 170, DH = 44, DT = DZ + DH, DF = DY + DD;
969
+ const SX = 24, SY = 17, R1 = [44, 41], R2 = [96, 41], TAPE = 640;
970
+ const pal = () => theme === 'light'
971
+ ? { body: '#f0f0ef', deck: '#f6f6f5', line: '#8e9397', detail: '#c3c6c8', recess: '#dcdddd', ink: '#6a6f73', inkHi: '#16181a', glassOff: '#e4e5e5', glassOn: '#f6ece2', hi: '#7a3a00' }
972
+ : { body: '#1d1e21', deck: '#202124', line: '#4f5357', detail: '#2b2e30', recess: '#0f1011', ink: '#868b8f', inkHi: '#e6e8e9', glassOff: '#141516', glassOn: '#22170f', hi: '#ffd9a8' };
973
+ const MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
974
+ const disc = (pos, r, mat, rotY = 0, rotX = 0) => { const d = new THREE.Mesh(new THREE.CircleGeometry(r * TS, 32), mat); d.position.copy(pos); d.rotation.set(rotX, rotY, 0); cs.add(d); return d; };
975
+ // a flat canvas lying on a top face (iso TOP plane), w×h units, origin at its top-left (x, y)
976
+ function topCanvas(x, y, z, w, h, P, parent = cs, transparent = false) {
977
+ const c = document.createElement('canvas'); c.width = Math.round(w * P); c.height = Math.round(h * P);
978
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
979
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), new THREE.MeshBasicMaterial({ map: tex, toneMapped: false, transparent, depthWrite: !transparent, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 }));
980
+ m.rotation.x = -Math.PI / 2; m.position.copy(W(x + w / 2, y + h / 2, z + 0.15)); parent.add(m);
981
+ return { c, x: c.getContext('2d'), tex, P };
982
+ }
983
+
984
+ // plate, plinth, deck
985
+ rbox(0, 0, 0, 320, 240, PLZ, 14, bodyMat, cs);
986
+ { const sh = new THREE.Shape(); roundRect(sh, 9, -(9 + 222), 302, 222, 10); cs.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, PLZ + 0.06)), lineMat)); }
987
+ [[18, 18], [302, 18], [18, 222], [302, 222]].forEach(([x, y]) => { disc(W(x, y, PLZ + 0.05), 3, recessMat, 0, -Math.PI / 2); seg([[[x - 1.8, y, PLZ + 0.2], [x + 1.8, y, PLZ + 0.2]]], cs); });
988
+ inset(24, 210, PLZ, 56, 5, 2.5, cs);
989
+ rbox(DX + 6, DY + 6, PLZ, DW - 12, DD - 12, DZ - PLZ, 3, bodyMat, cs);
990
+ rbox(DX, DY, DZ, DW, DD, DH, 8, bodyMat, cs);
991
+ // deck top: cassette well, counter/VU glass, key strip
992
+ inset(DX + 12, DY + 10, DT, 164, 104, 6, cs);
993
+ { const sh = new THREE.Shape(); roundRect(sh, DX + 16, -(DY + 13.5 + 97), 156, 97, 4); cs.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, DT + 0.1)), lineMat)); }
994
+ rbox(DX + SX, DY + SY, DT, 140, 89, 2, 5, bodyMat, cs); // cassette shell (its top is the canvas below)
995
+ inset(DX + 186, DY + 10, DT, 82, 104, 6, cs);
996
+ inset(DX + 12, DY + 122, DT, 256, 40, 4, cs);
997
+ // cassette top: label, window with tape packs, turning reels — one canvas, redrawn while the tape moves
998
+ const cas = topCanvas(DX + SX, DY + SY, DT + 2, 140, 89, 14);
999
+ // counter + VU glass — canvas over the recess
1000
+ const vu = topCanvas(DX + 186, DY + 10, DT, 82, 104, 14);
1001
+ const vuHalo = softMat(0xffffff, true);
1002
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(1.1, 1.3), vuHalo); h.rotation.x = -Math.PI / 2; h.position.copy(W(DX + 227, DY + 62, DT + 0.4)); cs.add(h); }
1003
+ // front face: name plate, vents, REC LED, jack, slot
1004
+ label('STEREO CASSETTE DECK', 110, 8, 6.5, onFront(DX + 14 + 55, DF, DT - 15), cs, { align: 'left', spacing: 0.8 });
1005
+ label('3-HEAD · AUTO STOP · C-07', 90, 6, 4.6, onFront(DX + 14 + 45, DF, DT - 25.5), cs, { align: 'left' });
1006
+ { const s = []; for (let x = 120; x <= 196 + 1e-6; x += 3.2) s.push([[DX + x, DF + 0.08, DT - 10], [DX + x, DF + 0.08, DT - 34]]); seg(s, cs); }
1007
+ const recMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 });
1008
+ disc(W(DX + 214, DF + 0.1, DT - 22), 1.6, recMat);
1009
+ label('REC', 14, 6, 4.6, onFront(DX + 219 + 7, DF, DT - 23), cs, { align: 'left' });
1010
+ disc(W(DX + 252, DF + 0.08, DT - 22), 5, recessMat);
1011
+ cs.add(loopL(Array.from({ length: 32 }, (_, i) => { const t = i / 32 * Math.PI * 2; return W(DX + 252 + 2.4 * Math.cos(t), DF + 0.1, DT - 22 + 2.4 * Math.sin(t)); }), lineMat));
1012
+ { const sh = new THREE.Shape(); roundRect(sh, 0, 0, 54, 4, 2); const g = new THREE.ShapeGeometry(sh, 8); g.scale(TS, TS, TS); const m = new THREE.Mesh(g, recessMat); m.position.copy(W(DX + 14, DF + 0.06, DT - 38)); cs.add(m); }
1013
+ { const s = []; for (let u = 20; u <= 150 + 1e-6; u += 3.4) s.push([[DX + DW + 0.08, DF - u, DT - 10], [DX + DW + 0.08, DF - u, DT - 30]]); seg(s, cs); }
1014
+ const recGlow = glowPoints([W(DX + 214, DF + 0.5, DT - 22)], 0.14); cs.add(recGlow);
1015
+
1016
+ // transport keys (tap; FF/REW hold to wind)
1017
+ const KEYS = [['rew', 'REW'], ['play', 'PLAY'], ['ff', 'FF'], ['stop', 'STOP'], ['rec', 'REC']];
1018
+ const ICON = { rew: 'M-1 -3 L-6 0 L-1 3Z M5 -3 L0 0 L5 3Z', play: 'M-2.5 -3.2 L3.5 0 L-2.5 3.2Z', ff: 'M1 -3 L6 0 L1 3Z M-5 -3 L0 0 L-5 3Z', stop: 'M-3 -3 H3 V3 H-3Z', rec: 'M3 0 A3 3 0 1 1 -3 0 A3 3 0 1 1 3 0Z' };
1019
+ const KW = 46.4, KST = 49.6, KD = 32, KH = 6, keys = {};
1020
+ KEYS.forEach(([k, lab], i) => {
1021
+ const kx = DX + 16 + i * KST, ky = DY + 126;
1022
+ const g = new THREE.Group(); g.userData = { tkey: k }; cs.add(g);
1023
+ const mat = capMat(); twCaps.push(mat);
1024
+ rbox(kx, ky, DT, KW, KD, KH, 2, mat, g);
1025
+ const face = topCanvas(kx, ky, DT + KH, KW, KD, 16, g, true);
1026
+ keys[k] = { g, mat, face, lab, icon: new Path2D(ICON[k]) };
1027
+ });
1028
+ const drawKey = (k, on) => { const { face: f, lab, icon } = keys[k], x = f.x, P = f.P, C = pal(); x.clearRect(0, 0, f.c.width, f.c.height);
1029
+ const col = on ? (theme === 'light' ? '#f6f6f5' : '#121314') : C.ink;
1030
+ x.save(); x.translate(KW / 2 * P, 11 * P); x.scale(P, P); x.fillStyle = col; x.fill(icon); x.restore();
1031
+ x.fillStyle = col; x.font = `${6.5 * P}px ${MONO}`; x.textAlign = 'center'; x.textBaseline = 'alphabetic'; x.fillText(lab, KW / 2 * P, 24 * P); f.tex.needsUpdate = true; };
1032
+ const haze = softMat(0xffffff, true);
1033
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(4.6, 3.8), haze); h.rotation.x = -Math.PI / 2; h.position.copy(W(160, 120, -0.5)); cs.add(h); }
1034
+ const shadow = softMat(0x000000, false);
1035
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(4.0, 3.2), shadow); h.rotation.x = -Math.PI / 2; h.position.copy(W(160, 125, -0.8)); cs.add(h); }
1036
+ for (const m of twMeshes.slice(first)) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); }
1037
+
1038
+ // ── drawing the cassette top and the counter/VU glass ──
1039
+ function drawCassette() {
1040
+ const { x, P } = cas, C = pal(), f = st.count / TAPE, rr = [6 + 13 * (1 - f), 6 + 13 * f], run = st.mode !== 'stop';
1041
+ const circ = (cx, cy, r) => { x.beginPath(); x.arc(cx * P, cy * P, r * P, 0, Math.PI * 2); };
1042
+ const rrect = (a, b, w, h, r) => { x.beginPath(); x.roundRect(a * P, b * P, w * P, h * P, r * P); };
1043
+ x.lineWidth = 0.22 * P;
1044
+ x.fillStyle = C.body; rrect(0, 0, 140, 89, 5); x.fill();
1045
+ x.strokeStyle = C.detail; [[6, 6], [134, 6], [6, 83], [134, 83], [70, 84]].forEach(([a, b]) => { circ(a, b, 1.8); x.stroke(); });
1046
+ x.fillStyle = C.deck; rrect(10, 6, 120, 54, 3); x.fill(); x.strokeStyle = C.line; x.stroke();
1047
+ x.strokeStyle = C.detail; x.beginPath(); x.moveTo(14 * P, 23 * P); x.lineTo(126 * P, 23 * P); x.stroke();
1048
+ x.fillStyle = C.inkHi; x.font = `italic ${11 * P}px "Bradley Hand", "Segoe Print", "Comic Sans MS", cursive`; x.fillText('mix 01', 16 * P, 19.5 * P);
1049
+ x.fillStyle = C.ink; x.font = `${5.5 * P}px ${MONO}`; x.fillText('C-60', 108 * P, 18 * P);
1050
+ x.font = `${7 * P}px ${MONO}`; x.fillText('A', 14 * P, 51 * P); x.font = `${5 * P}px ${MONO}`; x.fillText('NR', 117 * P, 51 * P);
1051
+ x.strokeStyle = C.detail; x.beginPath(); x.moveTo(24 * P, 89 * P); x.lineTo(31 * P, 66 * P); x.lineTo(109 * P, 66 * P); x.lineTo(116 * P, 89 * P); x.stroke();
1052
+ [48, 62, 78, 92].forEach(a => { rrect(a - 2, 74, 4, 4, 1); x.stroke(); });
1053
+ // window with the tape packs
1054
+ x.save(); rrect(30, 27, 80, 28, 3); x.fillStyle = C.recess; x.fill(); x.clip();
1055
+ [[R1, rr[0]], [R2, rr[1]]].forEach(([c, r]) => { circ(c[0], c[1], r); x.fillStyle = C.recess; x.fill(); x.strokeStyle = C.detail; x.stroke(); });
1056
+ x.restore(); rrect(30, 27, 80, 28, 3); x.strokeStyle = C.detail; x.stroke();
1057
+ // reels: hub, spokes (accent while the tape runs), the take-up side turns a little faster as the packs change
1058
+ [R1, R2].forEach((c, i) => {
1059
+ circ(c[0], c[1], 8.6); x.fillStyle = C.recess; x.fill();
1060
+ x.strokeStyle = C.detail; circ(c[0], c[1], 7); x.stroke(); circ(c[0], c[1], 2.6); x.stroke();
1061
+ const a0 = st.ang * (i ? 1 : 1 + (rr[1] - rr[0]) / 40);
1062
+ x.strokeStyle = run ? '#' + accent.getHexString() : C.line; x.lineCap = 'round';
1063
+ for (let s = 0; s < 6; s++) { const a = a0 + s * Math.PI / 3; x.beginPath(); x.moveTo((c[0] + Math.cos(a) * 2.6) * P, (c[1] + Math.sin(a) * 2.6) * P); x.lineTo((c[0] + Math.cos(a) * 6.2) * P, (c[1] + Math.sin(a) * 6.2) * P); x.stroke(); }
1064
+ });
1065
+ cas.tex.needsUpdate = true;
1066
+ }
1067
+ function drawVU(n, lit, on) {
1068
+ const { x, P } = vu, C = pal(), A = '#' + accent.getHexString();
1069
+ const rrect = (a, b, w, h, r) => { x.beginPath(); x.roundRect(a * P, b * P, w * P, h * P, r * P); };
1070
+ x.clearRect(0, 0, vu.c.width, vu.c.height);
1071
+ x.fillStyle = C.recess; rrect(0, 0, 82, 104, 6); x.fill();
1072
+ x.fillStyle = on ? C.glassOn : C.glassOff; rrect(5, 5, 72, 94, 5); x.fill();
1073
+ x.lineWidth = 0.3 * P; x.strokeStyle = on ? A : C.detail; x.stroke();
1074
+ x.fillStyle = C.ink; x.font = `${5 * P}px ${MONO}`; x.fillText('COUNTER', 11 * P, 16 * P); x.fillText('VU', 11 * P, 54 * P);
1075
+ x.font = `${6 * P}px ${MONO}`; x.fillText('L', 8 * P, 66.5 * P); x.fillText('R', 8 * P, 80.5 * P);
1076
+ x.lineWidth = 0.22 * P; x.strokeStyle = C.detail;
1077
+ for (let i = 0; i < 3; i++) { rrect(17 + i * 16, 21, 14, 22, 2); x.stroke(); }
1078
+ x.beginPath(); x.moveTo(15 * P, 87 * P); x.lineTo(77 * P, 87 * P); x.stroke();
1079
+ x.font = `${3.6 * P}px ${MONO}`; ['-20', '-10', '-5', '0', '+3'].forEach((t, i) => x.fillText(t, [15, 36, 51, 67, 73][i] * P, 93 * P));
1080
+ x.save(); if (on && theme !== 'light') { x.shadowColor = A; x.shadowBlur = 0.9 * P; }
1081
+ x.font = `${16 * P}px ${MONO}`; x.textAlign = 'center'; x.fillStyle = !on && st.count < 1 ? C.line : A;
1082
+ for (let i = 0; i < 3; i++) x.fillText(n[i], (24 + i * 16) * P, 38 * P);
1083
+ [60, 74].forEach((v, r) => { for (let i = 0; i < 12; i++) { const lit_ = i < lit[r]; x.fillStyle = lit_ ? (i >= 10 ? C.hi : A) : C.detail; rrect(15 + i * 5.2, v, 4, 7, 0.6); x.fill(); } });
1084
+ x.restore(); vu.tex.needsUpdate = true;
1085
+ }
1086
+
1087
+ // ── state, audio and behaviour (as in the figure) ──
1088
+ const st = { mode: 'stop', rec: false, count: 0, ang: 0, lv: [0, 0], shown: '', hold: null };
1089
+ const SPEED = { stop: 0, play: 1.25, ff: 26, rew: -26 }, SPIN = { stop: 0, play: 1.1, ff: 14, rew: -14 };
1090
+ let A = null, au = null;
1091
+ const audioOut = () => { if (!A) { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const out = ctx.createGain(); out.gain.value = 0.8; out.connect(ctx.destination); A = { ctx, out }; } else if (A.ctx.state === 'suspended') A.ctx.resume(); return A; };
1092
+ const mtof = m => 440 * Math.pow(2, (m - 69) / 12);
1093
+ const CHORDS = [[57,60,64,67],[53,57,60,64],[48,52,55,59],[55,59,62,65]], ARP = [0,1,2,3,2,1,3,2], EIGHTH = 60 / 76 / 2;
1094
+ function voice(a, type, f, t, dur, peak, pan = 0, att = .01) {
1095
+ const { ctx } = a, o = ctx.createOscillator(), g = ctx.createGain(), p = ctx.createStereoPanner();
1096
+ o.type = type; o.frequency.setValueAtTime(f, t); o.detune.value = (Math.random() - .5) * 12;
1097
+ g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + att); g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
1098
+ p.pan.value = pan; o.connect(g); g.connect(p); p.connect(a.filt);
1099
+ o.start(t); o.stop(t + dur + .05); a.nodes.add(o); o.onended = () => { a.nodes.delete(o); p.disconnect(); };
1100
+ return o;
1101
+ }
1102
+ function schedule(a, step, t) {
1103
+ const bar = Math.floor(step / 8) % 4, e = step % 8, ch = CHORDS[bar];
1104
+ if (e === 0) { ch.forEach((m, i) => voice(a, 'triangle', mtof(m), t, EIGHTH * 8.2, .045, (i - 1.5) * .3, .25)); voice(a, 'sine', mtof(ch[0] - 24), t, EIGHTH * 3.5, .22, 0, .02); }
1105
+ if (e === 4) voice(a, 'sine', mtof(ch[0] - 24), t, EIGHTH * 3, .16, 0, .02);
1106
+ const sw = e % 2 ? EIGHTH * .16 : 0;
1107
+ voice(a, 'square', mtof(ch[ARP[e]] + 12), t + sw, EIGHTH * .9, .022, e % 2 ? .55 : -.55);
1108
+ if (e === 0 || e === 4) { const o = voice(a, 'sine', 110, t, .32, .5, 0, .004); o.frequency.exponentialRampToValueAtTime(42, t + .18); }
1109
+ if (e % 2) { const { ctx } = a, n = ctx.createBufferSource(), g = ctx.createGain();
1110
+ n.buffer = a.noise; g.gain.setValueAtTime(.05, t + sw); g.gain.exponentialRampToValueAtTime(.001, t + sw + .05);
1111
+ n.connect(g); g.connect(a.filt); n.start(t + sw); n.stop(t + sw + .07); a.nodes.add(n); n.onended = () => a.nodes.delete(n); }
1112
+ }
1113
+ function startAudio() {
1114
+ if (au) return;
1115
+ try { const O = audioOut(), ctx = O.ctx, a = { ctx, nodes: new Set(), step: 0, next: ctx.currentTime + .06 };
1116
+ a.bus = ctx.createGain(); a.bus.gain.setValueAtTime(0, ctx.currentTime); a.bus.gain.setTargetAtTime(.9, ctx.currentTime, .04);
1117
+ a.filt = ctx.createBiquadFilter(); a.filt.type = 'lowpass'; a.filt.frequency.value = 1500; a.filt.Q.value = .8;
1118
+ a.lfo = ctx.createOscillator(); a.lfo.frequency.value = .18; a.lfoG = ctx.createGain(); a.lfoG.gain.value = 500;
1119
+ a.lfo.connect(a.lfoG); a.lfoG.connect(a.filt.frequency); a.lfo.start();
1120
+ a.filt.connect(a.bus); a.bus.connect(O.out);
1121
+ a.split = ctx.createChannelSplitter(2); a.bus.connect(a.split);
1122
+ a.an = [0, 1].map(i => { const n = ctx.createAnalyser(); n.fftSize = 512; a.split.connect(n, i); return n; });
1123
+ a.buf = new Float32Array(512);
1124
+ a.noise = ctx.createBuffer(1, ctx.sampleRate * .1, ctx.sampleRate); const d = a.noise.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
1125
+ const tick = () => { while (a.next < ctx.currentTime + .3) { schedule(a, a.step++, a.next); a.next += EIGHTH; } };
1126
+ tick(); a.timer = setInterval(tick, 60); au = a;
1127
+ } catch (err) { au = null; }
1128
+ }
1129
+ function stopAudio(now = false) {
1130
+ const a = au; if (!a) return; au = null; clearInterval(a.timer);
1131
+ const kill = () => { a.nodes.forEach(n => { try { n.stop(); } catch (e) {} }); a.nodes.clear(); try { a.lfo.stop(); } catch (e) {} try { a.bus.disconnect(); a.filt.disconnect(); } catch (e) {} };
1132
+ try { a.bus.gain.cancelScheduledValues(a.ctx.currentTime); a.bus.gain.setTargetAtTime(0, a.ctx.currentTime, .025); } catch (e) {}
1133
+ if (now) kill(); else setTimeout(kill, 180);
1134
+ }
1135
+ function levels(t) {
1136
+ if (au && au.ctx.state === 'running') {
1137
+ const r = au.an.map(n => { n.getFloatTimeDomainData(au.buf); let s = 0; for (const v of au.buf) s += v * v; return Math.sqrt(s / au.buf.length); });
1138
+ const m = r.map(v => Math.max(0, Math.min(1, (20 * Math.log10(v + 1e-9) + 40) / 38)));
1139
+ if (m[0] + m[1] > .16) return m;
1140
+ }
1141
+ const ph = (t % EIGHTH) / EIGHTH, beat = Math.floor(t / EIGHTH) % 2 === 0 ? 1 : .6;
1142
+ const base = .5 + .22 * beat * Math.exp(-ph * 3) + .06 * Math.sin(t * 2.3);
1143
+ return [base + .06 * Math.random(), base - .05 + .06 * Math.random() + .04 * Math.sin(t * 3.7)];
1144
+ }
1145
+ function render() {
1146
+ const m = st.mode;
1147
+ keys.play.mat.userData.base = m === 'play' ? 0.55 : 0; keys.ff.mat.userData.base = m === 'ff' ? 0.55 : 0;
1148
+ keys.rew.mat.userData.base = m === 'rew' ? 0.55 : 0; keys.rec.mat.userData.base = st.rec ? 0.55 : 0;
1149
+ Object.entries(keys).forEach(([k, o]) => { const on = (o.mat.userData.base || 0) > 0; if (o.on !== on) { o.on = on; drawKey(k, on); }
1150
+ if (!twFlashes.some(f => f.mats.includes(o.mat))) o.mat.emissiveIntensity = o.mat.userData.base || 0; });
1151
+ recMat.emissiveIntensity = st.rec ? 1.4 : 0; recGlow.material.uniforms.intensity.value = st.rec ? 0.8 : 0;
1152
+ drawCassette(); st.shown = '';
1153
+ }
1154
+ let lastAdv = performance.now();
1155
+ function advance() { const now = performance.now(), dt = Math.min(2, (now - lastAdv) / 1000); lastAdv = now;
1156
+ st.count = Math.max(0, Math.min(TAPE, st.count + SPEED[st.mode] * dt)); st.ang += SPIN[st.mode] * dt; }
1157
+ function setMode(m) { if (m === st.mode) return; advance(); if (st.mode === 'play') stopAudio(); st.mode = m; if (m === 'play') startAudio(); render(); }
1158
+ function act(k) {
1159
+ flash(keys[k].g);
1160
+ if (k === 'play') setMode(st.mode === 'play' ? 'stop' : 'play');
1161
+ else if (k === 'ff' || k === 'rew') setMode(st.mode === k ? 'stop' : k);
1162
+ else if (k === 'stop') setMode('stop');
1163
+ else if (k === 'rec') { st.rec = !st.rec; render(); }
1164
+ }
1165
+ function down(k) { act(k); if ((k === 'ff' || k === 'rew') && st.mode === k) st.hold = { k, t: performance.now() }; }
1166
+ function up(k) { const h = st.hold; if (!h || (k && h.k !== k)) return; st.hold = null; if (performance.now() - h.t > 350 && st.mode === h.k) setMode('stop'); }
1167
+ let lastT = performance.now(), lastKey = '';
1168
+ function tick(now, dt) {
1169
+ advance();
1170
+ if (st.count <= 0 && st.mode === 'rew') setMode('stop');
1171
+ if (st.count >= TAPE && st.mode !== 'rew' && st.mode !== 'stop') setMode('stop');
1172
+ const m = st.mode, on = m !== 'stop';
1173
+ if (on) drawCassette();
1174
+ const n = String(Math.floor(st.count) % 1000).padStart(3, '0');
1175
+ const tgt = m === 'play' ? levels(now / 1000) : [0, 0];
1176
+ st.lv = st.lv.map((v, i) => tgt[i] > v ? tgt[i] : Math.max(0, v - dt * 1.6));
1177
+ const lit = st.lv.map(v => Math.round(Math.min(1, v) * 12));
1178
+ const key = n + '|' + lit + '|' + on + '|' + theme;
1179
+ if (key !== lastKey) { lastKey = key; drawVU(n, lit, on); }
1180
+ vuHalo.uniforms.opacity.value = on ? 0.25 : 0;
1181
+ haze.uniforms.opacity.value += ((on ? 0.22 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
1182
+ let txt = (m === 'play' && st.rec ? 'rec' : m === 'stop' ? 'stop' : m) + ' · ' + n;
1183
+ if (m === 'play') txt += ` · L${'▮'.repeat(Math.ceil(lit[0] / 2))} R${'▮'.repeat(Math.ceil(lit[1] / 2))}`;
1184
+ else if (st.rec) txt += ' · rec armed';
1185
+ if (active === 'cs' && txt !== st.shown) { st.shown = txt; document.getElementById('read').textContent = txt; }
1186
+ tickFlashes(now);
1187
+ }
1188
+ const KEYMAP = { ' ': 'play', ArrowLeft: 'rew', ArrowRight: 'ff', s: 'stop', S: 'stop', r: 'rec', R: 'rec' };
1189
+ function key(e) { const k = KEYMAP[e.key]; if (!k) return false; if (!e.repeat) down(k); return true; }
1190
+ function keyup(e) { const k = KEYMAP[e.key]; if (k) up(k); }
1191
+ function pointerDown(hit) { let o = hit?.object; while (o && !o.userData.tkey) o = o.parent; if (!o) return false; down(o.userData.tkey); return true; }
1192
+ function demo() { setTimeout(() => { down('ff'); up('ff'); }, 60); setTimeout(() => { down('play'); up('play'); }, 700); }
1193
+ themeHooks.push(t => {
1194
+ const light = t === 'light';
1195
+ recMat.color.set(light ? 0xc3c6c8 : 0x2b2e30);
1196
+ [haze, vuHalo].forEach(m => { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.needsUpdate = true; m.uniforms.color.value.copy(accent); });
1197
+ shadow.uniforms.opacity.value = light ? 0.12 : 0.5;
1198
+ Object.keys(keys).forEach(k => drawKey(k, (keys[k].mat.userData.base || 0) > 0));
1199
+ drawCassette(); lastKey = '';
1200
+ });
1201
+ render();
1202
+ return { group: cs, key, keyup, up, pointerDown, demo, tick, stop: () => { setMode('stop'); up(); stopAudio(true); }, readout: () => { st.shown = ''; } };
1203
+ })();
1204
+
1205
+ // ══ Kitchen timer (Fig 6 of the iso figures, rebuilt in 3D from the same box measurements) ══
1206
+ const MONO3 = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
1207
+ // flat canvas on a face: kind 'front' (y = const, faces +Z) or 'top' (z = const); (u, v) = top-left in iso units
1208
+ function faceCanvas(kind, x, y, z, w, h, P, parent, transparent = false) {
1209
+ const c = document.createElement('canvas'); c.width = Math.round(w * P); c.height = Math.round(h * P);
1210
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
1211
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), new THREE.MeshBasicMaterial({ map: tex, toneMapped: false, transparent, depthWrite: !transparent, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 }));
1212
+ if (kind === 'top') { m.rotation.x = -Math.PI / 2; m.position.copy(W(x + w / 2, y + h / 2, z + 0.15)); }
1213
+ else { m.position.copy(W(x + w / 2, y + 0.3, z - h / 2)); } // front: (x, z) is the top-left corner
1214
+ parent.add(m); return { c, x: c.getContext('2d'), tex, P, mesh: m };
1215
+ }
1216
+ function frontInset(parent, x, y, ztop, w, h, r) { // recess panel on a front face, top-left at (x, ztop)
1217
+ const sh = new THREE.Shape(); roundRect(sh, 0, 0, w, h, Math.min(r, w / 2, h / 2));
1218
+ const g = new THREE.ShapeGeometry(sh, 12); g.scale(TS, TS, TS);
1219
+ const m = new THREE.Mesh(g, recessMat); m.position.copy(W(x, y + 0.06, ztop - h)); parent.add(m);
1220
+ parent.add(loopL(sh.getPoints(12).map(p => W(x + p.x, y + 0.07, ztop - h + p.y)), lineMat)); return m;
1221
+ }
1222
+ function sideInset(parent, x, yRight, ztop, w, h, r) { // recess on a side face (x = const), u runs toward −y
1223
+ const sh = new THREE.Shape(); roundRect(sh, 0, 0, w, h, Math.min(r, w / 2, h / 2));
1224
+ const g = new THREE.ShapeGeometry(sh, 12); g.scale(TS, TS, TS);
1225
+ const m = new THREE.Mesh(g, recessMat); m.rotation.y = Math.PI / 2; m.position.copy(W(x + 0.06, yRight, ztop - h)); parent.add(m);
1226
+ parent.add(loopL(sh.getPoints(12).map(p => W(x + 0.07, yRight - p.x, ztop - h + p.y)), lineMat)); return m;
1227
+ }
1228
+ const pal3 = () => theme === 'light'
1229
+ ? { line: '#8e9397', detail: '#c3c6c8', recess: '#dcdddd', ink: '#6a6f73', inkHi: '#16181a', glassOff: '#e4e5e5', glassOn: '#f6ece2', hi: '#7a3a00', bg: '#f6f6f5' }
1230
+ : { line: '#4f5357', detail: '#2b2e30', recess: '#0f1011', ink: '#868b8f', inkHi: '#e6e8e9', glassOff: '#141516', glassOn: '#22170f', hi: '#ffd9a8', bg: '#121314' };
1231
+
1232
+ const TM = (() => {
1233
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
1234
+ const first = twMeshes.length;
1235
+ const PLZ = 7, BX = 40, BY = 36, BZ = 13, BW = 200, BD = 120, BH = 96, BT = BZ + BH, BF = BY + BD;
1236
+ const BTN_H = 9, BTN_V = 64, BTN_D = 36;
1237
+ rbox(0, 0, 0, 280, 210, PLZ, 14, bodyMat, g0);
1238
+ { const sh = new THREE.Shape(); roundRect(sh, 9, -(9 + 192), 262, 192, 10); g0.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, PLZ + 0.06)), lineMat)); }
1239
+ [[18, 18], [262, 18], [18, 192], [262, 192]].forEach(([x, y]) => { const d = new THREE.Mesh(new THREE.CircleGeometry(3 * TS, 24), recessMat); d.rotation.x = -Math.PI / 2; d.position.copy(W(x, y, PLZ + 0.05)); g0.add(d); seg([[[x - 1.8, y, PLZ + 0.2], [x + 1.8, y, PLZ + 0.2]]], g0); });
1240
+ inset(22, 176, PLZ, 50, 5, 2.5, g0);
1241
+ rbox(BX + 6, BY + 6, PLZ, BW - 12, BD - 12, BZ - PLZ, 3, bodyMat, g0);
1242
+ rbox(BX, BY, BZ, BW, BD, BH, 12, bodyMat, g0);
1243
+ // top deck: nameplate with ribs, button wells, run LED
1244
+ const BTNS = [{ id: 'p1', lab: '+1', w: 26, fs: 9 }, { id: 'p5', lab: '+5', w: 26, fs: 9 }, { id: 'p10', lab: '+10', w: 26, fs: 9 }, { id: 'start', lab: 'START/STOP', w: 50, fs: 5.6 }, { id: 'reset', lab: 'RESET', w: 34, fs: 6.4 }];
1245
+ let bx = 11; BTNS.forEach(b => { b.x = bx; bx += b.w + 4; });
1246
+ inset(BX + 12, BY + 12, BT, 176, 38, 8, g0);
1247
+ { const s = []; for (let x = 20; x <= 90 + 1e-6; x += 3.5) s.push([[BX + x, BY + 19, BT + 0.12], [BX + x, BY + 43, BT + 0.12]]); seg(s, g0); }
1248
+ label('KITCHEN TIMER', 76, 9, 7, onTop(BX + 100 + 38, BY + 25.5, BT), g0, { align: 'left', spacing: 1.4 });
1249
+ label('MM:SS · MAX 99:59', 70, 7, 5, onTop(BX + 100 + 35, BY + 38.2, BT), g0, { align: 'left', spacing: 1 });
1250
+ BTNS.forEach(b => inset(BX + b.x - 1.5, BY + BTN_V - 1.5, BT, b.w + 3, BTN_D + 3, 5, g0));
1251
+ const ledMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 });
1252
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(1.6 * TS, 20), ledMat); d.rotation.x = -Math.PI / 2; d.position.copy(W(BX + 183, BY + BTN_V + BTN_D + 8, BT + 0.08)); g0.add(d); }
1253
+ const ledGlow = glowPoints([W(BX + 183, BY + BTN_V + BTN_D + 8, BT + 0.5)], 0.14); g0.add(ledGlow);
1254
+ const btns = {};
1255
+ BTNS.forEach(b => {
1256
+ const x = BX + b.x, y = BY + BTN_V, g = new THREE.Group(); g.userData = { btn: b.id }; g0.add(g);
1257
+ const mat = capMat(); twCaps.push(mat);
1258
+ rbox(x, y, BT, b.w, BTN_D, BTN_H, 4, mat, g);
1259
+ label(b.lab, b.w, BTN_D, b.fs, onTop(x + b.w / 2, y + BTN_D / 2, BT + BTN_H), g, { spacing: 0.4 });
1260
+ btns[b.id] = { g, mat };
1261
+ });
1262
+ // front: bezel + seven-segment display (canvas), MIN / SEC legends
1263
+ frontInset(g0, BX + 16, BF, BT - 16, 168, 58, 10);
1264
+ const disp = faceCanvas('front', BX + 22, BF, BT - 21, 156, 48, 12, g0);
1265
+ const halo = softMat(0xffffff, true);
1266
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(156 * TS * 1.3, 48 * TS * 1.9), halo); h.position.copy(W(BX + 100, BF + 0.6, BT - 45)); g0.add(h); }
1267
+ label('MIN', 20, 7, 5.5, onFront(BX + 64, BF, BT - 86.5), g0, { spacing: 1.2 });
1268
+ label('SEC', 20, 7, 5.5, onFront(BX + 136, BF, BT - 86.5), g0, { spacing: 1.2 });
1269
+ seg([[14, 44], [84, 116], [156, 186]].map(([a, b]) => [[BX + a, BF + 0.08, BT - 83], [BX + b, BF + 0.08, BT - 83]]), g0);
1270
+ // side: speaker grille
1271
+ sideInset(g0, BX + BW, BF - 34, BT - 20, 56, 50, 8);
1272
+ { const s = []; for (let u = 42; u <= 82 + 1e-6; u += 4) s.push([[BX + BW + 0.12, BF - u, BT - 27], [BX + BW + 0.12, BF - u, BT - 63]]); seg(s, g0); }
1273
+ sideInset(g0, BX + BW, BF - 10, BT - 80, 18, 5, 2.5);
1274
+ const haze = softMat(0xffffff, true), shadow = softMat(0x000000, false);
1275
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(3.8, 3.0), haze); h.rotation.x = -Math.PI / 2; h.position.copy(W(140, 105, -0.5)); g0.add(h); }
1276
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(3.4, 2.6), shadow); h.rotation.x = -Math.PI / 2; h.position.copy(W(140, 108, -0.8)); g0.add(h); }
1277
+ for (const m of twMeshes.slice(first)) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); }
1278
+
1279
+ // ── state and behaviour (as in the figure) ──
1280
+ const MAP = ['abcdef','bc','abdeg','abcdg','bcfg','acdfg','acdefg','abc','abcdefg','abcdfg'];
1281
+ const SEG = { a: [3,0,16,4], b: [18,3,4,14.5], c: [18,18.5,4,14.5], d: [3,32,16,4], e: [0,18.5,4,14.5], f: [0,3,4,14.5], g: [3,16,16,4] };
1282
+ const DIGX = [39, 67, 111, 139].map(x => x - 22), DIGY = 6, MAX = 99 * 60 + 59;
1283
+ const st = { t: 0, running: false, ringing: false, paused: false };
1284
+ let tickId = null, ringNode = null, ringTok = 0, A = null, blinkOn = true;
1285
+ const fmt = t => `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
1286
+ function drawDisplay() {
1287
+ const { x, P } = disp, C = pal3(), A_ = '#' + accent.getHexString(), hot = st.running || st.ringing || st.t > 0;
1288
+ const flashOff = st.ringing && !blinkOn;
1289
+ x.clearRect(0, 0, disp.c.width, disp.c.height);
1290
+ x.fillStyle = hot && !flashOff ? C.glassOn : C.glassOff; x.beginPath(); x.roundRect(0, 0, disp.c.width, disp.c.height, 8 * P); x.fill();
1291
+ x.lineWidth = 0.3 * P; x.strokeStyle = hot ? A_ : C.detail; x.stroke();
1292
+ const s = fmt(st.t).replace(':', '');
1293
+ DIGX.forEach((dx, i) => {
1294
+ x.save(); x.translate((dx) * P, DIGY * P); x.transform(1, 0, Math.tan(-6 * Math.PI / 180), 1, 0, 0);
1295
+ Object.entries(SEG).forEach(([k, [sx, sy, w, h]]) => {
1296
+ const on = MAP[+s[i]].includes(k);
1297
+ x.globalAlpha = on ? (flashOff ? 0.15 : 1) : 0.5;
1298
+ x.fillStyle = on ? (hot ? (theme === 'light' ? A_ : C.hi) : C.ink) : C.detail;
1299
+ x.beginPath(); x.roundRect(sx * P, sy * P, w * P, h * P, 2 * P); x.fill();
1300
+ });
1301
+ x.restore();
1302
+ });
1303
+ x.globalAlpha = flashOff ? 0.15 : (st.running && !blinkOn ? 0.25 : 1);
1304
+ x.fillStyle = hot ? (theme === 'light' ? A_ : C.hi) : C.detail;
1305
+ x.beginPath(); x.roundRect((97 - 22) * P, (34 - 21) * P, 5 * P, 5 * P, 2 * P); x.roundRect((95.4 - 22) * P, (50 - 21) * P, 5 * P, 5 * P, 2 * P); x.fill();
1306
+ x.globalAlpha = 1; disp.tex.needsUpdate = true;
1307
+ }
1308
+ function readout() {
1309
+ if (active !== 'tm') return;
1310
+ document.getElementById('read').textContent = st.ringing ? 'ringing · tap any button' : st.running ? `running · ${fmt(st.t)} left` : st.paused && st.t > 0 ? `paused · ${fmt(st.t)} left` : `set · ${fmt(st.t)}`;
1311
+ }
1312
+ function render() {
1313
+ const hot = st.running || st.ringing || st.t > 0;
1314
+ btns.start.mat.userData.base = st.running ? 0.55 : 0;
1315
+ if (!twFlashes.some(f => f.mats.includes(btns.start.mat))) btns.start.mat.emissiveIntensity = btns.start.mat.userData.base;
1316
+ ledMat.emissiveIntensity = st.running || st.ringing ? 1.4 : 0; ledGlow.material.uniforms.intensity.value = st.running || st.ringing ? 0.8 : 0;
1317
+ halo.uniforms.opacity.value = hot ? 0.22 : 0;
1318
+ drawDisplay(); readout();
1319
+ }
1320
+ const audio = () => { if (!A) { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const out = ctx.createGain(); out.gain.value = 0.8; out.connect(ctx.destination); A = { ctx, out }; } else if (A.ctx.state === 'suspended') A.ctx.resume(); return A; };
1321
+ function stopTick() { if (tickId !== null) { clearInterval(tickId); tickId = null; } }
1322
+ function stopBeep() { if (!ringNode) return; const { osc, g, ctx } = ringNode; ringNode = null; try { g.gain.cancelScheduledValues(ctx.currentTime); g.gain.setValueAtTime(0, ctx.currentTime); osc.stop(); } catch (e) {} }
1323
+ function beep() {
1324
+ let a; try { a = audio(); } catch (e) { return; }
1325
+ const { ctx, out } = a, t0 = ctx.currentTime + 0.02, osc = ctx.createOscillator(), g = ctx.createGain();
1326
+ osc.type = 'square'; osc.frequency.value = 2100; g.gain.value = 0; osc.connect(g); g.connect(out);
1327
+ for (let k = 0; k < 6; k++) for (const off of [0, 0.14]) { const t = t0 + k * 0.5 + off;
1328
+ g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.16, t + 0.006); g.gain.setValueAtTime(0.16, t + 0.08); g.gain.linearRampToValueAtTime(0, t + 0.09); }
1329
+ osc.start(t0); osc.stop(t0 + 3.1); ringNode = { osc, g, ctx };
1330
+ }
1331
+ function ring() { stopTick(); st.running = false; st.paused = false; st.ringing = true; beep(); const tok = ++ringTok; setTimeout(() => { if (tok === ringTok && st.ringing) silence(); }, 3200); }
1332
+ function silence() { ringTok++; st.ringing = false; stopBeep(); render(); }
1333
+ function tick() { if (!st.running) return; st.t = Math.max(0, st.t - 1); if (st.t === 0) ring(); render(); }
1334
+ function startTick() { stopTick(); tickId = setInterval(tick, 1000); }
1335
+ function act(id) {
1336
+ flash((btns[id] || btns.p1).g);
1337
+ if (st.ringing) { silence(); return true; }
1338
+ if (id === 'p1') st.t = Math.min(MAX, st.t + 60);
1339
+ else if (id === 'p5') st.t = Math.min(MAX, st.t + 300);
1340
+ else if (id === 'p10') st.t = Math.min(MAX, st.t + 600);
1341
+ else if (id === 's10') st.t = Math.min(MAX, st.t + 10);
1342
+ else if (id === 'minus') st.t = Math.max(0, st.t - 60);
1343
+ else if (id === 'start') { try { audio(); } catch (e) {}
1344
+ if (st.running) { st.running = false; st.paused = true; stopTick(); } else if (st.t > 0) { st.running = true; st.paused = false; startTick(); } }
1345
+ else if (id === 'reset') { st.t = 0; st.running = false; st.paused = false; stopTick(); }
1346
+ if (st.running && st.t === 0) ring();
1347
+ render(); return true;
1348
+ }
1349
+ function key(e) {
1350
+ const k = e.key;
1351
+ if (k === '1') return act('p1'); if (k === '5') return act('p5'); if (k === '0') return act('p10');
1352
+ if (k === '+' || k === '=') return act('p1'); if (k === '-' || k === '_') return act('minus');
1353
+ if (k === 's' || k === 'S') return act('s10');
1354
+ if (k === ' ') { if (!e.repeat) act('start'); return true; }
1355
+ if (k === 'r' || k === 'R' || k === 'Backspace') return act('reset');
1356
+ return false;
1357
+ }
1358
+ function click(hit) { let o = hit?.object; while (o && !o.userData.btn) o = o.parent; if (o) act(o.userData.btn); }
1359
+ let lastBlink = 0;
1360
+ function tickFrame(now, dt) {
1361
+ const b = Math.floor(now / 500) % 2 === 0;
1362
+ if (b !== blinkOn) { blinkOn = b; if (st.ringing || st.running) drawDisplay();
1363
+ if (st.ringing) btns.start.mat.emissiveIntensity = blinkOn ? 1.2 : 0; }
1364
+ if (st.ringing) halo.uniforms.opacity.value = blinkOn ? 0.3 : 0.06;
1365
+ haze.uniforms.opacity.value += ((st.running || st.ringing ? 0.2 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
1366
+ tickFlashes(now);
1367
+ }
1368
+ function demo() { setTimeout(() => act('p5'), 0); setTimeout(() => act('p1'), 160); setTimeout(() => act('start'), 320); }
1369
+ function stop() { if (st.running) { st.running = false; st.paused = true; } stopTick(); silence(); }
1370
+ themeHooks.push(t => {
1371
+ const light = t === 'light';
1372
+ ledMat.color.set(light ? 0xc3c6c8 : 0x2b2e30);
1373
+ [haze, halo].forEach(m => { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.needsUpdate = true; m.uniforms.color.value.copy(accent); });
1374
+ shadow.uniforms.opacity.value = light ? 0.12 : 0.5; drawDisplay();
1375
+ });
1376
+ render();
1377
+ return { group: g0, key, click, tick: tickFrame, stop, readout, demo, act };
1378
+ })();
1379
+
1380
+ // ══ Vending machine (Fig 10 of the iso figures, rebuilt in 3D from the same box measurements) ══
1381
+ const VM = (() => {
1382
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
1383
+ const first = twMeshes.length;
1384
+ const X0 = 60, X1 = 200, Y0 = 70, YB = 130, YF = 160, Z0 = 15, ZT = 285;
1385
+ const WX0 = 68, WX1 = 156, WZ0 = 85, WZ1 = 265, SW = (WX1 - WX0) / 3;
1386
+ const ROWS = [{ L: 'C', z: 85, w: 22, h: 15 }, { L: 'B', z: 145, w: 19, h: 26 }, { L: 'A', z: 205, w: 12, h: 24 }];
1387
+ const PRICE = { A1: 100, A2: 125, A3: 100, B1: 150, B2: 150, B3: 175, C1: 75, C2: 75, C3: 100 };
1388
+ const f2 = c => (c / 100).toFixed(2);
1389
+ // plate, plinth
1390
+ rbox(20, 40, 0, 220, 170, 7, 14, bodyMat, g0);
1391
+ { const sh = new THREE.Shape(); roundRect(sh, 29, -(49 + 152), 202, 152, 10); g0.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, 7.06)), lineMat)); }
1392
+ [[38, 58], [222, 58], [38, 192], [222, 192]].forEach(([x, y]) => { const d = new THREE.Mesh(new THREE.CircleGeometry(3 * TS, 24), recessMat); d.rotation.x = -Math.PI / 2; d.position.copy(W(x, y, 7.05)); g0.add(d); seg([[[x - 1.8, y, 7.2], [x + 1.8, y, 7.2]]], g0); });
1393
+ inset(42, 180, 7, 40, 5, 2.5, g0);
1394
+ rbox(64, 74, 7, 132, 82, 8, 2, bodyMat, g0);
1395
+ // body (its front is the window's back wall) with vertical ribs
1396
+ rbox(X0, Y0, Z0, X1 - X0, YB - Y0, ZT - Z0, 6, bodyMat, g0);
1397
+ { const s = []; for (let u = 14; u <= 92 + 1e-6; u += 6) s.push([[X0 + u, YB + 0.08, ZT - 26], [X0 + u, YB + 0.08, ZT - 196]]); seg(s, g0); }
1398
+ rbox(X0, YB, Z0, WX0 - X0, YF - YB, ZT - Z0, 2, bodyMat, g0); // left stile
1399
+ // bottom rail, built around a real pickup-tray opening (x 80–144, z 27–61, 20 deep) so the bought item can sit in it
1400
+ const TX0 = WX0 + 12, TX1 = WX0 + 76, TZ0 = WZ0 - 58, TZ1 = WZ0 - 24, TY = YF - 20;
1401
+ rbox(WX0, YB, Z0, WX1 - WX0, YF - YB, TZ0 - Z0, 0.01, bodyMat, g0); // below the opening (its top is the tray floor)
1402
+ rbox(WX0, YB, TZ1, WX1 - WX0, YF - YB, WZ0 - TZ1, 0.01, bodyMat, g0); // above
1403
+ rbox(WX0, YB, TZ0, TX0 - WX0, YF - YB, TZ1 - TZ0, 0.01, bodyMat, g0); // left cheek
1404
+ rbox(TX1, YB, TZ0, WX1 - TX1, YF - YB, TZ1 - TZ0, 0.01, bodyMat, g0); // right cheek
1405
+ rbox(TX0, YB, TZ0, TX1 - TX0, TY - YB, TZ1 - TZ0, 0.01, recessMat, g0); // back wall (dark, like the figure's recess)
1406
+ // pickup tray: recess, lit item behind the flap, PUSH flap
1407
+ // the item that lands in the tray: one per row shape, shown for the bought slot and lit until taken
1408
+ const trayItems = {};
1409
+ ROWS.forEach(R => { const g = new THREE.Group(); g.visible = false; g0.add(g); const mat = capMat(); twCaps.push(mat); mat.userData.base = 0.45;
1410
+ rbox((TX0 + TX1) / 2 - R.w / 2, TY + 6, TZ0, R.w, 7, Math.min(R.h, TZ1 - TZ0 - 14), R.L === 'A' ? 2.5 : 1.2, mat, g); trayItems[R.L] = { g, mat }; });
1411
+ const trayHalo = softMat(0xffffff, true);
1412
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(70 * TS, 34 * TS), trayHalo); h.position.copy(W(WX0 + 44, YF + 0.5, WZ0 - 47)); g0.add(h); }
1413
+ const tray = new THREE.Group(); tray.userData = { tray: true }; g0.add(tray);
1414
+ rbox(WX0 + 12, YF - 1, WZ0 - 35, 64, 2.2, 11, 2, bodyMat, tray); // flap
1415
+ { const s = []; for (let v = 26.5; v <= 33 + 1e-6; v += 2.2) s.push([[WX0 + 18, YF + 1.3, WZ0 - v], [WX0 + 70, YF + 1.3, WZ0 - v]]); seg(s, tray); }
1416
+ label('PUSH', 20, 5, 4, onFront(WX0 + 44, YF + 1.2, WZ0 - 30), tray, { spacing: 0.6 });
1417
+ // invisible hit plate over the tray opening so a click anywhere on it takes the item
1418
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(64 * TS, 34 * TS), new THREE.MeshBasicMaterial({ visible: false })); h.position.copy(W(WX0 + 44, YF + 1.5, WZ0 - 41)); tray.add(h); }
1419
+ label('TAKE ITEM', 30, 5, 3.4, onFront(WX0 + 44, YF, WZ0 - 65.3), g0, { spacing: 0.8 });
1420
+ seg([[[WX0 + 6, YF + 0.08, WZ0 - 8], [WX0 + 82, YF + 0.08, WZ0 - 8]]], g0);
1421
+ // shelves with price lips (canvas strips) and items (3 deep per slot)
1422
+ const lips = [], items = [];
1423
+ ROWS.forEach(R => {
1424
+ rbox(WX0, YB, R.z, WX1 - WX0, 28, 8, 0.01, bodyMat, g0);
1425
+ lips.push({ R, cv: faceCanvas('front', WX0, YB + 28, R.z + 8, WX1 - WX0, 8, 20, g0) });
1426
+ const zb = R.z + 8;
1427
+ for (let n = 0; n < 3; n++) for (let i = 0; i < 3; i++) {
1428
+ const code = R.L + (i + 1), cx = WX0 + SW * (i + .5), x = cx - R.w / 2, y = YB + 1.5 + n * 8.5, d = 7;
1429
+ const g = new THREE.Group(); g.userData = { slot: code, n, zb }; g0.add(g);
1430
+ const mat = capMat(); twCaps.push(mat);
1431
+ rbox(x, y, zb, R.w, d, R.h, R.L === 'A' ? 2.5 : 1.2, mat, g);
1432
+ const fy = y + d + 0.08, s = [];
1433
+ if (R.L === 'A') { s.push([[x, fy, zb + R.h - 4], [x + R.w, fy, zb + R.h - 4]], [[x, fy, zb + 4], [x + R.w, fy, zb + 4]]); label(String(i + 1), 6, 6, 4, onFront(x + R.w / 2, fy, zb + R.h / 2), g); }
1434
+ if (R.L === 'B') { for (const v of [1.5, 2.5, 3.5, R.h - 3.5, R.h - 2.5, R.h - 1.5]) s.push([[x + 2, fy, zb + R.h - v], [x + R.w - 2, fy, zb + R.h - v]]);
1435
+ g.add(loopL(Array.from({ length: 24 }, (_, k) => { const t = k / 24 * Math.PI * 2; return W(x + R.w / 2 + R.w / 4 * Math.cos(t), fy, zb + R.h / 2 - 1 + R.w / 4 * Math.sin(t)); }), lineMat)); }
1436
+ if (R.L === 'C') { s.push([[x + 2.5, fy, zb + R.h / 2], [x + R.w - 2.5, fy, zb + R.h / 2]]);
1437
+ g.add(loopL([W(x + 2.5, fy, zb + R.h - 4), W(x + R.w - 2.5, fy, zb + R.h - 4), W(x + R.w - 2.5, fy, zb + 4), W(x + 2.5, fy, zb + 4)], lineMat)); }
1438
+ seg(s, g);
1439
+ items.push({ g, mat, code, n, zb });
1440
+ }
1441
+ });
1442
+ // window frame outline (no glass), top rail with wordmark
1443
+ g0.add(loopL([W(WX0, YF + 0.05, WZ1), W(WX1, YF + 0.05, WZ1), W(WX1, YF + 0.05, WZ0), W(WX0, YF + 0.05, WZ0)], majorMat));
1444
+ rbox(WX0, YB, WZ1, WX1 - WX0, YF - YB, ZT - WZ1, 0.01, bodyMat, g0);
1445
+ frontInset(g0, WX0 + 7, YF, ZT - 5.5, 9, 9, 1.5);
1446
+ seg([[[WX0 + 9, YF + 0.1, ZT - 8], [WX0 + 11.5, YF + 0.1, ZT - 10]], [[WX0 + 11.5, YF + 0.1, ZT - 10], [WX0 + 9, YF + 0.1, ZT - 12]], [[WX0 + 12, YF + 0.1, ZT - 12.3], [WX0 + 14.2, YF + 0.1, ZT - 12.3]]], g0);
1447
+ label('SNACK.EXE', 50, 9, 7.4, onFront(WX0 + 21 + 25, YF, ZT - 10.6), g0, { align: 'left', spacing: 0.9 });
1448
+ // right column: display, keypad well, coin slot, vents, service lock
1449
+ rbox(WX1, YB, Z0, X1 - WX1, YF - YB, ZT - Z0, 2, bodyMat, g0);
1450
+ frontInset(g0, WX1 + 3, YF, ZT - 19, 38, 36, 3);
1451
+ const disp = faceCanvas('front', WX1 + 5, YF, ZT - 21, 34, 32, 16, g0);
1452
+ const dHalo = softMat(0xffffff, true);
1453
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(46 * TS, 44 * TS), dHalo); h.position.copy(W(WX1 + 22, YF + 0.6, ZT - 37)); g0.add(h); }
1454
+ frontInset(g0, WX1 + 3, YF, ZT - 60, 38, 40, 2);
1455
+ frontInset(g0, WX1 + 9, YF, ZT - 106, 26, 22, 3);
1456
+ rbox(WX1 + 20.5, YF - 1, ZT - 125, 3, 1.4, 16, 1.4, bodyMat, g0);
1457
+ seg([[[WX1 + 22, YF + 0.5, ZT - 111], [WX1 + 22, YF + 0.5, ZT - 123]]], g0);
1458
+ label('0.50 ONLY', 26, 5, 3.2, onFront(WX1 + 22, YF, ZT - 132), g0, { spacing: 0.6 });
1459
+ { const s = []; for (let v = 156; v <= 196 + 1e-6; v += 3.5) s.push([[WX1 + 10, YF + 0.08, ZT - v], [WX1 + 34, YF + 0.08, ZT - v]]); seg(s, g0); }
1460
+ const lock = new THREE.Group(); lock.userData = { lock: true }; g0.add(lock);
1461
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(6 * TS, 32), recessMat); d.position.copy(W(WX1 + 22, YF + 0.06, ZT - 226)); lock.add(d);
1462
+ lock.add(loopL(Array.from({ length: 32 }, (_, k) => { const t = k / 32 * Math.PI * 2; return W(WX1 + 22 + 6 * Math.cos(t), YF + 0.07, ZT - 226 + 6 * Math.sin(t)); }), lineMat));
1463
+ rbox(WX1 + 21, YF - 0.5, ZT - 230, 2, 1.2, 8, 1, bodyMat, lock); }
1464
+ label('SERVICE', 22, 5, 3, onFront(WX1 + 22, YF, ZT - 239.5), g0, { spacing: 0.6 });
1465
+ // side vents and panel
1466
+ { const s = []; for (let u = 16; u <= 74 + 1e-6; u += 3.5) { s.push([[X1 + 0.08, YF - u, ZT - 200], [X1 + 0.08, YF - u, ZT - 246]], [[X1 + 0.08, YF - u, ZT - 14], [X1 + 0.08, YF - u, ZT - 34]]); } seg(s, g0); }
1467
+ sideInset(g0, X1, YF - 64, ZT - 120, 12, 30, 2);
1468
+ // crown
1469
+ rbox(58, 68, ZT, 144, 94, 8, 3, bodyMat, g0);
1470
+ { const sh = new THREE.Shape(); roundRect(sh, 64, -(74 + 82), 132, 82, 3); g0.add(loopL(sh.getPoints(8).map(p => W(p.x, -p.y, ZT + 8.06)), lineMat)); }
1471
+ // keypad: real buttons sticking out of the column face; they push in (−y) when pressed
1472
+ const BTN = [['A','A',5,64,10,8], ['B','B',17,64,10,8], ['C','C',29,64,10,8], ['1','1',5,77,10,8], ['2','2',17,77,10,8], ['3','3',29,77,10,8], ['CLR','CLR',5,90,34,8], ['COIN','COIN',9,136,26,9]];
1473
+ const btns = {};
1474
+ BTN.forEach(([id, lab, u, v, w, h]) => {
1475
+ const x = WX1 + u, z = ZT - v - h, g = new THREE.Group(); g.userData = { b: id, pressAxis: 'z', pressDepth: 2 }; g0.add(g);
1476
+ const mat = capMat(); twCaps.push(mat);
1477
+ rbox(x, YF, z, w, 4, h, 1.2, mat, g);
1478
+ label(lab, w, h, lab.length > 1 ? 3.6 : 4.4, onFront(x + w / 2, YF + 4, z + h / 2), g);
1479
+ btns[id] = { g, mat };
1480
+ });
1481
+ const haze = softMat(0xffffff, true), shadow = softMat(0x000000, false);
1482
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 3.0), haze); h.rotation.x = -Math.PI / 2; h.position.copy(W(130, 125, -0.5)); g0.add(h); }
1483
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 2.2), shadow); h.rotation.x = -Math.PI / 2; h.position.copy(W(130, 118, -0.8)); g0.add(h); }
1484
+ for (const m of twMeshes.slice(first)) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); }
1485
+
1486
+ // ── state and behaviour (as in the figure) ──
1487
+ const st = { credit: 0, letter: null, sel: null, msg: '', status: 'ready', vending: false, tray: null, hot: null, stock: Object.fromEntries(Object.keys(PRICE).map(k => [k, 3])) };
1488
+ const drops = [];
1489
+ function drawLips() {
1490
+ const C = pal3(), A_ = '#' + accent.getHexString(), hiCol = theme === 'light' ? A_ : C.hi;
1491
+ lips.forEach(({ R, cv }) => {
1492
+ const { x, P } = cv; x.clearRect(0, 0, cv.c.width, cv.c.height);
1493
+ for (let i = 0; i < 3; i++) {
1494
+ const code = R.L + (i + 1), u = i * SW;
1495
+ const sel = (st.letter && code[0] === st.letter) || st.sel === code, hot = st.hot === code, out = !st.stock[code];
1496
+ if (hot) { x.globalAlpha = 0.35; x.fillStyle = A_; x.beginPath(); x.roundRect((u + 1) * P, 1 * P, (SW - 2) * P, 6 * P, P); x.fill(); x.globalAlpha = 1; }
1497
+ x.fillStyle = out ? C.detail : sel || hot ? hiCol : C.ink;
1498
+ x.font = `${4.2 * P}px ${MONO3}`; x.textAlign = 'left'; x.fillText(code, (u + 3) * P, 5.6 * P);
1499
+ x.font = `${3.6 * P}px ${MONO3}`; x.textAlign = 'right'; x.fillText(f2(PRICE[code]), (u + SW - 3) * P, 5.6 * P);
1500
+ if (i) { x.strokeStyle = C.detail; x.lineWidth = 0.2 * P; x.beginPath(); x.moveTo(u * P, 1.5 * P); x.lineTo(u * P, 6.5 * P); x.stroke(); }
1501
+ }
1502
+ cv.tex.needsUpdate = true;
1503
+ });
1504
+ }
1505
+ function drawDisp(on) {
1506
+ const { x, P } = disp, C = pal3(), A_ = '#' + accent.getHexString();
1507
+ x.clearRect(0, 0, disp.c.width, disp.c.height);
1508
+ x.fillStyle = on ? C.glassOn : C.glassOff; x.beginPath(); x.roundRect(0, 0, disp.c.width, disp.c.height, 2.5 * P); x.fill();
1509
+ x.lineWidth = 0.25 * P; x.strokeStyle = on ? A_ : C.detail; x.stroke();
1510
+ const col = on ? A_ : C.ink;
1511
+ if (on && theme !== 'light') { x.shadowColor = A_; x.shadowBlur = 0.6 * P; }
1512
+ x.fillStyle = col; x.textAlign = 'left';
1513
+ x.font = `${3.4 * P}px ${MONO3}`; x.letterSpacing = `${0.6 * P}px`; x.fillText('CREDIT', 4 * P, 7.5 * P); x.letterSpacing = '0px';
1514
+ x.font = `${9.5 * P}px ${MONO3}`; x.fillText(f2(st.credit), 4 * P, 19 * P);
1515
+ x.font = `${4 * P}px ${MONO3}`; x.fillText(st.msg || 'ready', 4 * P, 27.5 * P);
1516
+ x.shadowBlur = 0; disp.tex.needsUpdate = true;
1517
+ }
1518
+ function readout() {
1519
+ if (active !== 'vm') return;
1520
+ const parts = [`credit ${f2(st.credit)}`], code = st.letter ? st.letter.toLowerCase() + '_' : st.sel ? st.sel.toLowerCase() : '';
1521
+ if (code) parts.push(code); parts.push(st.status);
1522
+ document.getElementById('read').textContent = parts.join(' · ');
1523
+ }
1524
+ function render() {
1525
+ const dispOn = st.credit > 0 || !!st.msg || st.vending;
1526
+ drawDisp(dispOn); dHalo.uniforms.opacity.value = dispOn ? 0.22 : 0;
1527
+ Object.entries(trayItems).forEach(([L, t]) => { t.g.visible = !!st.tray && st.tray[0] === L; t.mat.emissiveIntensity = 0.45; });
1528
+ trayHalo.uniforms.opacity.value = st.tray ? 0.16 : 0;
1529
+ items.forEach(it => {
1530
+ if (!drops.some(d => d.it === it)) it.g.visible = it.n < st.stock[it.code];
1531
+ it.mat.userData.base = st.hot === it.code ? 0.35 : 0;
1532
+ if (!twFlashes.some(f => f.mats.includes(it.mat))) it.mat.emissiveIntensity = it.mat.userData.base;
1533
+ });
1534
+ drawLips(); readout();
1535
+ }
1536
+ function coin() {
1537
+ flash(btns.COIN.g); SFX.coin();
1538
+ if (st.credit >= 500) { st.msg = 'max 5.00'; st.status = 'max'; } else { st.credit += 50; st.msg = st.letter ? st.letter + '_' : ''; st.status = 'credit'; }
1539
+ if (st.tray) st.tray = null; render(); return true;
1540
+ }
1541
+ function letter(L) { flash(btns[L].g); SFX.button(); if (st.vending) return true; st.letter = L; st.sel = null; st.msg = `select ${L}_`; st.status = 'select'; render(); return true; }
1542
+ function number(n) {
1543
+ flash(btns[String(n)].g); SFX.button(); if (st.vending) return true;
1544
+ if (!st.letter) { st.msg = 'pick a-c'; st.status = 'pick a letter'; render(); return true; }
1545
+ const code = st.letter + n, price = PRICE[code]; st.letter = null; st.sel = code;
1546
+ if (!st.stock[code]) { st.msg = `${code} sold out`; st.status = 'sold out'; render(); return true; }
1547
+ if (st.credit < price) { st.msg = `insert ${f2(price - st.credit)}`; st.status = `insert ${f2(price - st.credit)}`; render(); return true; }
1548
+ vend(code, price); return true;
1549
+ }
1550
+ function vend(code, price) {
1551
+ st.credit -= price; st.vending = true; st.tray = null; st.hot = code; st.msg = `${code} vending`; st.status = 'vending';
1552
+ const n = st.stock[code] - 1; st.stock[code] = n;
1553
+ const it = items.find(e => e.code === code && e.n === n);
1554
+ it.g.visible = true; drops.push({ it, t0: performance.now(), dy: (it.zb - WZ0) + 14 });
1555
+ render();
1556
+ setTimeout(() => { st.vending = false; st.tray = code; st.msg = `${code} take item`; st.status = 'vended'; render(); }, 520);
1557
+ setTimeout(() => { if (st.hot === code) { st.hot = null; render(); } }, 900);
1558
+ }
1559
+ function clear() {
1560
+ flash(btns.CLR.g); SFX.button(); if (st.vending) return true;
1561
+ if (st.letter || (st.sel && st.status !== 'vended')) { st.letter = null; st.sel = null; st.msg = 'cancelled'; st.status = 'cancelled'; }
1562
+ else if (st.credit > 0) { st.msg = `refund ${f2(st.credit)}`; st.status = `refund ${f2(st.credit)}`; st.credit = 0; st.sel = null; }
1563
+ else { st.sel = null; st.msg = ''; st.status = 'ready'; st.tray = null; }
1564
+ render(); return true;
1565
+ }
1566
+ function take() { if (!st.tray) return; st.tray = null; st.sel = null; st.msg = ''; st.status = st.credit > 0 ? 'credit' : 'ready'; render(); }
1567
+ function restock() { if (st.vending) return; Object.keys(st.stock).forEach(k => { st.stock[k] = 3; }); st.msg = 'restocked'; st.status = 'restocked'; render(); }
1568
+ function key(e) {
1569
+ const k = e.key;
1570
+ if (k === ' ' || k === '$') { if (!e.repeat) coin(); return true; }
1571
+ if (k === 'Escape' || k === 'Backspace') return clear();
1572
+ if (/^[abc]$/i.test(k)) return letter(k.toUpperCase());
1573
+ if (/^[123]$/.test(k)) return number(+k);
1574
+ return false;
1575
+ }
1576
+ function click(hit) {
1577
+ let o = hit?.object; while (o && !(o.userData.b || o.userData.tray || o.userData.lock)) o = o.parent;
1578
+ if (!o) return; const u = o.userData;
1579
+ if (u.b) { const id = u.b; if (id === 'COIN') coin(); else if (id === 'CLR') clear(); else if (/[ABC]/.test(id)) letter(id); else number(+id); }
1580
+ else if (u.tray) take(); else if (u.lock) restock();
1581
+ }
1582
+ function tickFrame(now, dt) {
1583
+ for (let i = drops.length - 1; i >= 0; i--) { // the item falls (accelerating) into the tray, then disappears
1584
+ const d = drops[i], t = Math.min(1, (now - d.t0) / 500);
1585
+ d.it.g.position.y = -d.dy * TS * t * t;
1586
+ if (t >= 1) { d.it.g.position.y = 0; drops.splice(i, 1); SFX.thunk(); render(); }
1587
+ }
1588
+ const on = st.credit > 0 || st.vending || !!st.tray;
1589
+ haze.uniforms.opacity.value += ((on ? 0.2 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
1590
+ tickFlashes(now);
1591
+ }
1592
+ function demo() { [0, 100, 200].forEach(t => setTimeout(coin, t)); setTimeout(() => letter('B'), 320); setTimeout(() => number(2), 440); }
1593
+ themeHooks.push(t => {
1594
+ const light = t === 'light';
1595
+ [haze, dHalo, trayHalo].forEach(m => { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.needsUpdate = true; m.uniforms.color.value.copy(accent); });
1596
+ shadow.uniforms.opacity.value = light ? 0.12 : 0.5; render();
1597
+ });
1598
+ render();
1599
+ return { group: g0, key, click, tick: tickFrame, stop: () => {}, readout, demo, coin, clear };
1600
+ })();
1601
+
1602
+ // ══ Shared helpers for the newer figures ══
1603
+ function sideCanvas(x, yRight, ztop, w, h, P, parent, transparent = false) { // canvas on a side face (x = const), u runs toward −y
1604
+ const c = document.createElement('canvas'); c.width = Math.round(w * P); c.height = Math.round(h * P);
1605
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
1606
+ const m = new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), new THREE.MeshBasicMaterial({ map: tex, toneMapped: false, transparent, depthWrite: !transparent, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 }));
1607
+ m.rotation.y = Math.PI / 2; m.position.copy(W(x + 0.3, yRight - w / 2, ztop - h / 2)); parent.add(m);
1608
+ return { c, x: c.getContext('2d'), tex, P, mesh: m };
1609
+ }
1610
+ const polyLine = (pts, mat, parent) => { const out = []; for (let i = 0; i < pts.length - 1; i++) out.push(...W(...pts[i]).toArray(), ...W(...pts[i + 1]).toArray()); const l = fat(out, mat); parent.add(l); return l; };
1611
+ const plateWith = (g, w, d, screws, slot) => { // the standard iso plate: rounded slab, inset line, four screws, optional slot
1612
+ rbox(0, 0, 0, w, d, 7, 14, bodyMat, g);
1613
+ { const sh = new THREE.Shape(); roundRect(sh, 9, -(9 + d - 18), w - 18, d - 18, 10); g.add(loopL(sh.getPoints(12).map(p => W(p.x, -p.y, 7.06)), lineMat)); }
1614
+ screws.forEach(([x, y]) => { const s = new THREE.Mesh(new THREE.CircleGeometry(3 * TS, 24), recessMat); s.rotation.x = -Math.PI / 2; s.position.copy(W(x, y, 7.05)); g.add(s); seg([[[x - 1.8, y, 7.2], [x + 1.8, y, 7.2]]], g); });
1615
+ if (slot) inset(slot[0], slot[1], 7, slot[2], 5, 2.5, g);
1616
+ };
1617
+ const hazeAndShadow = (g, cx, cy, w, d) => {
1618
+ const haze = softMat(0xffffff, true), shadow = softMat(0x000000, false);
1619
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.2, d * 1.2), haze); h.rotation.x = -Math.PI / 2; h.position.copy(W(cx, cy, -0.5)); g.add(h); }
1620
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(w, d), shadow); h.rotation.x = -Math.PI / 2; h.position.copy(W(cx, cy + 4, -0.8)); g.add(h); }
1621
+ themeHooks.push(t => { const light = t === 'light'; haze.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; haze.needsUpdate = true; haze.uniforms.color.value.copy(accent); shadow.uniforms.opacity.value = light ? 0.12 : 0.5; });
1622
+ return haze;
1623
+ };
1624
+ const creases = first => { for (const m of twMeshes.slice(first)) { m.add(fat(creaseLines(m.geometry, 30), lineMat)); const mj = fat(creaseLines(m.geometry, 60), majorMat); mj.renderOrder = 1; m.add(mj); } };
1625
+
1626
+ // ══ Desk computer (Fig 1 of the iso figures, rebuilt in 3D) ══
1627
+ const PC = (() => {
1628
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
1629
+ const first = twMeshes.length;
1630
+ const PLZ = 7, CX = 108, CY = 18, CW = 140, CD = 148, CZ = 13, CH = 187, CT = CZ + CH, CF = CY + CD;
1631
+ const KX = 18, KY = 186, KW = 194, KD = 70, KZ = 7, KH = 12, KT = KZ + KH;
1632
+ plateWith(g0, 300, 268, [[18, 18], [282, 18], [18, 250], [282, 250]], [22, 236, 56]);
1633
+ // the computer (click it to switch on/off)
1634
+ const cpu = new THREE.Group(); cpu.userData = { cpu: true }; g0.add(cpu);
1635
+ rbox(CX + 4, CY + 4, PLZ, CW - 8, CD - 8, CZ - PLZ, 3, bodyMat, cpu);
1636
+ rbox(CX, CY, CZ, CW, CD, CH, 10, bodyMat, cpu);
1637
+ inset(CX + 30, CY + 14, CT, 80, 12, 6, cpu);
1638
+ { const s = []; for (let v = 36; v <= 58 + 1e-6; v += 3.2) s.push([[CX + 40, CY + v, CT + 0.1], [CX + 104, CY + v, CT + 0.1]]); seg(s, cpu); }
1639
+ { const sh = new THREE.Shape(); roundRect(sh, 0, 0, 120, 108, 11); cpu.add(loopL(sh.getPoints(12).map(p => W(CX + 10 + p.x, CF + 0.08, CT - 12 - 108 + p.y)), lineMat)); }
1640
+ frontInset(cpu, CX + 15, CF, CT - 17, 110, 98, 12);
1641
+ const scr = faceCanvas('front', CX + 22, CF, CT - 22, 96, 86, 10, cpu);
1642
+ const halo = softMat(0xffffff, true);
1643
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(96 * TS * 1.25, 86 * TS * 1.25), halo); h.position.copy(W(CX + 70, CF + 0.6, CT - 65)); cpu.add(h); }
1644
+ { const s = []; for (let u = 14; u <= 38 + 1e-6; u += 2.6) s.push([[CX + u, CF + 0.08, CT - 128], [CX + u, CF + 0.08, CT - 146]]); seg(s, cpu); }
1645
+ const ledMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 });
1646
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(1.4 * TS, 20), ledMat); d.position.copy(W(CX + 15, CF + 0.1, CT - 156)); cpu.add(d); }
1647
+ const ledGlow = glowPoints([W(CX + 15, CF + 0.5, CT - 156)], 0.12); cpu.add(ledGlow);
1648
+ frontInset(cpu, CX + 72, CF, CT - 148, 54, 5, 2.5);
1649
+ { const s = []; for (let u = 96; u <= 136 + 1e-6; u += 3.4) s.push([[CX + CW + 0.08, CF - u, CT - 12], [CX + CW + 0.08, CF - u, CT - 70]]);
1650
+ for (let u = 40; u <= 64 + 1e-6; u += 3.4) s.push([[CX + CW + 0.08, CF - u, CT - 130], [CX + CW + 0.08, CF - u, CT - 168]]); seg(s, cpu); }
1651
+ sideInset(cpu, CX + CW, CF - 8, CT - 148, 16, 26, 2);
1652
+ // keyboard
1653
+ rbox(KX, KY, KZ, KW, KD, KH, 6, bodyMat, g0);
1654
+ inset(KX + 6, KY + 5, KT, KW - 12, KD - 10, 3, g0);
1655
+ const ROWS = [
1656
+ [['`',1],['1',1],['2',1],['3',1],['4',1],['5',1],['6',1],['7',1],['8',1],['9',1],['0',1],['-',1],['=',1],['Backspace',1]],
1657
+ [['Tab',1.5],['q',1],['w',1],['e',1],['r',1],['t',1],['y',1],['u',1],['i',1],['o',1],['p',1],['[',1],[']',1.5]],
1658
+ [['CapsLock',1.75],['a',1],['s',1],['d',1],['f',1],['g',1],['h',1],['j',1],['k',1],['l',1],[';',1],["'",1],['Enter',1.25]],
1659
+ [['Shift',2.25],['z',1],['x',1],['c',1],['v',1],['b',1],['n',1],['m',1],[',',1],['.',1],['/',1],['ShiftRight',1.75]],
1660
+ [['Control',1.25],['Alt',1.25],['Meta',1.25],[' ',7],['MetaRight',1.25],['AltRight',1],['ControlRight',1]],
1661
+ ];
1662
+ const AX = KX + 9, AY = KY + 7, U = (KW - 18) / 14, RD = (KD - 14) / ROWS.length, G = 1.3, keys = new Map();
1663
+ ROWS.forEach((row, r) => { let x = AX; row.forEach(([k, w]) => {
1664
+ const g = new THREE.Group(); g.userData = { key: k }; g0.add(g); const mat = capMat(); twCaps.push(mat);
1665
+ rbox(x + G / 2, AY + r * RD + G / 2, KT, w * U - G, RD - G, 4, 1.5, mat, g); keys.set(k, g); x += w * U; }); });
1666
+ // coiled cable from the keyboard to the computer's side port (a pulse of light runs along it on each key)
1667
+ const plugK = [KX + KW, 226, 9], plugC = [CX + CW, CF - 16, CT - 161];
1668
+ rbox(plugK[0], plugK[1], plugK[2], 6, 8, 8, 1.5, bodyMat, g0); rbox(plugC[0], plugC[1], plugC[2], 6, 8, 10, 1.5, bodyMat, g0);
1669
+ const P0 = [plugK[0] + 6, plugK[1] + 4, plugK[2] + 4], P1 = [P0[0] + 34, P0[1] + 6, 8], P2 = [plugC[0] + 30, plugC[1] + 30, 8], P3 = [plugC[0] + 6, plugC[1] + 4, plugC[2] + 5];
1670
+ const bez = t => P0.map((_, i) => (1 - t) ** 3 * P0[i] + 3 * (1 - t) ** 2 * t * P1[i] + 3 * (1 - t) * t ** 2 * P2[i] + t ** 3 * P3[i]);
1671
+ const coil = [];
1672
+ for (let i = 0; i <= 900; i++) {
1673
+ const t = i / 900, p = bez(t), a = bez(Math.max(0, t - 1e-3)), b = bez(Math.min(1, t + 1e-3));
1674
+ let T = b.map((v, j) => v - a[j]); const tl = Math.hypot(...T); T = T.map(v => v / tl);
1675
+ let N1 = [T[1], -T[0], 0]; const nl = Math.hypot(...N1) || 1; N1 = N1.map(v => v / nl);
1676
+ const N2 = [T[1] * N1[2] - T[2] * N1[1], T[2] * N1[0] - T[0] * N1[2], T[0] * N1[1] - T[1] * N1[0]];
1677
+ const env = Math.min(1, Math.max(0, (t - .08) / .06)) * Math.min(1, Math.max(0, (.9 - t) / .06)), ang = t * 34 * 2 * Math.PI;
1678
+ coil.push(p.map((v, j) => v + env * 2.6 * (Math.cos(ang) * N1[j] + Math.sin(ang) * N2[j])));
1679
+ }
1680
+ const cable = polyLine(coil, majorMat, g0);
1681
+ const pulse = glowPoints([W(...P0)], 0.18); g0.add(pulse);
1682
+ const haze = hazeAndShadow(g0, 150, 140, 3.6, 3.0);
1683
+ creases(first);
1684
+
1685
+ // ── state and behaviour (as in the figure) ──
1686
+ const st = { on: true, text: '', key: null, pulseT: -1 };
1687
+ const TAIL = 15, logo = [new Path2D('M0 17 L6.3 0 L9.9 0 L16.2 17 L12.6 17 L11.3 13.4 L4.9 13.4 L3.6 17 Z M6 10.4 L10.2 10.4 L8.1 4.5 Z'), new Path2D('M12.2 0 L15.8 0 L22.1 17 L18.5 17 Z')];
1688
+ let blink = true;
1689
+ function drawScreen() {
1690
+ const { x, P } = scr, C = pal3(), A_ = '#' + accent.getHexString(), on = st.on;
1691
+ x.clearRect(0, 0, scr.c.width, scr.c.height);
1692
+ x.fillStyle = on ? C.glassOn : C.glassOff; x.beginPath(); x.roundRect(0, 0, scr.c.width, scr.c.height, 12 * P); x.fill();
1693
+ x.lineWidth = 0.3 * P; x.strokeStyle = on ? A_ : C.detail; x.stroke();
1694
+ if (on) {
1695
+ x.save(); x.translate(34.5 * P, 20 * P); x.scale(1.25 * P, 1.25 * P); x.fillStyle = A_; x.fill(logo[0], 'evenodd'); x.fill(logo[1]); x.restore();
1696
+ const vis = st.text.slice(-TAIL);
1697
+ x.fillStyle = theme === 'light' ? C.inkHi : C.hi; x.font = `${6 * P}px ${MONO3}`; x.fillText('> ' + vis, 8 * P, 72 * P);
1698
+ if (blink) { x.fillStyle = A_; x.fillRect((8 + (2 + vis.length) * 3.6 + .4) * P, 67 * P, 3.4 * P, 6 * P); }
1699
+ }
1700
+ scr.tex.needsUpdate = true;
1701
+ }
1702
+ function readout() {
1703
+ if (active !== 'pc') return;
1704
+ const parts = [st.on ? 'on' : 'off']; if (st.on || st.text) parts.push(`${st.text.length} chars`); if (st.key) parts.push(`key ${st.key}`);
1705
+ document.getElementById('read').textContent = parts.join(' · ');
1706
+ }
1707
+ function render() {
1708
+ ledMat.emissiveIntensity = st.on ? 1.4 : 0; ledGlow.material.uniforms.intensity.value = st.on ? 0.8 : 0;
1709
+ halo.uniforms.opacity.value = st.on ? 0.2 : 0;
1710
+ cable.material = st.on ? accentLineMat : majorMat;
1711
+ drawScreen(); readout();
1712
+ }
1713
+ const BASE = { '!':'1','@':'2','#':'3','$':'4','%':'5','^':'6','&':'7','*':'8','(':'9',')':'0','_':'-','+':'=','{':'[','}':']',':':';','"':"'",'<':',','>':'.','?':'/','~':'`' };
1714
+ function press(k, code = '') {
1715
+ let id = k.length === 1 ? (BASE[k] || k.toLowerCase()) : k;
1716
+ if (/Right$/.test(code)) id = code.replace(/Left|Right/, '') + 'Right';
1717
+ const el = keys.get(id); if (!el) return false;
1718
+ flash(el, 90); SFX.key(); if (st.on) st.pulseT = performance.now();
1719
+ st.key = k === ' ' ? 'space' : k.length === 1 ? k : id.replace('Right', '').toLowerCase();
1720
+ if (st.on) { if (k === 'Backspace') st.text = st.text.slice(0, -1); else if (k === 'Enter') st.text = ''; else if (k.length === 1) st.text += k; }
1721
+ render(); return true;
1722
+ }
1723
+ function power() { st.on = !st.on; render(); }
1724
+ function key(e) { return press(e.key, e.code); }
1725
+ function click(hit) {
1726
+ let o = hit?.object; while (o && o.userData.key === undefined && !o.userData.cpu) o = o.parent; if (!o) return;
1727
+ if (o.userData.cpu) power(); else { const id = o.userData.key; press(id.replace('Right', ''), /Right$/.test(id) ? id : ''); }
1728
+ }
1729
+ function tick(now, dt) {
1730
+ const b = Math.floor(now / 530) % 2 === 0; if (b !== blink) { blink = b; if (st.on) drawScreen(); }
1731
+ if (st.pulseT >= 0) { const t = Math.max(0, (now - st.pulseT) / 450); // rAF time can lag performance.now() slightly // light pulse runs keyboard → computer
1732
+ if (t >= 1) { st.pulseT = -1; pulse.material.uniforms.intensity.value = 0; }
1733
+ else { pulse.geometry.attributes.position.setXYZ(0, ...W(...coil[Math.floor(t * (coil.length - 1))]).toArray()); pulse.geometry.attributes.position.needsUpdate = true; pulse.material.uniforms.intensity.value = 0.9; } }
1734
+ haze.uniforms.opacity.value += ((st.on ? 0.18 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
1735
+ tickFlashes(now);
1736
+ }
1737
+ function demo() { 'hello claude'.split('').forEach((c, i) => setTimeout(() => press(c), i * 40)); }
1738
+ themeHooks.push(t => { ledMat.color.set(t === 'light' ? 0xc3c6c8 : 0x2b2e30); halo.blending = t === 'light' ? THREE.NormalBlending : THREE.AdditiveBlending; halo.needsUpdate = true; halo.uniforms.color.value.copy(accent); drawScreen(); });
1739
+ render();
1740
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
1741
+ panel: [['Demo', '▶', demo], ['Power', '', power]],
1742
+ view: { target: W(140, 140, 90), dist: 8 },
1743
+ info: '<h2>Desk computer</h2><p>Type on your keyboard or click its keys to write on the screen; a pulse of light runs down the coiled cable with each key. Click the computer to switch it on or off.</p>',
1744
+ hint: 'type on it · click it to switch' };
1745
+ })();
1746
+
1747
+ // ══ Desk lamp (Fig 8 of the iso figures, rebuilt in 3D): a real spotlight in the shade lights the desk, book and pencil ══
1748
+ const LP = (() => {
1749
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
1750
+ const first = twMeshes.length, PLZ = 7;
1751
+ const add = (a, b) => a.map((v, i) => v + b[i]), sub = (a, b) => a.map((v, i) => v - b[i]), mul = (a, k) => a.map(v => v * k);
1752
+ const len = a => Math.hypot(...a), nrm = a => mul(a, 1 / len(a));
1753
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
1754
+ const NA = nrm([1, 1, 0]); // arm-plane normal (iso coords)
1755
+ const Bp = [212, 37, 35], E = [182, 67, 150], H = [126, 123, 146], At = [116, 133, 134];
1756
+ const dir = nrm([-0.22, 0.22, -1]), SL = 34, RT = 7, RR = 24, R = add(At, mul(dir, SL)), SPREAD = 0.52;
1757
+ const hitZ = z => { const t = (R[2] - z) / -dir[2]; return { c: add(R, mul(dir, t)), r: RR + SPREAD * t }; };
1758
+ const floor = hitZ(PLZ);
1759
+ const vW = v => new THREE.Vector3(v[0], v[2], v[1]).normalize(); // iso direction → Three.js direction
1760
+ plateWith(g0, 300, 250, [[18, 18], [282, 18], [18, 232], [282, 232]]);
1761
+ // base, bracket, dimmer, rocker switch
1762
+ const BX = 190, BY = 22, BW = 72, BD = 60, BZ = PLZ, BH = 18, BT = BZ + BH;
1763
+ rbox(BX, BY, BZ, BW, BD, BH, 6, bodyMat, g0);
1764
+ { const sh = new THREE.Shape(); roundRect(sh, BX + 4, -(BY + 4 + BD - 8), BW - 8, BD - 8, 4); g0.add(loopL(sh.getPoints(8).map(p => W(p.x, -p.y, BT + 0.06)), lineMat)); }
1765
+ { const s = []; for (let u = 8; u <= 30 + 1e-6; u += 3) s.push([[BX + u, BY + BD + 0.08, BT - 5], [BX + u, BY + BD + 0.08, BT - 13]]); seg(s, g0); }
1766
+ label('A-08', 16, 6, 5, onFront(BX + BW - 8 - 8, BY + BD, BT - 10), g0);
1767
+ sideInset(g0, BX + BW, BY + BD - (BD - 38), BT - 6, 9, 7, 1.5);
1768
+ polyLine([[BX + BW, BY + BD - 33, BZ + 3.5], [BX + BW + 10, BY + BD - 33, PLZ + 0.6], [BX + BW + 22, BY + BD - 22, PLZ + 0.6], [BX + BW + 26, BY + BD + 14, PLZ + 0.6], [BX + BW + 27, 150, PLZ + 0.6]], majorMat, g0);
1769
+ rbox(204, 30, BT, 16, 14, 8, 2, bodyMat, g0);
1770
+ const KC = [215, 61], KR = 9, KZ1 = BT + 7, dets = [];
1771
+ for (let i = 0; i <= 10; i++) {
1772
+ const a = (-135 + i * 27) * Math.PI / 180, sn = Math.sin(a), cs = -Math.cos(a), r1 = KR + 2, r2 = KR + (i % 5 ? 4 : 5.5);
1773
+ const pts = [[KC[0] + sn * r1, KC[1] + cs * r1, BT + 0.1], [KC[0] + sn * r2, KC[1] + cs * r2, BT + 0.1]];
1774
+ polyLine(pts, lineMat, g0); const lit = polyLine(pts, accentLineMat, g0); lit.visible = false; dets.push(lit);
1775
+ }
1776
+ label('DIM', 12, 6, 4.5, onTop(KC[0] - KR - 6 + 6, KC[1] + KR + 7.5, BT), g0, {});
1777
+ const knob = new THREE.Group(); knob.userData = { knob: true }; g0.add(knob);
1778
+ { const k = new THREE.Mesh(new THREE.CylinderGeometry(KR * TS, KR * TS, (KZ1 - BT) * TS, 40), bodyMat); k.position.copy(W(KC[0], KC[1], (BT + KZ1) / 2)); addOutline(k); twMeshes.push(k); knob.add(k);
1779
+ const s = []; for (let i = 0; i < 24; i++) { const a = i / 24 * 2 * Math.PI; s.push([[KC[0] + KR * Math.cos(a), KC[1] + KR * Math.sin(a), BT + 0.6], [KC[0] + KR * Math.cos(a), KC[1] + KR * Math.sin(a), KZ1 - 0.6]]); } seg(s, knob);
1780
+ knob.add(loopL(Array.from({ length: 40 }, (_, i) => { const a = i / 40 * Math.PI * 2; return W(KC[0] + (KR - 2) * Math.cos(a), KC[1] + (KR - 2) * Math.sin(a), KZ1 + 0.06); }), lineMat)); }
1781
+ const tickPivot = new THREE.Group(); tickPivot.position.copy(W(KC[0], KC[1], KZ1 + 0.1)); knob.add(tickPivot);
1782
+ const tickMat = new THREE.MeshBasicMaterial({ color: 0xe6e8e9, toneMapped: false });
1783
+ { const t = new THREE.Mesh(new THREE.PlaneGeometry(2 * TS, (KR - 1.3) * TS), tickMat); t.rotation.x = -Math.PI / 2; t.position.set(0, 0, -(KR - 1.3) / 2 * TS - 1.3 * TS); tickPivot.add(t); }
1784
+ const SX = 236, SY = 56, sw = new THREE.Group(); sw.userData = { sw: true }; g0.add(sw);
1785
+ const swMat = capMat(); twCaps.push(swMat); rbox(SX, SY, BT, 16, 16, 5, 2, swMat, sw);
1786
+ seg([[[SX + 2, SY + 8, BT + 5.1], [SX + 14, SY + 8, BT + 5.1]]], sw);
1787
+ label('I', 4, 5, 4.5, onTop(SX + 8, SY + 4.5, BT + 5), sw); label('O', 4, 5, 4.5, onTop(SX + 8, SY + 12, BT + 5), sw);
1788
+ // book and pencil
1789
+ const KX = 50, KY = 130, KW = 80, KD = 60, KH = 12, KT = PLZ + KH;
1790
+ rbox(KX, KY, PLZ, KW, KD, KH, 2, bodyMat, g0);
1791
+ frontInset(g0, KX + 4, KY + KD, KT - 2.2, KW - 5, KH - 4.4, 0.5);
1792
+ { const s = []; for (let v = 3.6; v <= 9 + 1e-6; v += 1.35) s.push([[KX + 4.5, KY + KD + 0.1, KT - v], [KX + KW - 1.5, KY + KD + 0.1, KT - v]]); seg(s, g0); }
1793
+ sideInset(g0, KX + KW, KY + KD - 1, KT - 2.2, KD - 2, KH - 4.4, 0.5);
1794
+ { const s = []; for (let v = 3.6; v <= 9 + 1e-6; v += 1.35) s.push([[KX + KW + 0.1, KY + KD - 1.5, KT - v], [KX + KW + 0.1, KY + 1.5, KT - v]]); seg(s, g0); }
1795
+ { const sh = new THREE.Shape(); roundRect(sh, KX + 7, -(KY + 4 + KD - 8), KW - 11, KD - 8, 1.5); g0.add(loopL(sh.getPoints(6).map(p => W(p.x, -p.y, KT + 0.06)), lineMat)); }
1796
+ seg([[[KX + 5, KY, KT + 0.06], [KX + 5, KY + KD, KT + 0.06]], [[KX + 15, KY + 20, KT + 0.06], [KX + 50, KY + 20, KT + 0.06]]], g0);
1797
+ label('FIELD NOTES', 48, 8, 6, onTop(KX + 15 + 24, KY + 14, KT), g0, { align: 'left' });
1798
+ label('vol. 8', 20, 6, 4.5, onTop(KX + 15 + 10, KY + KD - 10.5, KT), g0, { align: 'left' });
1799
+ const QX = 70, QY = 204, QL = 88;
1800
+ rbox(QX, QY, PLZ, QL, 6, 6, 1.5, bodyMat, g0);
1801
+ seg([[[QX + 9, QY, PLZ + 6.1], [QX + 9, QY + 6, PLZ + 6.1]], [[QX + 12, QY, PLZ + 6.1], [QX + 12, QY + 6, PLZ + 6.1]], [[QX + 14, QY + 3, PLZ + 6.1], [QX + QL, QY + 3, PLZ + 6.1]]], g0);
1802
+ label('HB · 2', 16, 4, 3.6, onFront(QX + 22 + 8, QY + 6, PLZ + 2.8), g0, { align: 'left' });
1803
+ rbox(QX + QL, QY + 1, PLZ + 1, 8, 4, 4, 1, bodyMat, g0); rbox(QX + QL + 8, QY + 2, PLZ + 2, 4, 2, 2, 0.6, bodyMat, g0);
1804
+ // arm: parallelogram rods, springs, hinges, neck — all in the plane x+y = 249
1805
+ const rodMesh = (a, b, r) => { const A = W(...a), B = W(...b), d = B.clone().sub(A);
1806
+ const m = new THREE.Mesh(new THREE.CylinderGeometry(r * TS, r * TS, d.length(), 16), bodyMat); m.position.copy(A).add(B).multiplyScalar(0.5);
1807
+ m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d.normalize()); addOutline(m); twMeshes.push(m); g0.add(m); };
1808
+ const pair = (a, b, w, off) => { const U = nrm(sub(b, a)), V = nrm(cross(NA, U)); [off, -off].forEach(o => rodMesh(add(a, mul(V, o)), add(b, mul(V, o)), w / 2)); };
1809
+ pair(Bp, E, 3.4, 4); pair(E, H, 3.2, 3.6); rodMesh(H, At, 2.1);
1810
+ { const U = nrm(sub(E, Bp)), V = nrm(cross(NA, U)), L = len(sub(E, Bp)), pts = [];
1811
+ for (let i = 0; i <= 160; i++) { const s = 0.12 + 0.6 * i / 160, ph = i / 160 * 22 * 2 * Math.PI; pts.push(add(add(add(Bp, mul(U, s * L)), mul(V, 1.8 * Math.sin(ph))), mul(NA, 4 + 1.8 * Math.cos(ph)))); }
1812
+ polyLine(pts, majorMat, g0); }
1813
+ const hinge = (c, r) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(r * TS, r * TS, 3 * TS, 24), bodyMat); m.position.copy(W(...c)); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), vW(NA)); addOutline(m); twMeshes.push(m); g0.add(m); };
1814
+ hinge(Bp, 6.5); hinge(E, 7); hinge(H, 5.5);
1815
+ // shade: an open cone from the cap (At) to the rim (R); rim lights up when on
1816
+ const shadeLen = SL * TS, shade = new THREE.Mesh(new THREE.CylinderGeometry(RT * TS, RR * TS, shadeLen, 64, 1, true), bodyMat);
1817
+ shade.position.copy(W(...add(At, mul(dir, SL / 2)))); shade.quaternion.setFromUnitVectors(new THREE.Vector3(0, -1, 0), vW(dir)); addOutline(shade); g0.add(shade);
1818
+ const cap = new THREE.Mesh(new THREE.CircleGeometry(RT * TS, 32), bodyMat); cap.position.copy(W(...At)); cap.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), vW(dir).negate()); g0.add(cap);
1819
+ const ringPts = (c, r) => { const e1 = vW(NA), e2 = new THREE.Vector3().crossVectors(vW(dir), e1).normalize(), C = W(...c);
1820
+ return Array.from({ length: 72 }, (_, i) => { const t = i / 72 * Math.PI * 2; return C.clone().addScaledVector(e1, r * TS * Math.cos(t)).addScaledVector(e2, r * TS * Math.sin(t)); }); };
1821
+ [0.62, 0.86].forEach(s => g0.add(loopL(ringPts(add(At, mul(dir, s * SL)), RT + (RR - RT) * s), lineMat)));
1822
+ const rimOff = loopL(ringPts(R, RR), majorMat), rimOn = loopL(ringPts(R, RR), accentLineMat); rimOn.visible = false; g0.add(rimOff, rimOn);
1823
+ g0.add(loopL(ringPts(At, RT), majorMat));
1824
+ // bulb + the light itself (real spotlight, aimed down the shade axis) + soft pool on the desk
1825
+ const bulbMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0 });
1826
+ const bulbPos = add(At, mul(dir, SL * 0.55));
1827
+ { const b = new THREE.Mesh(new THREE.SphereGeometry(6 * TS, 24, 16), bulbMat); b.position.copy(W(...bulbPos)); g0.add(b); }
1828
+ const bulbGlow = glowPoints([W(...add(R, mul(dir, -2)))], 0.5); g0.add(bulbGlow);
1829
+ const spot = new THREE.SpotLight(0xffffff, 0, 4, 0.62, 0.55, 1.2); spot.position.copy(W(...bulbPos));
1830
+ spot.target.position.copy(W(...floor.c)); g0.add(spot, spot.target);
1831
+ const poolMat = softMat(0xffffff, true);
1832
+ { const p = new THREE.Mesh(new THREE.PlaneGeometry(floor.r * 2.6 * TS, floor.r * 2.6 * TS), poolMat); p.rotation.x = -Math.PI / 2; p.position.copy(W(floor.c[0], floor.c[1], PLZ + 0.1)); g0.add(p); }
1833
+ const haze = hazeAndShadow(g0, 160, 130, 3.8, 3.2);
1834
+ creases(first);
1835
+
1836
+ // ── state and behaviour (as in the figure) ──
1837
+ const st = { on: false, level: 70 };
1838
+ function readout() { if (active !== 'lp') return; document.getElementById('read').textContent = st.on ? (st.level ? `on · ${st.level}% · ${2000 + st.level * 10}K` : 'on · 0%') : 'off'; }
1839
+ function render() {
1840
+ const lit = st.on && st.level > 0, k = st.level / 100, g = lit ? 0.15 + 0.85 * k : 0;
1841
+ swMat.userData.base = st.on ? 0.55 : 0; if (!twFlashes.some(f => f.mats.includes(swMat))) swMat.emissiveIntensity = swMat.userData.base;
1842
+ tickPivot.rotation.y = -(-135 + st.level * 2.7) * Math.PI / 180;
1843
+ tickMat.color.copy(lit ? accent : new THREE.Color(theme === 'light' ? 0x16181a : 0xe6e8e9));
1844
+ dets.forEach((d, i) => d.visible = lit && i * 10 <= st.level);
1845
+ rimOn.visible = lit; rimOff.visible = !lit;
1846
+ bulbMat.emissiveIntensity = lit ? 2 * g : 0; bulbGlow.material.uniforms.intensity.value = lit ? 0.9 * g : 0;
1847
+ spot.color.copy(accent).lerp(new THREE.Color(0xffffff), 0.35); spot.intensity = lit && theme !== 'light' ? 14 * g : 0; // light theme: no light pool, the lit bulb and rim show it's on
1848
+ poolMat.uniforms.opacity.value = lit ? 0.42 * g : 0;
1849
+ readout();
1850
+ }
1851
+ function toggle() { st.on = !st.on; flash(sw); SFX.switch(); render(); return true; }
1852
+ function setLevel(v) { st.level = Math.max(0, Math.min(100, v)); flash(knob, 120); SFX.detent(); render(); return true; }
1853
+ const step = d => setLevel(st.level + d * 10);
1854
+ function key(e) {
1855
+ if (e.key === 'ArrowUp') return step(1); if (e.key === 'ArrowDown') return step(-1);
1856
+ if (/^[0-9]$/.test(e.key)) return setLevel(+e.key * 10); if (e.key === 'm' || e.key === 'M') return setLevel(100);
1857
+ if (e.key === 'o' || e.key === 'O' || e.key === ' ') { if (!e.repeat) toggle(); return true; }
1858
+ return false;
1859
+ }
1860
+ function click(hit, ev) { let o = hit?.object; while (o && !o.userData.sw && !o.userData.knob) o = o.parent; if (!o) return;
1861
+ if (o.userData.sw) toggle(); else step(ev && ev.shiftKey ? -1 : 1); }
1862
+ function tick(now, dt) { haze.uniforms.opacity.value += ((st.on && st.level ? 0.14 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4); tickFlashes(now); }
1863
+ function demo() { setTimeout(toggle, 20); setTimeout(() => step(1), 160); }
1864
+ themeHooks.push(t => { bulbMat.color.set(t === 'light' ? 0xc3c6c8 : 0x2b2e30); poolMat.blending = t === 'light' ? THREE.NormalBlending : THREE.AdditiveBlending; poolMat.needsUpdate = true; poolMat.uniforms.color.value.copy(accent); render(); });
1865
+ render();
1866
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
1867
+ panel: [['Switch', 'O', toggle], ['Brighter', '↑', () => step(1)], ['Dimmer', '↓', () => step(-1)]],
1868
+ view: { target: W(160, 125, 70), dist: 7.4 },
1869
+ info: '<h2>Desk lamp</h2><p>Click the rocker switch (or press O / space) to turn it on or off; click the dimmer knob to raise brightness, shift-click to lower it. Arrow keys step by 10%, digits 0–9 set 0–90%, M sets 100%. The light is a real spotlight in the shade.</p>',
1870
+ hint: 'click switch · click knob to dim · ↑↓ 0-9 m' };
1871
+ })();
1872
+
1873
+ // ══ Logic board (Fig 9 of the iso figures, rebuilt in 3D): three switches feed four real gates ══
1874
+ const LG = (() => {
1875
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
1876
+ const first = twMeshes.length, PZ = 7, BZ = 17, WZ = 17.6;
1877
+ const SW = [['a', 'A', 80], ['b', 'B', 140], ['c', 'C', 200]], CY = [50, 102, 154, 206];
1878
+ const GATES = [
1879
+ { n: 'and', lbl: 'AND', ins: ['a', 'b'], f: (x, y) => x & y }, { n: 'or', lbl: 'OR', ins: ['a', 'b'], f: (x, y) => x | y },
1880
+ { n: 'xor', lbl: 'XOR', ins: ['a', 'b'], f: (x, y) => x ^ y }, { n: 'nand', lbl: 'NAND', ins: ['b', 'c'], f: (x, y) => 1 - (x & y) },
1881
+ ];
1882
+ const BUS = { a: 84, b: 96, c: 108 }, SY = { a: 80, b: 140, c: 200 };
1883
+ plateWith(g0, 380, 290, [[13, 13], [367, 13], [13, 277], [367, 277]]);
1884
+ rbox(20, 20, PZ, 340, 250, BZ - PZ, 6, bodyMat, g0);
1885
+ { const s = []; for (let u = 14; u <= 326 + 1e-6; u += 6) s.push([[20 + u, 270.08, BZ - 3], [20 + u, 270.08, BZ - 7]]); seg(s, g0); }
1886
+ // board top: breadboard holes, rails, printed labels — one canvas, gate names light up with their outputs
1887
+ const board = faceCanvas('top', 24, 24, BZ, 332, 242, 4, g0, true);
1888
+ // wires (3D hairlines that switch to the accent colour when they carry a 1) and the light pulses that run along them
1889
+ const routes = [];
1890
+ ['c', 'b', 'a'].forEach(s => GATES.forEach((g, i) => g.ins.forEach((src, j) => { if (src !== s) return;
1891
+ const yi = CY[i] + (j ? 17 : 7); routes.push({ src: s, pts: [[64, SY[s]], [BUS[s], SY[s]], [BUS[s], yi], [150, yi]] }); })));
1892
+ GATES.forEach((g, i) => routes.push({ src: g.n, pts: [[196, CY[i] + 17], [232, CY[i] + 17], [232, CY[i] + 12], [264, CY[i] + 12]] }));
1893
+ routes.forEach(r => { r.line = polyLine(r.pts.map(([x, y]) => [x, y, WZ]), majorMat, g0);
1894
+ r.cum = [0]; for (let i = 1; i < r.pts.length; i++) r.cum.push(r.cum[i - 1] + Math.hypot(r.pts[i][0] - r.pts[i - 1][0], r.pts[i][1] - r.pts[i - 1][1])); });
1895
+ Object.keys(BUS).forEach(s => { const d = new THREE.Mesh(new THREE.CircleGeometry(1.4 * TS, 12), recessMat); d.rotation.x = -Math.PI / 2; d.position.copy(W(BUS[s], SY[s], WZ + 0.05)); g0.add(d); });
1896
+ // switches: base with slot, lever slides +12 in x when on
1897
+ const levers = {};
1898
+ SW.forEach(([k, L, s]) => {
1899
+ const swg = new THREE.Group(); swg.userData = { sw: k }; g0.add(swg);
1900
+ rbox(40, s - 8, BZ, 24, 16, 6, 2, bodyMat, swg);
1901
+ inset(43, s - 8 + 5.5, BZ + 6, 18, 5, 2, swg);
1902
+ label('0', 3, 4, 3.4, onTop(41.5 + 1, s - 8 + 3, BZ + 6), swg); label('1', 3, 4, 3.4, onTop(60 + 1, s - 8 + 3, BZ + 6), swg);
1903
+ const lv = new THREE.Group(); lv.userData = { sw: k }; swg.add(lv); const mat = capMat(); twCaps.push(mat);
1904
+ rbox(43, s - 3, BZ + 6, 6, 6, 8, 1.5, mat, lv); levers[k] = { g: lv, mat, x: 0 };
1905
+ });
1906
+ // chips (legs, body, printed top), LEDs, readout block
1907
+ const chipMat = new THREE.MeshStandardMaterial({ color: 0x0f1011, roughness: 0.8, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
1908
+ const chipTops = [], leds = [];
1909
+ GATES.forEach((g, i) => {
1910
+ const cy = CY[i];
1911
+ for (let p = 0; p < 5; p++) { rbox(156 + p * 8, cy - 1.5, BZ, 2, 2, 3, 0.3, bodyMat, g0); rbox(156 + p * 8, cy + 24, BZ, 2, 1.5, 3, 0.3, bodyMat, g0); }
1912
+ rbox(150, cy, BZ + 3, 46, 24, 6, 1.5, chipMat, g0);
1913
+ chipTops.push({ g, cv: faceCanvas('top', 150, cy, BZ + 9, 46, 24, 10, g0, true) });
1914
+ rbox(262, cy + 7, BZ, 10, 10, 2, 2, bodyMat, g0);
1915
+ const ledMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0, roughness: 0.4, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
1916
+ rbox(263, cy + 8, BZ + 2, 8, 8, 7, 4, ledMat, g0);
1917
+ const glow = glowPoints([W(267, cy + 12, BZ + 9.5)], 0.32); g0.add(glow); leds.push({ mat: ledMat, glow });
1918
+ });
1919
+ rbox(316, 28, BZ, 30, 56, 24, 3, bodyMat, g0);
1920
+ { const s = []; for (let v = 6; v <= 50 + 1e-6; v += 4) s.push([[316 + 8, 28 + v, BZ + 24.1], [316 + 22, 28 + v, BZ + 24.1]]); seg(s, g0); }
1921
+ sideInset(g0, 346, 84 - 3, BZ + 24 - 3, 50, 13, 2.5);
1922
+ const rd = sideCanvas(346, 84 - 5, BZ + 24 - 4.5, 46, 16.5, 12, g0, true);
1923
+ const rdHalo = softMat(0xffffff, true);
1924
+ { const h = new THREE.Mesh(new THREE.PlaneGeometry(58 * TS, 18 * TS), rdHalo); h.rotation.y = Math.PI / 2; h.position.copy(W(346.8, 56, BZ + 14.5)); g0.add(h); }
1925
+ const beads = Array.from({ length: 8 }, () => { const p = glowPoints([W(0, 0, 0)], 0.16); g0.add(p); return { p, route: null, t0: 0 }; });
1926
+ const haze = hazeAndShadow(g0, 190, 145, 4.6, 3.6);
1927
+ creases(first);
1928
+
1929
+ // ── drawing ──
1930
+ function drawBoard(o) {
1931
+ const { x, P } = board, C = pal3(), A_ = '#' + accent.getHexString(), O = (u, v) => [(u - 4) * P, (v - 4) * P]; // board-local (from x=20,y=20) → canvas
1932
+ x.clearRect(0, 0, board.c.width, board.c.height);
1933
+ x.strokeStyle = C.detail; x.lineWidth = 0.25 * P;
1934
+ const rr = (u, v, w, h, r) => { x.beginPath(); x.roundRect(...O(u, v), w * P, h * P, r * P); x.stroke(); };
1935
+ rr(4, 4, 332, 242, 4);
1936
+ [[8], [24], [226], [242]].forEach(([v]) => { x.beginPath(); x.moveTo(...O(12, v)); x.lineTo(...O(328, v)); x.stroke(); });
1937
+ x.fillStyle = C.detail;
1938
+ const holes = (u0, v0, w, h) => { for (let u = u0 + 2.2; u < u0 + w; u += 6) for (let v = v0 + 2.2; v < v0 + h; v += 6) x.fillRect(...O(u, v), 1.6 * P, 1.6 * P); };
1939
+ holes(12, 10, 316, 12); holes(12, 228, 316, 12); holes(12, 30, 316, 190);
1940
+ x.fillStyle = C.ink; x.font = `${7 * P}px ${MONO3}`;
1941
+ x.fillText('+', ...O(6, 18.5)); x.fillText('+', ...O(331, 18.5)); x.fillText('−', ...O(6, 237)); x.fillText('−', ...O(331, 237));
1942
+ x.font = `${5 * P}px ${MONO3}`; x.letterSpacing = `${0.8 * P}px`; x.fillText('IN', ...O(8, 34)); x.fillText('OUT', ...O(212, 34)); x.letterSpacing = '0px';
1943
+ x.font = `${9 * P}px ${MONO3}`; SW.forEach(([k, L, s]) => x.fillText(L, ...O(28 - 20 + 0, s + 3 - 20)));
1944
+ x.font = `${5 * P}px ${MONO3}`;
1945
+ GATES.forEach((g, i) => { x.fillStyle = o[g.n] ? (theme === 'light' ? A_ : C.hi) : C.ink; x.fillText(`${g.lbl.toLowerCase()} ${g.ins.join('')}`, ...O(201 - 20, CY[i] + 14.6 - 20)); });
1946
+ x.fillStyle = C.ink; x.font = `${4.4 * P}px ${MONO3}`; x.fillText('and·or·xor = f(a,b) · nand = f(b,c)', ...O(216 - 20, 256 - 20));
1947
+ board.tex.needsUpdate = true;
1948
+ }
1949
+ function drawChips() {
1950
+ const C = pal3();
1951
+ chipTops.forEach(({ g, cv }, i) => { const { x, P } = cv; x.clearRect(0, 0, cv.c.width, cv.c.height);
1952
+ x.strokeStyle = C.line; x.lineWidth = 0.25 * P; x.beginPath(); x.arc(0, 12 * P, 3 * P, -Math.PI / 2, Math.PI / 2); x.stroke();
1953
+ x.beginPath(); x.arc(5 * P, 19 * P, 1.2 * P, 0, Math.PI * 2); x.stroke();
1954
+ x.fillStyle = C.ink; x.textAlign = 'center'; x.font = `${7 * P}px ${MONO3}`; x.letterSpacing = `${0.6 * P}px`; x.fillText(g.lbl, 23 * P, 14.5 * P); x.letterSpacing = '0px';
1955
+ x.font = `${3 * P}px ${MONO3}`; x.fillText('74·' + ['08', '32', '86', '00'][i], 23 * P, 20.5 * P); cv.tex.needsUpdate = true; });
1956
+ }
1957
+ function drawReadout(o, any) {
1958
+ const { x, P } = rd, C = pal3(), A_ = '#' + accent.getHexString();
1959
+ x.clearRect(0, 0, rd.c.width, rd.c.height);
1960
+ x.fillStyle = any ? C.glassOn : C.glassOff; x.beginPath(); x.roundRect(0, 0, 46 * P, 10 * P, 2 * P); x.fill();
1961
+ x.lineWidth = 0.25 * P; x.strokeStyle = any ? A_ : C.detail; x.stroke();
1962
+ x.textAlign = 'center';
1963
+ GATES.forEach((g, i) => { const cx = (6 + i * 11.3) * P;
1964
+ x.fillStyle = o[g.n] ? (theme === 'light' ? A_ : C.hi) : C.ink; x.font = `${8.5 * P}px ${MONO3}`; x.fillText(String(o[g.n]), cx, 8.1 * P);
1965
+ x.fillStyle = C.ink; x.font = `${2.9 * P}px ${MONO3}`; x.fillText(g.n, cx, 16 * P); });
1966
+ rd.tex.needsUpdate = true;
1967
+ }
1968
+
1969
+ // ── state and behaviour (as in the figure) ──
1970
+ const st = { a: 0, b: 0, c: 0 };
1971
+ const outs = () => Object.fromEntries(GATES.map(g => [g.n, g.f(st[g.ins[0]], st[g.ins[1]])]));
1972
+ function readout() { if (active !== 'lg') return; const o = outs(); document.getElementById('read').textContent = `a${st.a} b${st.b} c${st.c} · ` + GATES.map(g => `${g.n} ${o[g.n]}`).join(' · '); }
1973
+ function render() {
1974
+ const o = outs(), any = Object.values(o).some(v => v);
1975
+ ['a', 'b', 'c'].forEach(k => { levers[k].mat.userData.base = st[k] ? 0.55 : 0; if (!twFlashes.some(f => f.mats.includes(levers[k].mat))) levers[k].mat.emissiveIntensity = levers[k].mat.userData.base; });
1976
+ routes.forEach(r => r.line.material = (r.src in st ? st[r.src] : o[r.src]) ? accentLineMat : majorMat);
1977
+ GATES.forEach((g, i) => { leds[i].mat.emissiveIntensity = o[g.n] ? 1.6 : 0; leds[i].glow.material.uniforms.intensity.value = o[g.n] ? 0.85 : 0; });
1978
+ rdHalo.uniforms.opacity.value = any ? 0.18 : 0;
1979
+ drawBoard(o); drawReadout(o, any); readout();
1980
+ }
1981
+ function pulse(src) { routes.filter(r => r.src === src).forEach(r => { const b = beads.find(b => !b.route) || beads[0]; b.route = r; b.t0 = performance.now(); }); }
1982
+ function flip(k) {
1983
+ if (!(k in st)) return false;
1984
+ const before = outs(); st[k] ^= 1; flash(levers[k].g, 200); SFX.switch(); pulse(k); const after = outs(); render();
1985
+ setTimeout(() => GATES.forEach((g, i) => { if (before[g.n] !== after[g.n]) { pulse(g.n); setTimeout(() => SFX.bit(after[g.n]), i * 40); } }), 330);
1986
+ return true;
1987
+ }
1988
+ function key(e) { const k = e.key.toLowerCase(); if (!(k in st)) return false; if (!e.repeat) flip(k); return true; }
1989
+ function click(hit) { let o = hit?.object; while (o && !o.userData.sw) o = o.parent; if (o) flip(o.userData.sw); }
1990
+ function tick(now, dt) {
1991
+ ['a', 'b', 'c'].forEach(k => { const L = levers[k], tx = st[k] ? 12 * TS : 0; L.x += (tx - L.x) * Math.min(1, dt * 14); L.g.position.x = L.x; });
1992
+ beads.forEach(b => { if (!b.route) { b.p.material.uniforms.intensity.value = 0; return; }
1993
+ const r = b.route, t = Math.max(0, (now - b.t0) / 330); if (t >= 1) { b.route = null; return; }
1994
+ const d = t * r.cum[r.cum.length - 1]; let i = 1; while (i < r.cum.length - 1 && r.cum[i] < d) i++;
1995
+ const f = (d - r.cum[i - 1]) / (r.cum[i] - r.cum[i - 1] || 1), p = r.pts[i - 1].map((v, j) => v + (r.pts[i][j] - v) * f);
1996
+ b.p.geometry.attributes.position.setXYZ(0, ...W(p[0], p[1], WZ + 0.8).toArray()); b.p.geometry.attributes.position.needsUpdate = true; b.p.material.uniforms.intensity.value = 0.9; });
1997
+ const any = Object.values(outs()).some(v => v);
1998
+ haze.uniforms.opacity.value += ((any ? 0.12 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
1999
+ tickFlashes(now);
2000
+ }
2001
+ function demo() { setTimeout(() => flip('a'), 60); setTimeout(() => flip('c'), 520); }
2002
+ themeHooks.push(t => { const light = t === 'light'; chipMat.color.set(light ? 0xdcdddd : 0x0f1011); leds.forEach(l => l.mat.color.set(light ? 0xc3c6c8 : 0x2b2e30));
2003
+ rdHalo.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; rdHalo.needsUpdate = true; rdHalo.uniforms.color.value.copy(accent); drawChips(); render(); });
2004
+ render();
2005
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
2006
+ panel: [['Demo', '▶', demo], ['Switch A', 'A', () => flip('a')], ['Switch B', 'B', () => flip('b')], ['Switch C', 'C', () => flip('c')]],
2007
+ view: { target: W(190, 145, 20), dist: 8.6 },
2008
+ info: '<h2>Logic board</h2><p>Three toggle switches A, B and C feed four real logic chips: AND(A,B), OR(A,B), XOR(A,B) and NAND(B,C). Each output drives an LED and the 4-bit readout. Press A, B or C, or click a switch, to flip it.</p>',
2009
+ hint: 'press a · b · c or click the switches' };
2010
+ })();
2011
+
2012
+ // ══ Server rack (Fig 4 of the iso figures, rebuilt in 3D): power servers on, watch them talk to the switch ══
2013
+ // small shared bits for the newer figures: LEDs that blink by material swap, front-face (u, v) frames, slits
2014
+ const ledOffMat = new THREE.MeshBasicMaterial({ color: 0x2b2e30, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
2015
+ const ledOnMat = new THREE.MeshBasicMaterial({ color: accent.clone(), toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
2016
+ themeHooks.push(t => { ledOffMat.color.set(t === 'light' ? 0xc3c6c8 : 0x2b2e30); ledOnMat.color.copy(accent); });
2017
+ const frontUV = (x0, y, ztop) => (u, v, d = 0.08) => [x0 + u, y + d, ztop - v]; // front face (y = const), v runs down from ztop
2018
+ const sideUV = (x, yRight, ztop) => (u, v, d = 0.08) => [x + d, yRight - u, ztop - v]; // side face (x = const), u runs toward −y
2019
+ const topUV = (x0, y0, z) => (u, v, d = 0.08) => [x0 + u, y0 + v, z + d];
2020
+ const slitsUV = (f, u0, u1, step, v0, v1, parent, vertical = true, skip = () => false) => { const s = [];
2021
+ for (let k = u0; k <= u1 + 1e-6; k += step) { if (skip(k)) continue; s.push(vertical ? [f(k, v0), f(k, v1)] : [f(v0, k), f(v1, k)]); } if (s.length) seg(s, parent); };
2022
+ const dotFront = (x, y, z, r, parent, mat = ledOffMat) => { const m = new THREE.Mesh(new THREE.CircleGeometry(r * TS, 16), mat); m.position.copy(W(x, y + 0.15, z)); parent.add(m); return m; };
2023
+ const loopPts = (sh, map, n = 8) => sh.getPoints(n).map(p => W(...map(p.x, p.y)));
2024
+ const bezier = (P0, P1, P2, P3, n = 40) => Array.from({ length: n + 1 }, (_, i) => { const t = i / n; return P0.map((_, j) => (1 - t) ** 3 * P0[j] + 3 * (1 - t) ** 2 * t * P1[j] + 3 * (1 - t) * t * t * P2[j] + t ** 3 * P3[j]); });
2025
+ const cumLen = pts => pts.reduce((a, p, i) => (a.push(i ? a[i - 1] + Math.hypot(...p.map((v, j) => v - pts[i - 1][j])) : 0), a), []);
2026
+ const alongPts = (pts, cum, t) => { const d = t * cum[cum.length - 1]; let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
2027
+ const f = (d - cum[i - 1]) / (cum[i] - cum[i - 1] || 1); return pts[i - 1].map((v, j) => v + (pts[i][j] - v) * f); };
2028
+ const moveGlow = (gp, p, k) => { gp.geometry.attributes.position.setXYZ(0, ...W(...p).toArray()); gp.geometry.attributes.position.needsUpdate = true; gp.material.uniforms.intensity.value = k; };
2029
+ const screenBg = (cv, on, r = 2.5) => { const { x, P } = cv, C = pal3(), A_ = '#' + accent.getHexString();
2030
+ x.clearRect(0, 0, cv.c.width, cv.c.height); x.fillStyle = on ? C.glassOn : C.glassOff; x.beginPath(); x.roundRect(0, 0, cv.c.width, cv.c.height, r * P); x.fill();
2031
+ x.lineWidth = 0.25 * P; x.strokeStyle = on ? A_ : C.detail; x.stroke(); return { C, A_ }; };
2032
+
2033
+ const RK = (() => {
2034
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
2035
+ const first = twMeshes.length;
2036
+ const PLZ = 7, RX = 24, RY = 22, RW = 240, RD = 92, RZ = 20, RF = RY + RD;
2037
+ const UX = RX + 14, UY = RY + 8, UW = RW - 28, UD = RD - 12, UF = UY + UD, UH = 13, RH = 20;
2038
+ const SPEC = [{ U: 4, kind: 'store', W: 520 }, { U: 2, kind: 'bays', W: 340 }, { U: 1, kind: 'flat', W: 190 }, { U: 2, kind: 'vent', W: 300 },
2039
+ { U: 1, kind: 'flat', W: 180 }, { U: 2, kind: 'bays', W: 320 }, { U: 1, kind: 'flat', W: 200 }];
2040
+ let z = RZ + 2;
2041
+ const units = SPEC.map((s, i) => { const u = { ...s, n: SPEC.length - i, z, h: s.U * UH - 1.5 }; z += s.U * UH; return u; });
2042
+ const SZ = z, SH = UH - 1.5, ZT = SZ + UH + 3, TOPZ = ZT + RH;
2043
+ const portX = n => UX + 128 + n * 9, swPortZ = SZ + SH - 7.5;
2044
+ plateWith(g0, 288, 150, [[18, 18], [270, 18], [18, 132], [270, 132]], [30, 124, 56]);
2045
+ // plinth, floor, back and left panels
2046
+ rbox(RX + 6, RY + 6, PLZ, RW - 12, RD - 10, 10, 2, bodyMat, g0);
2047
+ slitsUV(frontUV(RX + 6, RF - 4, 17), 14, RW - 26, 4, 3, 7, g0);
2048
+ rbox(RX, RY, 17, RW, RD, 3, 1, bodyMat, g0);
2049
+ rbox(RX, RY, RZ, RW, 6, ZT - RZ, 1, bodyMat, g0);
2050
+ rbox(RX, RY, RZ, 6, RD, ZT - RZ, 1, bodyMat, g0);
2051
+ // a port: recess with an outline that lights up, and a receive flash
2052
+ const port = (f, u, v, w, h, parent) => {
2053
+ const sh = new THREE.Shape(); roundRect(sh, 0, 0, w, h, 0.6);
2054
+ const m = new THREE.Mesh(new THREE.ShapeGeometry(sh, 4).scale(TS, TS, TS), recessMat); const o = f(u, v + h, 0.06); m.position.copy(W(...o)); parent.add(m);
2055
+ const pts = loopPts(sh, (a, b) => f(u + a, v + h - b, 0.1), 4), off = loopL(pts, lineMat), on = loopL(pts, accentLineMat); on.visible = false; parent.add(off, on);
2056
+ return { m, on, off, set(lit, rx) { on.visible = lit; off.visible = !lit; m.material = rx ? ledOnMat : recessMat; } };
2057
+ };
2058
+ // servers
2059
+ const U = new Map();
2060
+ units.forEach(u => {
2061
+ const grp = new THREE.Group(); grp.userData = { unit: u.n }; g0.add(grp);
2062
+ const top = u.z + u.h, f = frontUV(UX, UF, top), pu = portX(u.n) - UX;
2063
+ rbox(UX, UY, u.z, UW, UD, u.h, 1.5, bodyMat, grp);
2064
+ const bays = u.kind === 'store' || u.kind === 'bays', leds = [];
2065
+ slitsUV(f, bays || u.kind === 'vent' ? 126 : 60, 200, 2.6, 2, u.h - 2, grp, true, k => Math.abs(k - pu) < 4.5);
2066
+ if (bays) {
2067
+ const rows = u.kind === 'store' ? 4 : 2, bh = (u.h - 3) / rows - 1;
2068
+ for (let r = 0; r < rows; r++) for (let c = 0; c < 6; c++) {
2069
+ const x = 60 + c * 10.4, y = 1.5 + r * (bh + 1);
2070
+ frontInset(grp, UX + x, UF, top - y, 9.4, bh, 1);
2071
+ seg([[f(x + 1.8, y + bh / 2, 0.12), f(x + 5.6, y + bh / 2, 0.12)]], grp);
2072
+ leds.push(dotFront(UX + x + 7.6, UF, top - y - bh / 2, 0.9, grp));
2073
+ }
2074
+ } else if (u.kind === 'vent') { frontInset(grp, UX + 60, UF, top - 2, 62, u.h - 4, 2); slitsUV(f, 63, 119, 2.2, 4, u.h - 6, grp); }
2075
+ label(String(u.n).padStart(2, '0'), 10, 7, 6.5, onFront(UX + 21 + 5, UF, top - 7), grp, { align: 'left' });
2076
+ for (let k = 0; k < 4; k++) leds.push(dotFront(UX + 38 + k * 5, UF, top - 6.5, 1.6, grp));
2077
+ const p = port(f, pu - 2.6, 3.5, 5.2, 4.4, grp);
2078
+ // power button: its own press group, protrudes from the front and pushes in
2079
+ const bx = UX + 8, bz = top - 10.5, btn = new THREE.Group(); btn.userData = { pressAxis: 'z', pressDepth: 2 }; grp.add(btn);
2080
+ const mat = capMat(); twCaps.push(mat); rbox(bx, UF, bz, 9, 3, 9, 1.6, mat, btn);
2081
+ const gf = frontUV(bx, UF + 3, bz + 9), arc = [];
2082
+ for (let i = 0; i <= 24; i++) { const a = (-50 + i * (280 / 24)) * Math.PI / 180; arc.push(gf(4.5 + 2.3 * Math.sin(a), 4.9 - 2.3 * Math.cos(a), 0.1)); }
2083
+ polyLine(arc, majorMat, btn); polyLine([gf(4.5, 1.8, 0.1), gf(4.5, 4.4, 0.1)], majorMat, btn);
2084
+ U.set(u.n, { ...u, on: false, last: 0, load: 40 + Math.random() * 40, grp, btn, mat, leds, p });
2085
+ });
2086
+ // switch
2087
+ rbox(UX, UY, SZ, UW, UD, SH, 1.5, bodyMat, g0);
2088
+ { const f = frontUV(UX, UF, SZ + SH);
2089
+ label('SW-08', 22, 7, 6, onFront(UX + 10 + 11, UF, SZ + SH - 6.5), g0, { align: 'left' });
2090
+ slitsUV(f, 40, 104, 2.6, 2, SH - 2, g0); port(f, 112, SH - 9.5, 8, 4.4, g0);
2091
+ units.forEach(u => { const d = U.get(u.n); d.sp = port(f, portX(u.n) - UX - 2.6, SH - 9.5, 5.2, 4.4, g0); d.sl = dotFront(portX(u.n), UF, SZ + SH - 2, 0.9, g0); }); }
2092
+ const swLed = dotFront(UX + 32, UF, SZ + SH - 6.5, 1.3, g0), swGlow = glowPoints([W(UX + 32, UF + 1, SZ + SH - 6.5)], 0.14); g0.add(swGlow);
2093
+ // front rails with screw holes
2094
+ [UX - 4, UX + UW - 6].forEach(x => {
2095
+ rbox(x, UF, RZ, 10, 4, ZT - RZ - 2, 1, bodyMat, g0);
2096
+ for (let v = 4; v < ZT - RZ - 4; v += 4.33) dotFront(x + 5, UF + 4, ZT - 2 - v, 0.9, g0, recessMat);
2097
+ });
2098
+ // cables: unit port → hang forward → switch port; a light bead runs along when a server sends
2099
+ U.forEach(d => {
2100
+ const px = portX(d.n), pz = d.z + d.h - 5.7, b = 7 + 0.09 * (swPortZ - pz);
2101
+ d.path = bezier([px, UF, pz], [px, UF + b, pz - 1], [px, UF + b, swPortZ - 4], [px, UF, swPortZ]); d.cum = cumLen(d.path);
2102
+ d.wire = polyLine(d.path, majorMat, g0);
2103
+ d.bead = glowPoints([W(px, UF, pz)], 0.16); g0.add(d.bead); d.beadT = -1;
2104
+ });
2105
+ // right panel with vents, roof with two fans and the status screen
2106
+ rbox(RX + RW - 6, RY, RZ, 6, RD, ZT - RZ, 1, bodyMat, g0);
2107
+ { const f = sideUV(RX + RW, RF, ZT); [[12, 38, 14, 80], [54, 80, 14, 80], [12, 38, 104, 170], [54, 80, 104, 170]].forEach(([a, b, c, e]) => slitsUV(f, a, b, 3.2, c, e, g0));
2108
+ sideInset(g0, RX + RW, RF - 38, ZT - 89, 16, 6, 3); }
2109
+ rbox(RX, RY, ZT, RW, RD, RH, 4, bodyMat, g0);
2110
+ { const sh = new THREE.Shape(); roundRect(sh, RX + 6, -(RY + 6 + RD - 12), RW - 12, RD - 12, 3); g0.add(loopL(sh.getPoints(8).map(p => W(p.x, -p.y, TOPZ + 0.06)), lineMat)); }
2111
+ const fans = [66, 174].map(cx => {
2112
+ const C = [RX + cx, RY + 46], ring = r => Array.from({ length: 64 }, (_, i) => { const a = i / 64 * Math.PI * 2; return W(C[0] + r * Math.cos(a), C[1] + r * Math.sin(a), TOPZ + 0.08); });
2113
+ const disc = new THREE.Mesh(new THREE.CircleGeometry(24 * TS, 48), recessMat); disc.rotation.x = -Math.PI / 2; disc.position.copy(W(C[0], C[1], TOPZ + 0.05)); g0.add(disc);
2114
+ g0.add(loopL(ring(24), lineMat), loopL(ring(20), lineMat));
2115
+ const rot = new THREE.Group(); rot.position.copy(W(C[0], C[1], TOPZ + 0.1)); g0.add(rot);
2116
+ for (let b = 0; b < 7; b++) { const pts = []; for (let i = 0; i <= 10; i++) { const r = 5 + i * 1.5, a = b / 7 * Math.PI * 2 + i * 0.07; pts.push([r * Math.cos(a), r * Math.sin(a), 0]); } polyLine(pts, lineMat, rot); }
2117
+ const hub = new THREE.Mesh(new THREE.CircleGeometry(3.5 * TS, 24), bodyMat); hub.rotation.x = -Math.PI / 2; hub.position.y = 0.02 * TS; rot.add(hub);
2118
+ return rot;
2119
+ });
2120
+ slitsUV(topUV(RX, RY, TOPZ), 104, 136, 3.2, 24, 68, g0);
2121
+ label('R-04', 18, 8, 6.5, onFront(RX + 12 + 9, RF, TOPZ - 10), g0, { align: 'left' });
2122
+ frontInset(g0, RX + 44, RF, TOPZ - 1.5, 156, 17, 3.5);
2123
+ const scr = faceCanvas('front', RX + 46, RF, TOPZ - 3, 152, 14, 12, g0);
2124
+ slitsUV(frontUV(RX, RF, TOPZ), 208, 228, 2.6, 4, 16, g0);
2125
+ const haze = hazeAndShadow(g0, 144, 75, 3.2, 1.8);
2126
+ creases(first);
2127
+
2128
+ // ── state and behaviour (as in the figure) ──
2129
+ const st = { wob: 0, tw: 0 };
2130
+ function metrics() {
2131
+ const on = [...U.values()].filter(u => u.on);
2132
+ const w = on.length ? (60 + on.reduce((s, u) => s + u.W, 0)) * (1 + st.wob * 0.025) : 0;
2133
+ return { n: on.length, kw: w / 1000, t: 22 + on.length * 2.3 + (on.length ? st.tw * 0.6 : 0), load: on.length ? on.reduce((s, u) => s + u.load, 0) / on.length : 0 };
2134
+ }
2135
+ function drawScreen(m) {
2136
+ const { x, P } = scr, on = m.n > 0, { C, A_ } = screenBg(scr, on, 3);
2137
+ const col = on ? A_ : C.ink; if (on && theme !== 'light') { x.shadowColor = A_; x.shadowBlur = 0.6 * P; }
2138
+ x.fillStyle = col; x.textBaseline = 'alphabetic'; x.font = `${10 * P}px ${MONO3}`; x.fillText(`UP ${m.n}/7`, 7 * P, 10.6 * P);
2139
+ x.font = `${7 * P}px ${MONO3}`; x.fillText(`LOAD ${Math.round(m.load)}%`, 66 * P, 9.9 * P); x.shadowBlur = 0;
2140
+ x.lineWidth = 0.3 * P; x.strokeStyle = on ? A_ : C.detail; x.beginPath(); x.roundRect(112 * P, 4 * P, 34 * P, 6 * P, 1.5 * P); x.stroke();
2141
+ if (on) { x.fillStyle = A_; x.beginPath(); x.roundRect(112 * P, 4 * P, 34 * m.load / 100 * P, 6 * P, 1.5 * P); x.fill(); }
2142
+ scr.tex.needsUpdate = true;
2143
+ }
2144
+ function readout() { if (active !== 'rk') return; const m = metrics(); document.getElementById('read').textContent = `${m.n}/7 up · ${m.kw.toFixed(1)} kW · ${Math.round(m.t)}°C`; }
2145
+ function render() {
2146
+ const m = metrics(), any = m.n > 0;
2147
+ U.forEach(u => {
2148
+ u.mat.userData.base = u.on ? 0.55 : 0; if (!twFlashes.some(f => f.mats.includes(u.mat))) u.mat.emissiveIntensity = u.mat.userData.base;
2149
+ u.wire.material = u.on ? accentLineMat : majorMat; u.p.set(u.on, false); u.sp.set(u.on, u.sp.rx); u.sl.material = u.on ? ledOnMat : ledOffMat;
2150
+ if (!u.on) u.leds.forEach(l => l.material = ledOffMat);
2151
+ });
2152
+ swLed.material = any ? ledOnMat : ledOffMat; swGlow.material.uniforms.intensity.value = any ? 0.7 : 0;
2153
+ drawScreen(m); readout();
2154
+ }
2155
+ function send(u) {
2156
+ u.last = performance.now(); u.beadT = u.last;
2157
+ setTimeout(() => { if (!u.on) return; u.sp.rx = true; render(); setTimeout(() => { u.sp.rx = false; render(); }, 140); }, 470);
2158
+ }
2159
+ function toggle(n) {
2160
+ const u = U.get(n); if (!u) return false;
2161
+ flash(u.btn, 110); SFX.switch(); u.on = !u.on;
2162
+ if (u.on) { tone(140, 0.7, 0.05, { type: 'sawtooth', to: 420 }); u.load = 35 + Math.random() * 45; u.leds.forEach(l => l.material = ledOnMat); setTimeout(() => u.on && send(u), 120); }
2163
+ render(); return true;
2164
+ }
2165
+ setInterval(() => U.forEach(u => { if (u.on) u.leds.forEach(l => l.material = Math.random() < .55 ? ledOnMat : ledOffMat); }), 110);
2166
+ setInterval(() => { const now = performance.now(); U.forEach(u => { if (u.on && now - u.last > 650 && Math.random() < .22) send(u); }); }, 240);
2167
+ setInterval(() => {
2168
+ st.wob = Math.max(-1, Math.min(1, st.wob + (Math.random() - .5) * .8)); st.tw = Math.max(-1, Math.min(1, st.tw + (Math.random() - .5) * .5));
2169
+ U.forEach(u => { if (u.on) u.load = Math.max(8, Math.min(97, u.load + (Math.random() - .5) * 12)); });
2170
+ if ([...U.values()].some(u => u.on)) render();
2171
+ }, 600);
2172
+ const allToggle = () => { const any = [...U.values()].some(u => u.on); U.forEach(u => { if (u.on === any) toggle(u.n); }); return true; };
2173
+ function key(e) { if (/^[1-7]$/.test(e.key)) { if (!e.repeat) toggle(+e.key); return true; } if ((e.key === 'a' || e.key === 'A') && !e.repeat) return allToggle(); return false; }
2174
+ function click(hit) { let o = hit?.object; while (o && !o.userData.unit) o = o.parent; if (o) toggle(o.userData.unit); }
2175
+ function tick(now, dt) {
2176
+ const n = metrics().n; fans.forEach((f, i) => f.rotation.y -= dt * n * (1.6 + i * 0.3));
2177
+ U.forEach(u => { if (u.beadT < 0) { u.bead.material.uniforms.intensity.value = 0; return; }
2178
+ const t = (now - u.beadT) / 470; if (t >= 1 || t < 0) { u.beadT = t < 0 ? u.beadT : -1; u.bead.material.uniforms.intensity.value = 0; return; }
2179
+ moveGlow(u.bead, alongPts(u.path, u.cum, t), 0.9); });
2180
+ haze.uniforms.opacity.value += ((n ? 0.12 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 4);
2181
+ tickFlashes(now);
2182
+ }
2183
+ function demo() { [7, 4, 2, 1].forEach((n, i) => setTimeout(() => toggle(n), i * 220)); }
2184
+ themeHooks.push(() => render());
2185
+ render();
2186
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
2187
+ panel: [['Demo', '▶', demo], ['All on / off', 'A', allToggle]],
2188
+ view: { target: W(144, 75, 118), dist: 8.4 },
2189
+ info: '<h2>Server rack</h2><p>Seven servers and a network switch. Click a server or its power button, or press 1–7, to switch it on or off: running servers blink, send traffic down their cables to the switch, spin the roof fans and raise the power draw and temperature.</p>',
2190
+ hint: 'click a server · keys 1–7' };
2191
+ })();
2192
+
2193
+ // ══ System stack (Fig 5 of the iso figures, rebuilt in 3D): exploded architecture; plug a cartridge in, watch the request route ══
2194
+ const SK = (() => {
2195
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
2196
+ const first = twMeshes.length;
2197
+ const SX = 200, SY = 0, SW = 180, SD = 160, SF = SY + SD;
2198
+ const WZ = [0, 36, 72], WH = 6, IZ = 112, IH = 8, TZ = 164, TH = 6;
2199
+ const CX = 0, CL = 170, CW = 20, CZ = 113, CH = 6, WIREZ = 116, FLOOR = -30;
2200
+ const LANGS = ['java', 'go', 'c++', 'rust', 'python'], NAMES = ['Java', 'Go', 'C++', 'Rust', 'Python'];
2201
+ const cy = i => 220 + 28 * i, xw = i => 220 + 30 * i;
2202
+ // dashed hairlines: the floor shadow outline and the exploded-view leaders at the near corner
2203
+ const dashed = (pts, parent, on = 5, off = 5) => { const s = []; let carry = 0, draw = true;
2204
+ for (let i = 0; i < pts.length - 1; i++) { const a = pts[i], b = pts[i + 1], L = Math.hypot(...a.map((v, j) => b[j] - v)); let d = 0;
2205
+ while (d < L) { const step = Math.min((draw ? on : off) - carry, L - d), p = t => a.map((v, j) => v + (b[j] - v) * t);
2206
+ if (draw) s.push([p(d / L), p((d + step) / L)]); d += step; carry += step; if (carry >= (draw ? on : off) - 1e-6) { carry = 0; draw = !draw; } } }
2207
+ seg(s, parent); };
2208
+ const rr = (x, y, w, d, r, z) => { const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, r); const p = sh.getPoints(8).map(q => [q.x, -q.y, z]); p.push(p[0]); return p; };
2209
+ dashed(rr(SX - 10, SY - 10, SW + 20, SD + 20, 12, FLOOR), g0); dashed(rr(SX, SY, SW, SD, 6, FLOOR), g0, 3, 6);
2210
+ const lead = (z0, z1) => dashed([[SX + SW, SF, z0], [SX + SW, SF, z1]], g0, 3, 3);
2211
+ lead(FLOOR, WZ[0]);
2212
+ const shadow = softMat(0x000000, false);
2213
+ { const m = new THREE.Mesh(new THREE.PlaneGeometry(SW * 1.25 * TS, SD * 1.25 * TS), shadow); m.rotation.x = -Math.PI / 2; m.position.copy(W(SX + SW / 2, SY + SD / 2 + 6, FLOOR - 0.5)); g0.add(m); }
2214
+ // a slab: body that fills with accent when working, accent rim when hot, printed top (detail frame, name, slits) on a canvas
2215
+ const slab = (x, z, w, h, name, size, slits, halo) => {
2216
+ const mat = capMat(); twCaps.push(mat); rbox(x, SY, z, w, SD, h, 6, mat, g0);
2217
+ const sh = new THREE.Shape(); roundRect(sh, x, -(SY + SD), w, SD, 6);
2218
+ const rim = loopL(sh.getPoints(10).map(p => W(p.x, -p.y, z + h + 0.06)), accentLineMat); rim.visible = false; g0.add(rim);
2219
+ const cv = faceCanvas('top', x, SY, z + h, w, SD, 4, g0, true);
2220
+ let hm = null; if (halo) { hm = softMat(0xffffff, true); const m = new THREE.Mesh(new THREE.PlaneGeometry((w + 70) * TS, (SD + 70) * TS), hm); m.rotation.x = -Math.PI / 2; m.position.copy(W(x + w / 2, SY + SD / 2, z + h / 2)); g0.add(m); }
2221
+ const s = { mat, rim, hm, state: '', draw(state) {
2222
+ s.state = state; const { x: c, P } = cv, C = pal3(), A_ = '#' + accent.getHexString(), on = state === 'on', hot = state === 'hot';
2223
+ c.clearRect(0, 0, cv.c.width, cv.c.height); c.lineWidth = 0.25 * P;
2224
+ const ln = on ? (theme === 'light' ? '#ffe2c2' : '#ffd9a8') : C.detail, tx = on ? C.bg : hot ? A_ : C.ink;
2225
+ c.strokeStyle = ln; c.beginPath(); c.roundRect(5 * P, 5 * P, (w - 10) * P, (SD - 10) * P, 3 * P); c.stroke();
2226
+ c.fillStyle = tx; c.font = `${size * P}px ${MONO3}`; c.letterSpacing = `${size * 0.14 * P}px`; c.textBaseline = 'alphabetic';
2227
+ if (slits === 'center') { c.textAlign = 'center'; c.fillText(name, w / 2 * P, (SD / 2 + 3) * P); } else { c.textAlign = 'left'; c.fillText(name, 12 * P, (SD - 12) * P); }
2228
+ c.beginPath(); (slits === 'center' ? [[14, 70, SD - 26, SD - 14]] : slits ? [[w - 60, w - 14, SD - 22, SD - 11]] : []).forEach(([u0, u1, v0, v1]) => { for (let u = u0; u <= u1; u += 4) { c.moveTo(u * P, v0 * P); c.lineTo(u * P, v1 * P); } });
2229
+ c.stroke(); cv.tex.needsUpdate = true;
2230
+ mat.emissiveIntensity = on ? 1.15 : 0; rim.visible = on || hot; if (hm) hm.uniforms.opacity.value = on ? 0.32 : 0;
2231
+ } };
2232
+ return s;
2233
+ };
2234
+ const workers = WZ.map((z, k) => {
2235
+ const s = slab(SX, z, SW, WH, `WORKER ${k + 1}`, 8.5, true, true);
2236
+ slitsUV(frontUV(SX, SF, z + WH), 12, 60, 4, 1.6, WH - 1.6, g0);
2237
+ lead(z + WH, k < 2 ? WZ[k + 1] : IZ); return s;
2238
+ });
2239
+ const interp = slab(SX, IZ, SW, IH, 'INPUT INTERPRETER', 9, false, false);
2240
+ LANGS.forEach((_, i) => frontInset(g0, xw(i) - 4, SF, IZ + IH - 2, 8, IH - 4, 1));
2241
+ lead(IZ + IH, TZ);
2242
+ const sup = slab(SX, TZ, 84, TH, 'SUPERVISOR', 8.5, 'center', false), rpc = slab(SX + 96, TZ, 84, TH, 'SERVICE RPC', 8.5, 'center', false);
2243
+ // cartridges: press group, printed top with name, request counter, grille and a small status light
2244
+ const carts = LANGS.map((_, i) => {
2245
+ const y = cy(i), g = new THREE.Group(); g.userData = { cart: i }; g0.add(g);
2246
+ const mat = capMat(); twCaps.push(mat); rbox(CX, y, CZ, CL, CW, CH, 3, mat, g);
2247
+ const cv = faceCanvas('top', CX, y, CZ + CH, CL, CW, 8, g, true);
2248
+ sideInset(g, CX + CL, y + CW - (CW / 2 - 3), CZ + CH - 1.5, 6, 3, 1);
2249
+ return { g, mat, cv };
2250
+ });
2251
+ const drawCart = (i, n, hot) => { const { cv } = carts[i], { x, P } = cv, C = pal3(), A_ = '#' + accent.getHexString();
2252
+ x.clearRect(0, 0, cv.c.width, cv.c.height); x.lineWidth = 0.25 * P;
2253
+ x.fillStyle = C.recess; x.strokeStyle = C.line; x.beginPath(); x.arc(10 * P, 10 * P, 4 * P, 0, Math.PI * 2); x.fill(); x.stroke();
2254
+ x.fillStyle = C.ink; x.font = `${8 * P}px ${MONO3}`; x.letterSpacing = `${P}px`; x.textBaseline = 'alphabetic'; x.textAlign = 'left'; x.fillText(NAMES[i], 22 * P, 13.4 * P);
2255
+ x.fillStyle = hot ? A_ : C.line; x.font = `${6 * P}px ${MONO3}`; x.letterSpacing = '0px'; x.textAlign = 'right'; x.fillText(String(n), (CL - 66) * P, 13.2 * P);
2256
+ x.strokeStyle = C.detail; x.beginPath();
2257
+ for (let u = CL - 58; u <= CL - 18; u += 2.5) { x.moveTo(u * P, 4 * P); x.lineTo(u * P, (CW - 4) * P); }
2258
+ for (let v = 4; v <= CW - 4; v += 2.5) { x.moveTo((CL - 58) * P, v * P); x.lineTo((CL - 18) * P, v * P); } x.stroke();
2259
+ x.fillStyle = A_; x.globalAlpha = hot ? 1 : 0.55; x.beginPath(); x.roundRect((CL - 12) * P, 6 * P, 5 * P, 8 * P, P); x.fill(); x.globalAlpha = 1;
2260
+ cv.tex.needsUpdate = true; };
2261
+ // wires: cartridge side → +x → quarter turn → −y into the interpreter's front sockets
2262
+ const R = 9, wires = LANGS.map((_, i) => {
2263
+ const y = cy(i) + CW / 2, x = xw(i), pts = [[CX + CL, y, WIREZ], [x - R, y, WIREZ]];
2264
+ for (let t = 1; t <= 8; t++) { const a = t / 8 * Math.PI / 2; pts.push([x - R + R * Math.sin(a), y - R + R * Math.cos(a), WIREZ]); }
2265
+ pts.push([x, SF, WIREZ]);
2266
+ const bead = glowPoints([W(...pts[0])], 0.18); g0.add(bead);
2267
+ return { pts, cum: cumLen(pts), line: polyLine(pts, majorMat, g0), bead, t0: -1 };
2268
+ });
2269
+ creases(first);
2270
+
2271
+ // ── state and behaviour (as in the figure) ──
2272
+ const st = { total: 0, per: [0, 0, 0, 0, 0], next: 0, last: null, wire: [0, 0, 0, 0, 0], interp: 0, worker: [0, 0, 0], resp: 0 };
2273
+ function readout() { if (active !== 'sk') return; document.getElementById('read').textContent = st.last ? `${st.last.lang} → interpreter → worker ${st.last.w + 1} · ${st.total} req` : 'idle · 0 req'; }
2274
+ function render() {
2275
+ wires.forEach((w, i) => w.line.material = st.wire[i] > 0 ? accentLineMat : majorMat);
2276
+ carts.forEach((_, i) => drawCart(i, st.per[i], st.wire[i] > 0));
2277
+ interp.draw(st.interp > 0 ? 'hot' : '');
2278
+ workers.forEach((w, k) => w.draw(st.worker[k] > 0 ? 'on' : ''));
2279
+ [sup, rpc].forEach(s => s.draw(st.resp > 0 ? 'on' : ''));
2280
+ readout();
2281
+ }
2282
+ function send(i) {
2283
+ const w = st.next; st.next = (st.next + 1) % WZ.length;
2284
+ st.total++; st.per[i]++; st.last = { lang: LANGS[i], w };
2285
+ flash(carts[i].g, 260); SFX.button(); tone(660, 0.1, 0.05, { type: 'triangle' });
2286
+ st.wire[i]++; wires[i].t0 = performance.now(); render();
2287
+ setTimeout(() => { st.interp++; render(); }, 560);
2288
+ setTimeout(() => { st.worker[w]++; tone(392 + w * 98, 0.3, 0.05); render(); }, 700);
2289
+ setTimeout(() => { st.wire[i]--; render(); }, 900);
2290
+ setTimeout(() => { st.worker[w]--; st.interp--; st.resp++; SFX.bit(1); render(); setTimeout(() => { st.resp--; render(); }, 320); }, 1900);
2291
+ return true;
2292
+ }
2293
+ function key(e) { const n = +e.key; if (n >= 1 && n <= 5) { if (!e.repeat) send(n - 1); return true; } return false; }
2294
+ function click(hit) { let o = hit?.object; while (o && o.userData.cart === undefined) o = o.parent; if (o) send(o.userData.cart); }
2295
+ function tick(now, dt) {
2296
+ wires.forEach(w => { if (w.t0 < 0) { w.bead.material.uniforms.intensity.value = 0; return; }
2297
+ const t = (now - w.t0) / 800; if (t >= 1) { w.t0 = -1; w.bead.material.uniforms.intensity.value = 0; return; }
2298
+ moveGlow(w.bead, alongPts(w.pts, w.cum, Math.max(0, t)), 0.95); });
2299
+ tickFlashes(now);
2300
+ }
2301
+ function demo() { send(1); setTimeout(() => send(3), 800); }
2302
+ themeHooks.push(t => { shadow.uniforms.opacity.value = t === 'light' ? 0.1 : 0.45; render(); });
2303
+ render();
2304
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
2305
+ panel: [['Demo', '▶', demo], ...NAMES.map((n, i) => [n, String(i + 1), () => send(i)])],
2306
+ view: { target: W(190, 185, 70), dist: 9.6 },
2307
+ info: '<h2>System stack</h2><p>An exploded architecture: five language cartridges wired to an input interpreter above three worker slabs, with Supervisor and Service RPC on top. Click a cartridge or press 1–5 to send a request: it runs down the wire, the interpreter picks it up, a worker lights while it works, and the response flashes the top layer.</p>',
2308
+ hint: 'click a cartridge · keys 1–5' };
2309
+ })();
2310
+
2311
+ // ══ Turntable (new figure): direct-drive deck; the platter spins up, the arm cues itself and a generated record plays ══
2312
+ const TT = (() => {
2313
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
2314
+ const first = twMeshes.length, PLZ = 7;
2315
+ const cyl = (r, h, seg, mat, parent, pos, axis) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(r * TS, r * TS, h * TS, seg), mat);
2316
+ if (axis === 'x') m.rotation.z = Math.PI / 2; m.position.copy(pos); addOutline(m); twMeshes.push(m); parent.add(m); return m; };
2317
+ const circ = (cx, cy, r, z, n = 96) => Array.from({ length: n }, (_, i) => { const a = i / n * Math.PI * 2; return W(cx + r * Math.cos(a), cy + r * Math.sin(a), z); });
2318
+ plateWith(g0, 330, 270, [[18, 18], [312, 18], [18, 252], [312, 252]], [24, 254, 56]);
2319
+ // body on four feet
2320
+ const BX = 22, BY = 22, BW = 286, BD = 220, BZ = PLZ + 4, BH = 30, BT = BZ + BH, BF = BY + BD;
2321
+ [[BX + 24, BY + 24], [BX + BW - 24, BY + 24], [BX + 24, BF - 24], [BX + BW - 24, BF - 24]].forEach(([x, y]) => cyl(10, 4, 32, bodyMat, g0, W(x, y, PLZ + 2)));
2322
+ rbox(BX, BY, BZ, BW, BD, BH, 10, bodyMat, g0);
2323
+ { const sh = new THREE.Shape(); roundRect(sh, BX + 6, -(BY + 6 + BD - 12), BW - 12, BD - 12, 6); g0.add(loopL(sh.getPoints(8).map(p => W(p.x, -p.y, BT + 0.06)), lineMat)); }
2324
+ label('TT-33 DIRECT DRIVE', 70, 8, 6, onFront(BX + 14 + 35, BF, BT - 11), g0, { align: 'left' });
2325
+ slitsUV(frontUV(BX, BF, BT), BW - 64, BW - 16, 2.6, 6, BH - 6, g0);
2326
+ // platter: well in the top deck, cast platter with strobe marks on its rim, the record, label and spindle
2327
+ const C = [124, 124], PR = 92, PZ = BT + 2, PH = 9, RR = 78, RZ = PZ + PH, RT = RZ + 1.6;
2328
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(97 * TS, 96), recessMat); d.rotation.x = -Math.PI / 2; d.position.copy(W(C[0], C[1], BT + 0.05)); g0.add(d); g0.add(loopL(circ(C[0], C[1], 97, BT + 0.07), lineMat)); }
2329
+ const pg = new THREE.Group(); pg.position.copy(W(C[0], C[1], 0)); pg.userData = { platter: true }; g0.add(pg);
2330
+ cyl(PR, PH, 128, bodyMat, pg, W(0, 0, PZ + PH / 2));
2331
+ { const s = []; for (let k = 0; k < 90; k++) { const a = k / 90 * Math.PI * 2, c = Math.cos(a), sn = Math.sin(a), r = PR + 0.1;
2332
+ s.push([[r * c, r * sn, PZ + 2], [r * c, r * sn, PZ + (k % 2 ? 4.5 : 6.5)]]); } seg(s, pg); }
2333
+ { const m = new THREE.Mesh(new THREE.CylinderGeometry(RR * TS, RR * TS, 1.6 * TS, 128), recessMat); m.position.copy(W(0, 0, RZ + 0.8)); addOutline(m); pg.add(m); }
2334
+ const ring = (r, mat) => pg.add(loopL(circ(0, 0, r, RT + 0.06, 128), mat));
2335
+ ring(RR - 1.5, lineMat); for (let r = 72; r >= 32; r -= 3.2) ring(r, lineMat); [60.8, 48, 38.4].forEach(r => ring(r, majorMat));
2336
+ const lab = document.createElement('canvas'); lab.width = lab.height = 512; const labTex = new THREE.CanvasTexture(lab); labTex.colorSpace = THREE.SRGBColorSpace; labTex.anisotropy = 8;
2337
+ { const m = new THREE.Mesh(new THREE.CircleGeometry(24 * TS, 64), new THREE.MeshBasicMaterial({ map: labTex, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 }));
2338
+ m.rotation.x = -Math.PI / 2; m.position.copy(W(0, 0, RT + 0.1)); pg.add(m); }
2339
+ const drawLabel = () => { const x = lab.getContext('2d'), Cc = pal3(), A_ = '#' + accent.getHexString(), k = 512 / 48;
2340
+ x.clearRect(0, 0, 512, 512); x.fillStyle = Cc.glassOff; x.beginPath(); x.arc(256, 256, 256, 0, Math.PI * 2); x.fill();
2341
+ x.strokeStyle = Cc.line; x.lineWidth = 0.4 * k; [22, 17].forEach(r => { x.beginPath(); x.arc(256, 256, r * k, 0, Math.PI * 2); x.stroke(); });
2342
+ x.fillStyle = A_; x.beginPath(); x.arc(256, 256, 3.4 * k, 0, Math.PI * 2); x.fill();
2343
+ x.fillStyle = Cc.inkHi; x.textAlign = 'center'; x.font = `${3.6 * k}px ${MONO3}`; x.fillText('ISO-GLOW RECORDS', 256, 256 - 9 * k);
2344
+ x.fillStyle = Cc.ink; x.font = `${2.6 * k}px ${MONO3}`; x.fillText('SIDE A · 33⅓', 256, 256 + 10 * k); x.fillText('HAIRLINE', 256, 256 + 14 * k);
2345
+ labTex.needsUpdate = true; };
2346
+ cyl(2, 8, 16, bodyMat, pg, W(0, 0, RT + 4));
2347
+ // controls: start/stop, 33 / 45, strobe pilot lamp, pitch slider
2348
+ const btn = (x, y, w, d, name, text) => { const g = new THREE.Group(); g.userData = { ttBtn: name }; g0.add(g); const mat = capMat(); twCaps.push(mat);
2349
+ rbox(x, y, BT, w, d, 4, 3, mat, g); label(text, w, 6, 4.6, onTop(x + w / 2, y + d / 2, BT + 4), g); return { g, mat }; };
2350
+ const bStart = btn(38, 196, 34, 30, 'start', 'START·STOP'), b33 = btn(84, 206, 22, 18, '33', '33'), b45 = btn(110, 206, 22, 18, '45', '45');
2351
+ rbox(34, 168, BT, 12, 12, 7, 3, bodyMat, g0);
2352
+ const pilotMat = new THREE.MeshStandardMaterial({ color: 0x2b2e30, emissive: accent.clone(), emissiveIntensity: 0, roughness: 0.4 });
2353
+ { const m = new THREE.Mesh(new THREE.SphereGeometry(3 * TS, 16, 12), pilotMat); m.position.copy(W(40, 174, BT + 8)); g0.add(m); }
2354
+ const pilotGlow = glowPoints([W(40, 174, BT + 9)], 0.26); g0.add(pilotGlow);
2355
+ const SLX = 284, SLY0 = 136, SLY1 = 222;
2356
+ inset(SLX - 3, SLY0, BT, 6, SLY1 - SLY0, 3, g0);
2357
+ { const s = []; for (let k = 0; k <= 16; k++) { const y = SLY0 + 6 + k * (SLY1 - SLY0 - 12) / 16; s.push([[SLX - 9, y, BT + 0.08], [SLX - (k % 4 ? 6 : 4), y, BT + 0.08]]); } seg(s, g0); }
2358
+ label('PITCH', 20, 6, 4.4, onTop(SLX, SLY0 - 6, BT), g0);
2359
+ const slider = new THREE.Group(); slider.userData = { pitch: true }; g0.add(slider);
2360
+ const sMat = capMat(); twCaps.push(sMat); rbox(SLX - 7, (SLY0 + SLY1) / 2 - 5, BT, 14, 10, 7, 2, sMat, slider);
2361
+ const sliderHit = new THREE.Mesh(new THREE.PlaneGeometry(18 * TS, (SLY1 - SLY0) * TS), new THREE.MeshBasicMaterial({ visible: false }));
2362
+ sliderHit.rotation.x = -Math.PI / 2; sliderHit.position.copy(W(SLX, (SLY0 + SLY1) / 2, BT + 1)); sliderHit.userData = { pitch: true }; g0.add(sliderHit);
2363
+ // tonearm: base, pivot tower, arm (counterweight → tube → headshell → cartridge), arm rest
2364
+ const P = [262, 60], AZ = BT + 21, LS = 152, A_REST = 96 * Math.PI / 180;
2365
+ cyl(18, 4, 48, bodyMat, g0, W(P[0], P[1], BT + 2)); cyl(7, 17, 32, bodyMat, g0, W(P[0], P[1], BT + 12.5));
2366
+ g0.add(loopL(circ(P[0], P[1], 14, BT + 4.08, 48), lineMat));
2367
+ { const r = [P[0] + 128 * Math.cos(A_REST), P[1] + 128 * Math.sin(A_REST)]; rbox(r[0] - 4, r[1] - 4, BT, 8, 8, 17, 3, bodyMat, g0); rbox(r[0] - 6, r[1] - 2, BT + 17, 12, 4, 2, 1, bodyMat, g0); }
2368
+ const arm = new THREE.Group(); arm.position.copy(W(P[0], P[1], AZ)); arm.userData = { arm: true }; g0.add(arm);
2369
+ const lift = new THREE.Group(); arm.add(lift);
2370
+ cyl(5, 8, 24, bodyMat, lift, W(0, 0, 0));
2371
+ cyl(1.8, 150, 16, bodyMat, lift, W(61, 0, 0), 'x');
2372
+ cyl(6.5, 12, 32, bodyMat, lift, W(-26, 0, 0), 'x');
2373
+ rbox(LS - 18, -5, -3.5, 22, 10, 3, 1.5, bodyMat, lift);
2374
+ rbox(LS - 9, -3, -7.6, 10, 6, 4.1, 1, bodyMat, lift);
2375
+ polyLine([[LS + 4, -2, -2], [LS + 9, -2, 1.5]], majorMat, lift);
2376
+ const tipGlow = glowPoints([W(LS - 4, 0, -7.4)], 0.1); lift.add(tipGlow);
2377
+ const haze = hazeAndShadow(g0, 165, 135, 3.6, 2.9);
2378
+ creases(first);
2379
+
2380
+ // ── record music: generated in the page, its pitch and tempo follow the platter speed ──
2381
+ const BEAT = 60 / 84 / 2, CHORDS = [[57, 60, 64, 67], [53, 57, 60, 64], [50, 53, 57, 60], [52, 56, 59, 62]], mtof = m => 440 * 2 ** ((m - 69) / 12);
2382
+ let mus = null;
2383
+ function voice(type, f, t, dur, peak, dest, att = 0.01) { const { ctx } = mus, o = ctx.createOscillator(), g = ctx.createGain(); o.type = type; o.frequency.setValueAtTime(f, t);
2384
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(peak, t + att); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.connect(g); g.connect(dest); o.start(t); o.stop(t + dur + 0.05); return o; }
2385
+ function noiseHit(t, freq, q, peak, dur) { const { ctx } = mus, n = ctx.createBufferSource(), f = ctx.createBiquadFilter(), g = ctx.createGain(); n.buffer = mus.noise; f.type = 'bandpass'; f.frequency.value = freq; f.Q.value = q;
2386
+ g.gain.setValueAtTime(peak, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); n.connect(f); f.connect(g); g.connect(mus.lp); n.start(t); n.stop(t + dur + 0.02); }
2387
+ function note(step, t, k) {
2388
+ const bar = Math.floor(step / 8) % 4, e = step % 8, ch = CHORDS[bar];
2389
+ if (e === 0) { ch.forEach(m => voice('triangle', mtof(m) * k, t, BEAT * 8.3 / k, 0.03, mus.lp, 0.2)); voice('sine', mtof(ch[0] - 24) * k, t, BEAT * 3.6 / k, 0.2, mus.lp, 0.02); }
2390
+ if (e === 4) voice('sine', mtof(ch[0] - 24) * k, t, BEAT * 3 / k, 0.15, mus.lp, 0.02);
2391
+ if (e === 0 || e === 5) { const o = voice('sine', 110 * k, t, 0.28, 0.45, mus.lp, 0.004); o.frequency.exponentialRampToValueAtTime(44 * k, t + 0.16); }
2392
+ if (e === 2 || e === 6) noiseHit(t, 1900 * k, 0.9, 0.22, 0.14);
2393
+ noiseHit(t + (e % 2 ? BEAT * 0.18 / k : 0), 7000, 1.2, 0.04, 0.04);
2394
+ voice('square', mtof(ch[[0, 2, 1, 3, 2, 1, 3, 2][e]] + 12) * k, t, BEAT * 0.8 / k, 0.012, mus.lp);
2395
+ }
2396
+ function crackle(t) { for (let i = 0; i < 3; i++) if (Math.random() < 0.35) noiseHit(t + Math.random() * 0.06, 2500 + Math.random() * 4000, 3, 0.02 + Math.random() * 0.05, 0.01); }
2397
+ function musicOn() {
2398
+ if (mus) return; try { const ctx = ac(), out = ctx.createGain(), lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2600; lp.connect(out); out.connect(ctx.destination);
2399
+ out.gain.setValueAtTime(0.0001, ctx.currentTime); out.gain.exponentialRampToValueAtTime(0.8, ctx.currentTime + 0.25);
2400
+ const noise = ctx.createBuffer(1, ctx.sampleRate * 0.2 | 0, ctx.sampleRate), d = noise.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
2401
+ mus = { ctx, out, lp, noise, next: ctx.currentTime + 0.05, step: 0 };
2402
+ const run = () => { if (!mus) return; const k = st.rpm / 33.333; while (mus.next < ctx.currentTime + 0.25) { note(mus.step++, mus.next, k); crackle(mus.next); mus.next += BEAT / Math.max(0.3, k); } };
2403
+ run(); mus.timer = setInterval(run, 50);
2404
+ } catch (e) { mus = null; } }
2405
+ function musicOff() { if (!mus) return; const m = mus; mus = null; clearInterval(m.timer);
2406
+ try { const t = m.ctx.currentTime; m.out.gain.cancelScheduledValues(t); m.out.gain.setTargetAtTime(0.0001, t, 0.05); tone(160, 0.45, 0.06, { type: 'sawtooth', to: 40 }); } catch (e) {}
2407
+ setTimeout(() => { try { m.out.disconnect(); } catch (e) {} }, 600); }
2408
+
2409
+ // ── state and behaviour ──
2410
+ const TRACK = 150, R0 = 75, R1 = 33; // seconds per side at 33⅓, lead-in and run-out radii
2411
+ const st = { on: false, speed: 33.333, pitch: 0, rpm: 0, mode: 'rest', a: A_REST, lift: 0, r: R0 };
2412
+ const tipAt = a => [P[0] + LS * Math.cos(a), P[1] + LS * Math.sin(a)];
2413
+ const radAt = a => { const t = tipAt(a); return Math.hypot(t[0] - C[0], t[1] - C[1]); };
2414
+ const angleFor = r => { let lo = A_REST, hi = 155 * Math.PI / 180; for (let i = 0; i < 30; i++) { const m = (lo + hi) / 2; if (radAt(m) > r) lo = m; else hi = m; } return (lo + hi) / 2; };
2415
+ const target = () => st.on ? st.speed * (1 + st.pitch / 100) : 0;
2416
+ const playing = () => st.mode === 'play' && st.lift < 0.3 && st.rpm > target() * 0.9 && target() > 0;
2417
+ const fmt = s => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
2418
+ function readout() { if (active !== 'tt') return;
2419
+ const sp = st.speed > 40 ? '45' : '33⅓', p = st.pitch ? ` ${st.pitch > 0 ? '+' : ''}${st.pitch.toFixed(0)}%` : '', t = fmt((R0 - st.r) / (R0 - R1) * TRACK);
2420
+ document.getElementById('read').textContent = !st.on && st.rpm < 0.5 ? `stopped · ${sp} rpm${p}` : `${playing() ? 'playing' : st.mode === 'cue' ? 'cueing' : st.on ? 'spinning' : 'stopping'} · ${sp} rpm${p} · ${t}`; }
2421
+ function render() {
2422
+ [[bStart, st.on], [b33, st.speed < 40], [b45, st.speed > 40]].forEach(([b, on]) => { b.mat.userData.base = on ? 0.55 : 0; if (!twFlashes.some(f => f.mats.includes(b.mat))) b.mat.emissiveIntensity = b.mat.userData.base; });
2423
+ readout();
2424
+ }
2425
+ function cue() { if (!st.on) st.on = true; if (st.mode === 'play' || st.mode === 'cue') { st.mode = 'return'; } else { if (st.r <= R1 + 0.5) st.r = R0; st.mode = 'cue'; } tap(900, 0.3, 2); render(); return true; }
2426
+ function startStop() { flash(bStart.g, 140); SFX.switch(); st.on = !st.on;
2427
+ if (st.on) { if (st.mode === 'rest' || st.mode === 'return') { if (st.r <= R1 + 0.5) st.r = R0; st.mode = 'cue'; } } else if (st.mode !== 'rest') st.mode = 'return';
2428
+ render(); return true; }
2429
+ function setSpeed(s) { flash(s > 40 ? b45.g : b33.g, 120); SFX.button(); st.speed = s; render(); return true; }
2430
+ function setPitch(p) { st.pitch = Math.max(-8, Math.min(8, Math.round(p))); SFX.detent(); render(); return true; }
2431
+ function key(e) {
2432
+ if (e.key === ' ') { if (!e.repeat) startStop(); return true; }
2433
+ if (e.key === '3') return setSpeed(33.333); if (e.key === '4') return setSpeed(45);
2434
+ if (e.key === 'c' || e.key === 'C') { if (!e.repeat) cue(); return true; }
2435
+ if (e.key === 'ArrowUp') return setPitch(st.pitch + 1); if (e.key === 'ArrowDown') return setPitch(st.pitch - 1); if (e.key === '0') return setPitch(0);
2436
+ return false;
2437
+ }
2438
+ function click(hit) {
2439
+ let o = hit?.object; while (o && !o.userData.ttBtn && !o.userData.platter && !o.userData.arm && !o.userData.pitch) o = o.parent; if (!o) return;
2440
+ const u = o.userData;
2441
+ if (u.ttBtn === 'start' || u.platter) startStop(); else if (u.ttBtn === '33') setSpeed(33.333); else if (u.ttBtn === '45') setSpeed(45); else if (u.arm) cue();
2442
+ else if (u.pitch) { const y = hit.point.z / TS; setPitch((y - (SLY0 + SLY1) / 2) / ((SLY1 - SLY0) / 2 - 6) * 8); }
2443
+ }
2444
+ let rotA = 0;
2445
+ function tick(now, dt) {
2446
+ const tg = target(); st.rpm += (tg - st.rpm) * Math.min(1, dt * (tg > st.rpm ? 2.4 : 1.1)); if (Math.abs(tg - st.rpm) < 0.01) st.rpm = tg;
2447
+ rotA -= st.rpm / 60 * Math.PI * 2 * dt; pg.rotation.y = rotA;
2448
+ // arm: swing toward its goal, lift / lower, then track the groove inward
2449
+ const goalA = st.mode === 'rest' || st.mode === 'return' ? A_REST : angleFor(st.r);
2450
+ const da = goalA - st.a, liftGoal = st.mode === 'play' || (st.mode === 'rest' && Math.abs(da) < 0.002) ? 0 : 6; // rests on the post, lifted while swinging
2451
+ if (st.mode === 'cue' && Math.abs(da) < 0.002 && st.lift > 5.9) st.mode = 'play';
2452
+ if (st.mode === 'return' && Math.abs(da) < 0.002) st.mode = 'rest';
2453
+ const canSwing = st.mode === 'play' || st.lift > 5.9;
2454
+ if (canSwing) st.a += Math.sign(da) * Math.min(Math.abs(da), (st.mode === 'play' ? 3 : 0.9) * dt);
2455
+ st.lift += Math.sign(liftGoal - st.lift) * Math.min(Math.abs(liftGoal - st.lift), (liftGoal ? 14 : 7) * dt);
2456
+ if (playing()) { st.r -= (R0 - R1) / TRACK * (st.rpm / 33.333) * dt; if (st.r <= R1) { st.r = R1; st.mode = 'return'; st.on = false; render(); } }
2457
+ arm.rotation.y = -st.a; lift.position.y = st.lift * TS;
2458
+ const p = playing(); if (p && !mus) musicOn(); else if (!p && mus) musicOff();
2459
+ tipGlow.material.uniforms.intensity.value = p ? 0.5 : 0;
2460
+ const spin = Math.min(1, st.rpm / 33.333); pilotMat.emissiveIntensity = st.on ? 1.4 : 0.6 * spin; pilotGlow.material.uniforms.intensity.value = st.on ? 0.8 : 0.4 * spin;
2461
+ slider.position.z = st.pitch / 8 * ((SLY1 - SLY0) / 2 - 6) * TS;
2462
+ haze.uniforms.opacity.value += ((p ? 0.12 : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 3);
2463
+ if ((tick.n = (tick.n || 0) + 1) % 15 === 0) readout();
2464
+ tickFlashes(now);
2465
+ }
2466
+ function demo() { if (!st.on) startStop(); }
2467
+ themeHooks.push(t => { pilotMat.color.set(t === 'light' ? 0xc3c6c8 : 0x2b2e30); drawLabel(); render(); });
2468
+ drawLabel(); render();
2469
+ return { group: g0, key, click, tick, stop: () => { st.on = false; if (st.mode !== 'rest') st.mode = 'return'; musicOff(); render(); }, readout, demo,
2470
+ panel: [['Start / stop', 'space', startStop], ['33⅓ rpm', '3', () => setSpeed(33.333)], ['45 rpm', '4', () => setSpeed(45)], ['Cue arm', 'C', cue], ['Pitch +', '↑', () => setPitch(st.pitch + 1)], ['Pitch −', '↓', () => setPitch(st.pitch - 1)]],
2471
+ view: { target: W(165, 130, 40), dist: 7.6 },
2472
+ info: '<h2>Turntable</h2><p>Press START·STOP (or space): the platter spins up, the tonearm swings over, lowers onto the lead-in and the record plays, tracking inward until the run-out. 33 / 45 (keys 3, 4) and the pitch slider (↑ ↓, or click the slider) change speed, so the music shifts in pitch and tempo. C lifts or cues the arm.</p>',
2473
+ hint: 'space start · 3 / 4 speed · ↑↓ pitch · c cue' };
2474
+ })();
2475
+
2476
+ // ══ Instant camera (new figure): press the shutter, the flash fires a real light, the motor ejects a print that slowly develops ══
2477
+ const PL = (() => {
2478
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
2479
+ const first = twMeshes.length, PLZ = 7;
2480
+ const cylY = (r, len, seg, mat, parent, pos) => { const m = new THREE.Mesh(new THREE.CylinderGeometry(r * TS, r * TS, len * TS, seg), mat); m.rotation.x = Math.PI / 2; m.position.copy(pos); addOutline(m); twMeshes.push(m); parent.add(m); return m; };
2481
+ const ringF = (cx, y, cz, r, n = 72) => Array.from({ length: n }, (_, i) => { const a = i / n * Math.PI * 2; return W(cx + r * Math.cos(a), y, cz + r * Math.sin(a)); });
2482
+ plateWith(g0, 340, 270, [[18, 18], [322, 18], [18, 252], [322, 252]], [24, 254, 56]);
2483
+ // body and the top hump (viewfinder / flash electronics)
2484
+ const BX = 50, BY = 40, BW = 170, BD = 96, BF = BY + BD, BT = PLZ + 104, HY = 44, HD = 40, HT = BT + 18;
2485
+ rbox(BX, BY, PLZ, BW, BD, BT - PLZ, 18, bodyMat, g0);
2486
+ rbox(BX + 6, HY, BT, BW - 12, HD, HT - BT, 10, bodyMat, g0);
2487
+ slitsUV(topUV(BX + 6, HY, HT), 14, 60, 3, 8, HD - 8, g0);
2488
+ // lens: barrel, focus ridges, front element rings
2489
+ const LC = [118, 64];
2490
+ cylY(34, 14, 72, bodyMat, g0, W(LC[0], BF + 7, LC[1]));
2491
+ { const s = []; for (let k = 0; k < 72; k++) { const a = k / 72 * Math.PI * 2, x = LC[0] + 34.1 * Math.cos(a), z = LC[1] + 34.1 * Math.sin(a); s.push([[x, BF + 3, z], [x, BF + 7, z]]); } seg(s, g0); }
2492
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(27 * TS, 64), recessMat); d.position.copy(W(LC[0], BF + 14.06, LC[1])); g0.add(d); }
2493
+ g0.add(loopL(ringF(LC[0], BF + 14.1, LC[1], 30), majorMat)); [22, 15].forEach(r => g0.add(loopL(ringF(LC[0], BF + 14.1, LC[1], r), lineMat)));
2494
+ { const d = new THREE.Mesh(new THREE.CircleGeometry(7 * TS, 32), bodyMat); d.position.copy(W(LC[0], BF + 14.08, LC[1])); g0.add(d); g0.add(loopL(ringF(LC[0], BF + 14.12, LC[1], 7, 32), lineMat)); }
2495
+ // viewfinder and flash windows on the front, label, film slot
2496
+ frontInset(g0, BX + 12, BF, BT - 5, 30, 18, 5); frontInset(g0, BX + 18, BF, BT - 9, 18, 10, 3);
2497
+ const FX = 168, FZ = BT - 5, FW = 44, FH = 22;
2498
+ const fWin = frontInset(g0, FX, BF, FZ, FW, FH, 4);
2499
+ { const s = []; for (let z = FZ - 3; z >= FZ - FH + 3; z -= 2.4) s.push([[FX + 3, BF + 0.12, z], [FX + FW - 3, BF + 0.12, z]]); seg(s, g0); }
2500
+ const flashGlow = glowPoints([W(FX + FW / 2, BF + 1.5, FZ - FH / 2)], 1.6); g0.add(flashGlow);
2501
+ const flashLight = new THREE.PointLight(0xffffff, 0, 3.2, 1.4); flashLight.position.copy(W(FX + FW / 2, BF + 24, FZ - FH / 2)); g0.add(flashLight);
2502
+ label('ISO-GLOW 600', 44, 7, 5.6, onFront(FX + FW / 2, BF, FZ - FH - 9), g0);
2503
+ label('INSTANT', 30, 6, 4.6, onFront(FX + FW / 2, BF, FZ - FH - 17), g0);
2504
+ const SLX = 76, SLW = 100, SLZ = 22;
2505
+ frontInset(g0, SLX, BF, SLZ + 2, SLW, 4, 2);
2506
+ // shutter (front, pushes in), flash button and film counter on the hump, ready LED on the hump front
2507
+ const shutter = new THREE.Group(); shutter.userData = { pl: 'shutter', pressAxis: 'z', pressDepth: 2.5 }; g0.add(shutter);
2508
+ const shMat = capMat(); twCaps.push(shMat); cylY(8, 5, 40, shMat, shutter, W(BX + 22, BF + 2.5, 44));
2509
+ shutter.add(loopL(ringF(BX + 22, BF + 5.1, 44, 5.5, 32), lineMat));
2510
+ const fBtn = new THREE.Group(); fBtn.userData = { pl: 'flash' }; g0.add(fBtn);
2511
+ const fMat = capMat(); twCaps.push(fMat); rbox(150, HY + 8, HT, 22, 12, 3, 3, fMat, fBtn);
2512
+ polyLine([[158, HY + 11, HT + 3.1], [163, HY + 14, HT + 3.1], [160, HY + 14, HT + 3.1], [165, HY + 17, HT + 3.1]], majorMat, fBtn);
2513
+ label('FLASH', 22, 6, 4.4, onTop(161, HY + 26, HT), g0);
2514
+ inset(184, HY + 6, HT, 26, 18, 3, g0);
2515
+ const counter = faceCanvas('top', 186, HY + 8, HT, 22, 14, 12, g0);
2516
+ const readyLed = dotFront(BX + 50, HY + HD, HT - 6, 1.5, g0);
2517
+ // strap lug on the side
2518
+ rbox(BX + BW, BY + 70, BT - 30, 4, 10, 12, 2, bodyMat, g0);
2519
+ const haze = hazeAndShadow(g0, 170, 140, 3.6, 2.8);
2520
+ creases(first);
2521
+
2522
+ // ── prints: thin card with a printed face (frame + picture that develops) ──
2523
+ const paperMat = new THREE.MeshStandardMaterial({ color: 0x2a2c2e, roughness: 0.9, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
2524
+ const PW = 88, PD = 106, PT = 1.2, PX0 = SLX + (SLW - PW) / 2, PIN = BF - PD, POUT = BF + 92 - PD;
2525
+ const photos = [];
2526
+ let shotN = 0;
2527
+ function makePhoto() {
2528
+ const g = new THREE.Group(); g.userData = { pl: 'photo' }; g0.add(g); g.visible = false;
2529
+ rbox(0, 0, 0, PW, PD, PT, 1, paperMat, g, { lines: false });
2530
+ { const sh = new THREE.Shape(); roundRect(sh, 0, -PD, PW, PD, 1); g.add(loopL(sh.getPoints(4).map(p => W(p.x, -p.y, PT + 0.06)), majorMat)); }
2531
+ const cv = faceCanvas('top', 0, 0, PT, PW, PD, 5, g);
2532
+ const rnd = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(++shotN * 7919 + 13);
2533
+ const scene_ = { n: shotN, sun: [16 + rnd() * 44, 16 + rnd() * 20], ridges: [0, 1, 2].map(k => Array.from({ length: 9 }, (_, i) => 44 + k * 9 - rnd() * (18 - k * 4) + (i % 2) * 3)), cube: rnd() < 0.5 };
2534
+ const p = { g, cv, scene: scene_, dev: 0, devT: -1, ejT: -1, flew: null, flash: false };
2535
+ photos.push(p); return p;
2536
+ }
2537
+ function drawPhoto(p) {
2538
+ const { x, P } = p.cv, C = pal3(), A_ = '#' + accent.getHexString(), light = theme === 'light', s = p.scene, d = p.dev;
2539
+ x.clearRect(0, 0, p.cv.c.width, p.cv.c.height);
2540
+ x.fillStyle = light ? '#ececea' : '#26282a'; x.fillRect(0, 0, PW * P, PD * P);
2541
+ const ix = 6, iy = 6, iw = 76, ih = 76;
2542
+ x.save(); x.beginPath(); x.rect(ix * P, iy * P, iw * P, ih * P); x.clip();
2543
+ x.fillStyle = light ? '#f7f2ec' : '#141516'; x.fillRect(ix * P, iy * P, iw * P, ih * P);
2544
+ x.translate(ix * P, iy * P); x.lineWidth = 0.35 * P; x.lineJoin = 'round';
2545
+ const g = x.createRadialGradient(s.sun[0] * P, s.sun[1] * P, 0, s.sun[0] * P, s.sun[1] * P, 22 * P); g.addColorStop(0, A_ + '55'); g.addColorStop(1, A_ + '00');
2546
+ x.fillStyle = g; x.fillRect(0, 0, iw * P, ih * P);
2547
+ x.fillStyle = A_; x.beginPath(); x.arc(s.sun[0] * P, s.sun[1] * P, 6 * P, 0, Math.PI * 2); x.fill();
2548
+ s.ridges.forEach((r, k) => { x.strokeStyle = k === 2 ? C.ink : C.line; x.fillStyle = light ? '#f7f2ec' : '#141516'; x.beginPath(); x.moveTo(0, ih * P);
2549
+ r.forEach((v, i) => x.lineTo(i * iw / 8 * P, v * P)); x.lineTo(iw * P, ih * P); x.closePath(); x.fill(); x.stroke(); });
2550
+ if (s.cube) { const cx = 54, cy = 58, a = 7; x.strokeStyle = C.inkHi; x.fillStyle = light ? '#f7f2ec' : '#141516'; x.beginPath();
2551
+ [[0, -a], [a * 0.87, -a / 2], [a * 0.87, a / 2], [0, a], [-a * 0.87, a / 2], [-a * 0.87, -a / 2]].forEach(([u, v], i) => i ? x.lineTo((cx + u) * P, (cy + v) * P) : x.moveTo((cx + u) * P, (cy + v) * P));
2552
+ x.closePath(); x.fill(); x.stroke(); x.beginPath(); x.moveTo(cx * P, cy * P); x.lineTo((cx + a * 0.87) * P, (cy - a / 2) * P); x.moveTo(cx * P, cy * P); x.lineTo((cx - a * 0.87) * P, (cy - a / 2) * P); x.moveTo(cx * P, cy * P); x.lineTo(cx * P, (cy + a) * P); x.stroke(); }
2553
+ // undeveloped: a flat blue-grey sheet that clears from the edges in
2554
+ const k = 1 - d * d * (3 - 2 * d);
2555
+ if (k > 0) { x.globalAlpha = k; x.fillStyle = light ? '#8d969c' : '#22282c'; x.fillRect(0, 0, iw * P, ih * P); x.globalAlpha = 1; }
2556
+ x.restore();
2557
+ x.strokeStyle = C.line; x.lineWidth = 0.3 * P; x.strokeRect(ix * P, iy * P, iw * P, ih * P);
2558
+ x.fillStyle = C.ink; x.font = `${4.2 * P}px ${MONO3}`; x.textAlign = 'left'; x.fillText(`No. ${String(s.n).padStart(2, '0')}${p.flash ? ' · flash' : ''}`, ix * P, (PD - 12) * P);
2559
+ p.cv.tex.needsUpdate = true;
2560
+ }
2561
+ const place = (p, x, y, z, rot = 0) => { p.g.position.copy(W(x, y, z)); p.g.rotation.y = rot; };
2562
+ const pileSpot = i => ({ x: 236 + (i % 3) * 2, y: 120 + (i % 3) * 3, z: PLZ + 0.2 + i * PT * 1.05, rot: [0.12, -0.08, 0.2, -0.15, 0.04][i % 5] });
2563
+
2564
+ // ── state and behaviour ──
2565
+ const st = { film: 10, flashOn: true, charge: 1, flashT: -1, busy: false, out: null, pile: [] };
2566
+ function readout() { if (active !== 'pl') return;
2567
+ const dev = photos.filter(p => p.devT >= 0 && p.dev < 1).map(p => Math.round(p.dev * 100));
2568
+ document.getElementById('read').textContent = (st.film ? `${st.film} shots left` : 'no film · R reloads') + ` · flash ${st.flashOn ? (st.charge < 1 ? 'charging' : 'ready') : 'off'}` + (dev.length ? ` · developing ${dev[dev.length - 1]}%` : st.out ? ' · take the print' : ''); }
2569
+ function drawCounter() { const { x, P } = counter, { C, A_ } = screenBg(counter, st.film > 0, 2);
2570
+ x.fillStyle = st.film ? A_ : C.ink; x.font = `${9 * P}px ${MONO3}`; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(String(st.film), 11 * P, 7.4 * P); counter.tex.needsUpdate = true; }
2571
+ function render() {
2572
+ fMat.userData.base = st.flashOn ? 0.55 : 0; if (!twFlashes.some(f => f.mats.includes(fMat))) fMat.emissiveIntensity = fMat.userData.base;
2573
+ shMat.userData.base = st.film && !st.busy ? 0.3 : 0; if (!twFlashes.some(f => f.mats.includes(shMat))) shMat.emissiveIntensity = shMat.userData.base;
2574
+ drawCounter(); readout();
2575
+ }
2576
+ function take() {
2577
+ const p = st.out; if (!p) return false; st.out = null;
2578
+ const i = st.pile.length; st.pile.push(p); if (st.pile.length > 5) { const old = st.pile.shift(); g0.remove(old.g); photos.splice(photos.indexOf(old), 1);
2579
+ st.pile.forEach((q, j) => { const s = pileSpot(j); place(q, s.x, s.y, s.z, s.rot); }); }
2580
+ const s = pileSpot(st.pile.length - 1); p.flew = { t0: performance.now(), from: [PX0, POUT, SLZ - PT / 2, 0], to: [s.x, s.y, s.z, s.rot] };
2581
+ tap(1500, 0.3, 1); render(); return true;
2582
+ }
2583
+ function shoot() {
2584
+ flash(shutter, 140);
2585
+ if (st.busy) return true;
2586
+ if (!st.film) { tap(700, 0.5, 2); render(); return true; }
2587
+ if (st.out) take();
2588
+ tap(2400, 0.9, 1.5); tap(600, 0.7, 1); st.film--; st.busy = true;
2589
+ const p = makePhoto();
2590
+ if (st.flashOn && st.charge >= 1) { st.flashT = performance.now(); st.charge = 0; p.flash = true; tone(1700, 1.5, 0.018, { to: 4600, at: 0.25 }); }
2591
+ drawPhoto(p); place(p, PX0, PIN, SLZ - PT / 2);
2592
+ setTimeout(() => { p.g.visible = true; p.ejT = performance.now(); tone(92, 1.1, 0.07, { type: 'square' }); tone(184, 1.1, 0.025, { type: 'sawtooth' }); }, 260);
2593
+ render(); return true;
2594
+ }
2595
+ function toggleFlash() { flash(fBtn, 110); SFX.button(); st.flashOn = !st.flashOn; render(); return true; }
2596
+ function reload() { if (st.busy) return true; st.film = 10; SFX.thunk(); tone(70, 0.5, 0.06, { type: 'square', at: 0.15 }); render(); return true; }
2597
+ function key(e) {
2598
+ if (e.key === ' ') { if (!e.repeat) shoot(); return true; }
2599
+ if (e.key === 'Enter') { take(); return true; }
2600
+ if (e.key === 'f' || e.key === 'F') { if (!e.repeat) toggleFlash(); return true; }
2601
+ if (e.key === 'r' || e.key === 'R') { if (!e.repeat) reload(); return true; }
2602
+ return false;
2603
+ }
2604
+ function click(hit) { let o = hit?.object; while (o && !o.userData.pl) o = o.parent; if (!o) return;
2605
+ const k = o.userData.pl; if (k === 'shutter') shoot(); else if (k === 'flash') toggleFlash(); else if (k === 'photo' && st.out && o === st.out.g) take(); }
2606
+ let lastDraw = 0;
2607
+ function tick(now, dt) {
2608
+ // flash: a real point light and the window glow, a sharp spike that dies in ~0.2 s; then the capacitor recharges
2609
+ const ft = st.flashT >= 0 ? (now - st.flashT) / 1000 : 9, fk = ft < 0.22 ? Math.exp(-ft * 22) * (ft < 0.012 ? ft / 0.012 : 1) : 0;
2610
+ flashLight.intensity = theme === 'light' ? 0 : 60 * fk; flashGlow.material.uniforms.intensity.value = 1.2 * fk; fWin.material = fk > 0.15 ? ledOnMat : recessMat;
2611
+ if (st.charge < 1) { st.charge = Math.min(1, st.charge + dt / 1.8); if (st.charge >= 1) { tap(5200, 0.15, 6); render(); } }
2612
+ readyLed.material = st.flashOn && (st.charge >= 1 || Math.floor(now / 160) % 2) ? ledOnMat : ledOffMat;
2613
+ photos.forEach(p => {
2614
+ if (p.ejT >= 0) { const t = Math.min(1, Math.max(0, (now - p.ejT) / 1100)); place(p, PX0, PIN + (POUT - PIN) * t, SLZ - PT / 2);
2615
+ if (t >= 1) { p.ejT = -1; p.devT = now; st.busy = false; st.out = p; render(); } }
2616
+ if (p.flew) { const t = Math.min(1, (now - p.flew.t0) / 600), e = t * t * (3 - 2 * t), f = p.flew.from, to = p.flew.to;
2617
+ place(p, f[0] + (to[0] - f[0]) * e, f[1] + (to[1] - f[1]) * e, f[2] + (to[2] - f[2]) * e + Math.sin(Math.PI * t) * 26, f[3] + (to[3] - f[3]) * e); if (t >= 1) p.flew = null; }
2618
+ if (p.devT >= 0 && p.dev < 1) p.dev = Math.min(1, (now - p.devT) / 7000);
2619
+ });
2620
+ if (now - lastDraw > 90) { lastDraw = now; photos.forEach(p => { if (p.devT >= 0 && (p.dev < 1 || !p.done)) { drawPhoto(p); if (p.dev >= 1) p.done = true; } }); readout(); }
2621
+ haze.uniforms.opacity.value += ((fk > 0.02 ? 0.5 * fk : 0) - haze.uniforms.opacity.value) * Math.min(1, dt * 20);
2622
+ tickFlashes(now);
2623
+ }
2624
+ function demo() { shoot(); }
2625
+ themeHooks.push(t => { paperMat.color.set(t === 'light' ? 0xe9e9e7 : 0x2a2c2e); photos.forEach(drawPhoto); render(); });
2626
+ render();
2627
+ return { group: g0, key, click, tick, stop: () => {}, readout, demo,
2628
+ panel: [['Shutter', 'space', shoot], ['Take print', '↵', take], ['Flash on / off', 'F', toggleFlash], ['Reload film', 'R', reload]],
2629
+ view: { target: W(185, 140, 60), dist: 7.6 },
2630
+ info: '<h2>Instant camera</h2><p>Press the shutter (or space): the flash fires a real light across the desk, the motor ejects a print and it develops over a few seconds. Click the print (or Enter) to lay it on the pile. F switches the flash, which needs a moment to recharge; R reloads a ten-shot pack.</p>',
2631
+ hint: 'space shoot · enter take print · f flash · r reload' };
2632
+ })();
2633
+
2634
+
2635
+ // ══ your figures ══
2636
+ // One FIGS3D.push({ id, name, mod }) per figure; mod is the figure module (see the iso-glow skill, "3D mode").
2637
+ // When this list has figures, only they get tabs; the built-in figures above stay as working references.
2638
+ const FIGS3D = [];
2639
+ /* paste 3D figure modules here */
2640
+
2641
+ // ── state ──
2642
+ const st = { eng: false, lights: false, cabin: false, door: false, rpm: 0, alt: 0, tAlt: 0, mr: 0, tr: 0, doorT: 0, flash: false, msg: '' };
2643
+ const ALTMAX = 6, RPM = 258;
2644
+ const btn = a => document.querySelector(`[data-act="${a}"]`);
2645
+ function render() {
2646
+ navMat.emissiveIntensity = st.lights ? 1.5 : 0;
2647
+ padLightMat.emissiveIntensity = st.lights ? 1.5 : 0;
2648
+ beaconMat.emissiveIntensity = st.lights && st.flash ? 2 : 0;
2649
+ if (padGlow) padGlow.material.uniforms.intensity.value = st.lights ? 0.85 : 0;
2650
+ if (navGlow) navGlow.material.uniforms.intensity.value = st.lights ? 0.8 : 0;
2651
+ if (beaconGlow) beaconGlow.material.uniforms.intensity.value = st.lights && st.flash ? 1 : 0;
2652
+ cabinLight.intensity = st.cabin ? 26 : 0;
2653
+ cockpitLight.intensity = st.cabin ? 9 : 0;
2654
+ ['engines', 'lights', 'cabin', 'door'].forEach(a => btn(a).classList.toggle('on', !!st[{ engines: 'eng' }[a] || a]));
2655
+ const p = [st.eng ? 'engines on' : st.rpm > 0.01 ? 'spooling down' : 'engines off', `nr ${Math.round(st.rpm * RPM)} rpm`];
2656
+ if (st.alt > 0.01 || st.tAlt) p.push(`alt ${st.alt.toFixed(1)} m`);
2657
+ if (st.lights) p.push('lights'); if (st.cabin) p.push('cabin lit'); if (st.door) p.push('doors open');
2658
+ if (st.msg) p.push(st.msg);
2659
+ if (active === 'heli') document.getElementById('read').textContent = p.join(' · ');
2660
+ }
2661
+ const act = {
2662
+ lights() { st.lights = !st.lights; },
2663
+ engines() { st.eng = !st.eng; if (!st.eng) st.tAlt = 0; st.msg = ''; },
2664
+ cabin() { st.cabin = !st.cabin; },
2665
+ door() { st.door = !st.door; },
2666
+ iso() { // toggle: on = snap to the iso angle in true isometric (orthographic); off = back to perspective at the same angle
2667
+ if (camera === ortho) { setOrtho(false); return; }
2668
+ const v = view();
2669
+ camAnim = { from: camera.position.clone(), fromT: controls.target.clone(), t: 0, v };
2670
+ setSpin(false);
2671
+ setOrtho(true, v.dist());
2672
+ camAnim.from = camera.position.clone(); camAnim.zoom0 = ortho.zoom;
2673
+ },
2674
+ climb(d) {
2675
+ if (d > 0 && !st.eng) { st.msg = 'start engines first'; return; }
2676
+ st.tAlt = THREE.MathUtils.clamp(st.tAlt + d, 0, ALTMAX);
2677
+ st.msg = d > 0 && st.rpm < 0.95 ? 'waiting for rotor speed' : '';
2678
+ },
2679
+ };
2680
+ const run = (a, ...x) => { act[a](...x); render(); };
2681
+ document.querySelectorAll('#ctl button').forEach(b => b.addEventListener('click', () => run(b.dataset.act)));
2682
+ addEventListener('keydown', e => {
2683
+ if (active !== 'heli' || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2684
+ const k = { l: 'lights', e: 'engines', c: 'cabin', d: 'door', i: 'iso' }[e.key.toLowerCase()];
2685
+ if (k) { run(k); e.preventDefault(); }
2686
+ else if (e.key === 'ArrowUp') { run('climb', 1); e.preventDefault(); }
2687
+ else if (e.key === 'ArrowDown') { run('climb', -1); e.preventDefault(); }
2688
+ });
2689
+ setInterval(() => { st.flash = true; render(); setTimeout(() => { st.flash = false; render(); }, 140); }, 1100);
2690
+
2691
+ // edge detail (remembered per viewer); lines always 1 CSS px, dimmed toward the background when zoomed out
2692
+ const store = (k, v) => { try { localStorage.setItem('iso-glow-seahawk-' + k, v); } catch {} };
2693
+ const load = k => { try { return localStorage.getItem('iso-glow-seahawk-' + k); } catch { return null; } };
2694
+ const ed = document.getElementById('ed'), edv = document.getElementById('edv');
2695
+ let appliedKey = '';
2696
+ function applyWeight() {
2697
+ const dist = camera.position.distanceTo(controls.target);
2698
+ const ppm = camera === ortho ? innerHeight * ortho.zoom / orthoH : innerHeight / (dist * fovK()); // CSS px per metre
2699
+ const k = THREE.MathUtils.clamp((ppm / 40 - 0.35) / 0.65, 0, 1);
2700
+ const fm = THEMES[theme].fadeMin, fade = fm + (1 - fm) * k, dpr = renderer.getPixelRatio();
2701
+ const key = fade.toFixed(2) + '|' + dpr + '|' + theme + '|' + (camera === ortho); if (key === appliedKey) return; appliedKey = key;
2702
+ lineMat.linewidth = majorMat.linewidth = dpr; accentLineMat.linewidth = 1.4 * dpr;
2703
+ lineMat.color.copy(BG_COL).lerp(EDGE_COL, fade);
2704
+ majorMat.color.copy(BG_COL).lerp(MAJOR_COL, fade);
2705
+ outlineMat.uniforms.thickness.value = 0.5 * dpr;
2706
+ outlineMat.uniforms.color.value.copy(BG_COL).lerp(LINE_COL, fade);
2707
+ }
2708
+ let edTimer;
2709
+ function setEdge(a, rebuild = true) { edgeAngle = a; ed.value = a; edv.textContent = a + '°'; store('edge', a); if (rebuild) { clearTimeout(edTimer); edTimer = setTimeout(rebuildEdges, 60); } }
2710
+ ed.addEventListener('input', () => setEdge(+ed.value));
2711
+ setEdge(+load('edge') || 30, false);
2712
+
2713
+ setAccent(PALETTE[0][1]); // always the orange (sodium) accent
2714
+
2715
+ const accentFillU = { value: 0 }, accentFilled = new WeakSet();
2716
+ function accentFill(root) { // patch every material that can light up with the accent
2717
+ root.traverse(o => { const m = o.material; if (!m || !m.isMeshStandardMaterial || accentFilled.has(m) || m.emissive.getHex() === 0) return; accentFilled.add(m);
2718
+ m.onBeforeCompile = sh => { sh.uniforms.uAccentFill = accentFillU; sh.uniforms.uAccentC = { value: accent };
2719
+ sh.fragmentShader = 'uniform float uAccentFill; uniform vec3 uAccentC;\n' + sh.fragmentShader.replace('#include <opaque_fragment>',
2720
+ 'if ( uAccentFill > 0.5 ) { float am = max( uAccentC.r, max( uAccentC.g, uAccentC.b ) ); outgoingLight = mix( outgoingLight, uAccentC, clamp( max( emissive.r, max( emissive.g, emissive.b ) ) / ( 0.5 * am ), 0.0, 1.0 ) ); }\n#include <opaque_fragment>'); };
2721
+ m.needsUpdate = true; });
2722
+ }
2723
+ function setTheme(t) {
2724
+ theme = t; const T = THEMES[t], light = t === 'light';
2725
+ accentFill(scene); accentFillU.value = light ? 1 : 0;
2726
+ document.documentElement.dataset.theme = t;
2727
+ scene.background.set(T.bg); BG_COL.set(T.bg);
2728
+ bodyMat.color.set(T.body); bladeMat.color.set(T.blade); padMat.color.set(T.pad);
2729
+ accentMats.forEach(m => m.color.set(T.lamp));
2730
+ LINE_COL.set(T.line); MAJOR_COL.set(T.major); EDGE_COL.set(T.edge); appliedKey = '';
2731
+ hemi.color.set(T.sky); hemi.groundColor.set(T.ground); hemi.intensity = T.hemi; ambient.intensity = T.amb; sun.intensity = T.sun; fill.intensity = T.fill;
2732
+ [...glowMats, spotMat, spillMat].forEach(m => { m.blending = light ? THREE.NormalBlending : THREE.AdditiveBlending; m.needsUpdate = true; });
2733
+ glowMats.forEach(m => m.uniforms.light.value = light ? 1 : 0);
2734
+ [...glowMats, ...softGlows].forEach(m => m.visible = !light); // light theme: no glow effects at all (halos, beads, pools, haze)
2735
+ setAccent(accentHex);
2736
+ themeHooks.forEach(f => f(t));
2737
+ store('theme', t);
2738
+ }
2739
+ // auto revolve: the camera slowly orbits the figure on screen; drag/zoom still work while it turns
2740
+ const spinBtn = document.getElementById('spin');
2741
+ function setSpin(on) { if (camAnim) camAnim.spin = on; controls.autoRotate = on; controls.autoRotateSpeed = 1.2; spinBtn.setAttribute('aria-pressed', on); store('spin', on ? 1 : 0); }
2742
+ spinBtn.addEventListener('click', () => { setSpin(!controls.autoRotate); spinBtn.blur(); });
2743
+ setSpin(load('spin') === '1');
2744
+ document.getElementById('theme').addEventListener('click', () => setTheme(theme === 'dark' ? 'light' : 'dark'));
2745
+ addEventListener('keydown', e => { if (!['tw', 'pc'].includes(active) && e.key.toLowerCase() === 't' && !e.metaKey && !e.ctrlKey && !e.altKey && e.target.tagName !== 'INPUT') setTheme(theme === 'dark' ? 'light' : 'dark'); });
2746
+ setTheme(load('theme') || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
2747
+
2748
+ // click a part to identify it
2749
+ const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(); let down = null;
2750
+ renderer.domElement.addEventListener('pointerdown', e => {
2751
+ down = [e.clientX, e.clientY];
2752
+ if (active !== 'cs') return; // cassette transport keys act on press (hold FF/REW to wind); orbit pauses while held
2753
+ ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); ray.setFromCamera(ndc, camera);
2754
+ const hit = ray.intersectObject(CS.group, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline);
2755
+ if (CS.pointerDown(hit)) controls.enabled = false;
2756
+ });
2757
+ addEventListener('pointerup', () => { if (active === 'cs') CS.up(); controls.enabled = true; });
2758
+ addEventListener('pointercancel', () => { if (active === 'cs') CS.up(); controls.enabled = true; });
2759
+ renderer.domElement.addEventListener('pointerup', e => {
2760
+ if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) return;
2761
+ ndc.set(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); ray.setFromCamera(ndc, camera);
2762
+ if (EXTRA[active]) { const F = EXTRA[active]; F.click(ray.intersectObject(F.group, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline), e); return; }
2763
+ if (active === 'cs') return;
2764
+ if (active === 'tm' || active === 'vm') { const F = active === 'tm' ? TM : VM; F.click(ray.intersectObject(F.group, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline)); return; }
2765
+ if (active === 'dr') { DR.click(ray.intersectObject(DR.group, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline)); return; }
2766
+ if (active === 'tw') { twClick(ray.intersectObject(tw, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline)); return; }
2767
+ const hit = ray.intersectObject(heli, true).find(h => h.object.isMesh && !h.object.isLineSegments2 && !h.object.userData.outline);
2768
+ let o = hit?.object; while (o && !o.userData.info) o = o.parent;
2769
+ document.getElementById('info').innerHTML = o ? `<h2>${o.userData.info.name}</h2><p>${o.userData.info.text}</p>` : `<h2>Click any part</h2><p>Drag to orbit, scroll to zoom, right-drag to pan.</p>`;
2770
+ });
2771
+
2772
+ // ── projection switch ──
2773
+ const fovK = () => 2 * Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
2774
+ function orthoFrustum() { const a = innerWidth / innerHeight; ortho.top = orthoH / 2; ortho.bottom = -orthoH / 2; ortho.left = -orthoH * a / 2; ortho.right = orthoH * a / 2; ortho.updateProjectionMatrix(); }
2775
+ function setOrtho(on, frameDist) {
2776
+ if (on === (camera === ortho)) return;
2777
+ if (on) {
2778
+ orthoH = (frameDist || camera.position.distanceTo(controls.target)) * fovK(); // frame the same area the perspective view shows
2779
+ ortho.position.copy(camera.position); ortho.quaternion.copy(camera.quaternion); ortho.zoom = 1; orthoFrustum();
2780
+ camera = ortho;
2781
+ } else {
2782
+ const d = orthoH / fovK() / ortho.zoom; // same framing back in perspective
2783
+ persp.position.copy(controls.target).add(ortho.position.clone().sub(controls.target).setLength(d));
2784
+ persp.quaternion.copy(ortho.quaternion); camera = persp;
2785
+ }
2786
+ controls.object = camera; renderPass.camera = camera; controls.update(); appliedKey = '';
2787
+ glowMats.forEach(m => m.uniforms.ortho.value = on ? 1 : 0);
2788
+ document.querySelectorAll('[data-act="iso"], [data-tw="iso"], [data-dr="iso"], [data-cs="iso"], [data-tm="iso"], [data-vm="iso"], [data-x="iso"]').forEach(b => b.classList.toggle('on', on));
2789
+ store('ortho', on ? 1 : 0);
2790
+ }
2791
+
2792
+ // ── figures: switcher, per-figure camera, panels, keyboard ──
2793
+ const VIEWS = {
2794
+ heli: { target: TARGET, dir: ISO, dist: fitDist, min: 4, cam: null,
2795
+ info: '<h2>Click any part</h2><p>Drag to orbit, scroll to zoom, right-drag to pan. ↑ ↓ climb and descend once the rotor is at speed.</p>',
2796
+ hint: 'L lights · E engines · ↑↓ climb · C cabin lights · D doors · I iso · T theme' },
2797
+ tw: { target: W(150, 125, 55), dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => 6.2 * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null,
2798
+ info: '<h2>Typewriter</h2><p>Type on your keyboard or click its keys to stamp characters on the paper. Enter or the return lever starts a new line, Backspace steps the carriage back; the bell rings near the right margin.</p>',
2799
+ hint: 'type · enter returns the carriage · backspace steps back' },
2800
+ dr: { target: W(150, 150, 30), dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => 7 * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null,
2801
+ info: '<h2>Drum machine</h2><p>Press pads (keys 1 2 3 4, q w e r, a s d f, z x c v) to play drum sounds and select a pad; click the step buttons to program the selected pad; space or the play button starts and stops the 112 bpm loop.</p>',
2802
+ hint: 'pads 1-4 q-r a-f z-v · space play · steps program the selected pad' },
2803
+ cs: { target: W(160, 125, 40), dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => 7 * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null,
2804
+ info: '<h2>Cassette deck</h2><p>Press PLAY (or space) to play a generated lo-fi loop: the reels turn, the tape counter advances and the VU meters follow the level. Tap or hold REW and FF (arrow keys) to wind, STOP (S) to stop, REC (R) to arm recording.</p>',
2805
+ hint: 'space play/stop · ← rew · → ff · S stop · R rec' },
2806
+ tm: { target: W(140, 110, 55), dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => 6.4 * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null,
2807
+ info: '<h2>Kitchen timer</h2><p>Press +1, +5 or +10 to add minutes (keys 1, 5, 0; + and − add or take a minute; S adds ten seconds), START/STOP or space to run the countdown, RESET or R to zero it. It rings at 00:00; any button silences it.</p>',
2808
+ hint: '1 · 5 · 0 add min · space start · r reset' },
2809
+ vm: { target: W(130, 115, 150), dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => 9.5 * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null,
2810
+ info: '<h2>Vending machine</h2><p>Press COIN or space to add 0.50 credit, then a letter A–C and a number 1–3 to buy; the item drops into the pickup tray. CLR or Escape cancels or refunds. Click the tray to take the item; the SERVICE lock restocks.</p>',
2811
+ hint: 'space = coin · pick a–c then 1–3 · esc clears' },
2812
+ };
2813
+ const view = () => VIEWS[active];
2814
+ function setFigure(f) {
2815
+ if (VIEWS[active]) VIEWS[active].cam = { pos: camera.position.clone(), target: controls.target.clone(), zoom: camera === ortho ? ortho.zoom : undefined };
2816
+ if (active === 'dr' && f !== 'dr') DR.stop();
2817
+ if (active === 'cs' && f !== 'cs') CS.stop();
2818
+ if (active === 'tm' && f !== 'tm') TM.stop();
2819
+ if (EXTRA[active] && f !== active) EXTRA[active].stop(); // leaving the drum machine stops the loop, like unmounting the figure
2820
+ active = f; const v = view();
2821
+ heli.visible = pad.visible = f === 'heli'; tw.visible = f === 'tw'; DR.group.visible = f === 'dr'; CS.group.visible = f === 'cs'; TM.group.visible = f === 'tm'; VM.group.visible = f === 'vm';
2822
+ Object.entries(EXTRA).forEach(([id, F]) => { F.group.visible = f === id; document.getElementById('ctl-' + id).classList.toggle('hide', f !== id); });
2823
+ document.getElementById('ctl').classList.toggle('hide', f !== 'heli');
2824
+ document.getElementById('ctl-tw').classList.toggle('hide', f !== 'tw');
2825
+ document.getElementById('ctl-dr').classList.toggle('hide', f !== 'dr');
2826
+ document.getElementById('ctl-cs').classList.toggle('hide', f !== 'cs');
2827
+ document.getElementById('ctl-tm').classList.toggle('hide', f !== 'tm');
2828
+ document.getElementById('ctl-vm').classList.toggle('hide', f !== 'vm');
2829
+ document.querySelectorAll('.seg button').forEach(b => b.setAttribute('aria-selected', b.dataset.fig === f));
2830
+ document.getElementById('info').innerHTML = v.info; document.getElementById('hint').textContent = v.hint;
2831
+ controls.minDistance = v.min; if (camAnim) { controls.enableDamping = true; controls.autoRotate = camAnim.spin ?? controls.autoRotate; } camAnim = null;
2832
+ if (v.cam) { camera.position.copy(v.cam.pos); controls.target.copy(v.cam.target); }
2833
+ else { controls.target.copy(v.target); camera.position.copy(v.target).addScaledVector(v.dir, v.dist()); }
2834
+ if (camera === ortho) { orthoH = camera.position.distanceTo(controls.target) * fovK(); ortho.zoom = v.cam?.zoom ?? 1; orthoFrustum(); }
2835
+ if (f === 'tw') twReadout(); else if (f === 'dr') DR.readout(); else if (f === 'cs') CS.readout(); else if (f === 'tm') TM.readout(); else if (f === 'vm') VM.readout(); else if (EXTRA[f]) EXTRA[f].readout(); else render();
2836
+ store('fig', f);
2837
+ }
2838
+ document.querySelectorAll('.seg button').forEach(b => b.addEventListener('click', () => setFigure(b.dataset.fig)));
2839
+ document.querySelectorAll('#ctl-tw button').forEach(b => b.addEventListener('click', () => {
2840
+ const a = b.dataset.tw; if (a === 'demo') twDemo(); else if (a === 'iso') act.iso(); else twPress(a); b.blur();
2841
+ }));
2842
+ document.querySelectorAll('#ctl-tm button').forEach(b => b.addEventListener('click', () => { const a = b.dataset.tm; if (a === 'demo') TM.demo(); else if (a === 'iso') act.iso(); else TM.act(a); b.blur(); }));
2843
+ document.querySelectorAll('#ctl-vm button').forEach(b => b.addEventListener('click', () => { const a = b.dataset.vm; if (a === 'demo') VM.demo(); else if (a === 'iso') act.iso(); else if (a === 'coin') VM.coin(); else VM.clear(); b.blur(); }));
2844
+ // ── registry-driven figures (computer, lamp, logic board): tab, panel, view, keys, clicks, ticks come from the module ──
2845
+ const EXTRA = { pc: PC, lp: LP, lg: LG, rk: RK, sk: SK, tt: TT, pl: PL }, EXTRA_NAMES = { pc: 'Computer', lp: 'Lamp', lg: 'Logic', rk: 'Rack', sk: 'Stack', tt: 'Turntable', pl: 'Camera' };
2846
+ FIGS3D.forEach(f => { EXTRA[f.id] = f.mod; EXTRA_NAMES[f.id] = f.name; });
2847
+ const SHOW_FIGS = FIGS3D.length ? FIGS3D.map(f => f.id) : ['tw', 'dr', 'cs', 'tm', 'vm', 'pc', 'lp', 'lg', 'rk', 'sk', 'tt', 'pl'];
2848
+ for (const [id, F] of Object.entries(EXTRA)) {
2849
+ VIEWS[id] = { target: F.view.target, dir: new THREE.Vector3(1, 1, 1).normalize(), dist: () => F.view.dist * Math.max(1, 1.45 / persp.aspect), min: 1, cam: null, info: F.info, hint: F.hint };
2850
+ const panel = document.createElement('div'); panel.className = 'ui ctl hide'; panel.id = 'ctl-' + id;
2851
+ panel.style.cssText = 'top:64px; right:16px; display:flex; flex-direction:column; gap:6px; width:182px;';
2852
+ [...F.panel, null, ['Iso view', '', () => act.iso(), 'iso']].forEach(b => {
2853
+ if (!b) { const sp = document.createElement('div'); sp.className = 'sep'; panel.appendChild(sp); return; }
2854
+ const el = document.createElement('button'); el.innerHTML = `${b[0]}<kbd>${b[1]}</kbd>`; if (b[3]) el.dataset.x = b[3];
2855
+ el.addEventListener('click', () => { b[2](); el.blur(); }); panel.appendChild(el);
2856
+ });
2857
+ document.body.insertBefore(panel, document.getElementById('info'));
2858
+ const tab = document.createElement('button'); tab.dataset.fig = id; tab.setAttribute('role', 'tab'); tab.textContent = EXTRA_NAMES[id];
2859
+ tab.addEventListener('click', () => setFigure(id)); document.querySelector('.seg').appendChild(tab);
2860
+ }
2861
+ addEventListener('keydown', e => {
2862
+ const F = EXTRA[active]; if (!F || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2863
+ if (F.key(e)) e.preventDefault();
2864
+ });
2865
+ addEventListener('keydown', e => {
2866
+ if ((active !== 'tm' && active !== 'vm') || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2867
+ if ((active === 'tm' ? TM : VM).key(e)) e.preventDefault();
2868
+ });
2869
+ document.querySelectorAll('#ctl-cs button').forEach(b => b.addEventListener('click', () => {
2870
+ const a = b.dataset.cs; if (a === 'demo') CS.demo(); else if (a === 'iso') act.iso(); else { CS.key({ key: a === 'play' ? ' ' : 's' }); CS.up(); } b.blur();
2871
+ }));
2872
+ addEventListener('keydown', e => {
2873
+ if (active !== 'cs' || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2874
+ if (CS.key(e)) e.preventDefault();
2875
+ });
2876
+ addEventListener('keyup', e => { if (active === 'cs') CS.keyup(e); });
2877
+ addEventListener('blur', () => CS.up());
2878
+ document.querySelectorAll('#ctl-dr button').forEach(b => b.addEventListener('click', () => {
2879
+ const a = b.dataset.dr; if (a === 'demo') DR.demo(); else if (a === 'iso') act.iso(); else DR.toggle(); b.blur();
2880
+ }));
2881
+ addEventListener('keydown', e => {
2882
+ if (active !== 'dr' || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2883
+ if (DR.key(e)) e.preventDefault();
2884
+ });
2885
+ addEventListener('keydown', e => {
2886
+ if (active !== 'tw' || e.metaKey || e.ctrlKey || e.altKey || e.target.tagName === 'INPUT') return;
2887
+ if (e.repeat && e.key === 'Enter') { e.preventDefault(); return; }
2888
+ if (twPress(e.key)) e.preventDefault();
2889
+ });
2890
+
2891
+ // ── load the embedded parts file ──
2892
+ // (Seahawk removed from this page — the model and its loader live in 'Iso 3D (with Seahawk) backup.html')
2893
+
2894
+ // ── animation ──
2895
+ let last = performance.now(), sig = '';
2896
+ const tmpV = new THREE.Vector2();
2897
+ const ease = t => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
2898
+ function frame(now) {
2899
+ const dt = Math.min(0.05, (now - last) / 1000); last = now;
2900
+ st.rpm = st.eng ? Math.min(1, st.rpm + 0.16 * dt) : Math.max(0, st.rpm - 0.07 * dt);
2901
+ st.mr += st.rpm * 7.5 * dt; // counter-clockwise from above; slowed on screen (real 27 rad/s strobes)
2902
+ st.tr += st.rpm * 7.5 * 4.6 * dt * 0.5;
2903
+ const goal = st.rpm >= 0.95 ? st.tAlt : 0;
2904
+ st.alt += Math.sign(goal - st.alt) * Math.min(Math.abs(goal - st.alt), 0.9 * dt);
2905
+ if (st.msg === 'waiting for rotor speed' && st.rpm >= 0.95) st.msg = '';
2906
+ st.doorT += ((st.door ? 1 : 0) - st.doorT) * Math.min(1, dt * 3);
2907
+ if (mainRotor) {
2908
+ mainRotor.quaternion.setFromAxisAngle(mrAxis, st.mr);
2909
+ tailRotor.quaternion.setFromAxisAngle(trAxis, st.tr);
2910
+ if (doorMesh) { const e = ease(st.doorT); doorMesh.position.z = -1.95 * e; doorMesh.position.x = -0.04 * Math.min(1, e * 4); }
2911
+ if (doorPortMesh) doorPortMesh.position.z = 1.2 * (1 - ease(st.doorT)); // closed = slid forward over the doorway
2912
+ heli.position.y = st.alt;
2913
+ }
2914
+ const lift = 1 - Math.min(1, st.alt / 9);
2915
+ shadowMat.uniforms.opacity.value = THEMES[theme].shadow * lift;
2916
+ spotMat.uniforms.opacity.value = st.lights ? 0.45 * (1 - Math.min(0.8, st.alt / 8)) : 0;
2917
+ spillMat.uniforms.opacity.value = st.cabin ? 0.4 * ease(st.doorT) * lift : 0;
2918
+ if (camAnim) {
2919
+ // while snapping back to the iso view, drop leftover drag momentum and pause the revolve so it lands exactly
2920
+ if (camAnim.t === 0) { camAnim.spin = controls.autoRotate; controls.autoRotate = false; controls.enableDamping = false; }
2921
+ camAnim.t = Math.min(1, camAnim.t + dt / 0.8); const k = ease(camAnim.t);
2922
+ const v = camAnim.v;
2923
+ controls.target.lerpVectors(camAnim.fromT, v.target, k);
2924
+ camera.position.lerpVectors(camAnim.from, v.target.clone().addScaledVector(v.dir, v.dist()), k);
2925
+ if (camera === ortho && camAnim.zoom0 !== undefined) { ortho.zoom = THREE.MathUtils.lerp(camAnim.zoom0, 1, k); ortho.updateProjectionMatrix(); }
2926
+ if (camAnim.t >= 1) { controls.enableDamping = true; controls.autoRotate = camAnim.spin; camAnim = null; }
2927
+ }
2928
+ if (active === 'tw') twTick(now, dt);
2929
+ if (active === 'dr') { DR.tick(now, dt); tickFlashes(now); }
2930
+ if (active === 'cs') CS.tick(now, dt);
2931
+ if (active === 'tm') TM.tick(now, dt);
2932
+ if (active === 'vm') VM.tick(now, dt);
2933
+ if (EXTRA[active]) EXTRA[active].tick(now, dt);
2934
+ const s = `${Math.round(st.rpm * RPM)}|${st.alt.toFixed(1)}|${st.msg}`;
2935
+ if (s !== sig) { sig = s; render(); }
2936
+ controls.update();
2937
+ applyWeight();
2938
+ const bufH = renderer.getDrawingBufferSize(tmpV).y, pxScale = camera === ortho ? bufH * ortho.zoom / orthoH : bufH / fovK();
2939
+ glowMats.forEach(m => m.uniforms.scale.value = pxScale);
2940
+ composer.render();
2941
+ requestAnimationFrame(frame);
2942
+ }
2943
+ addEventListener('resize', () => {
2944
+ persp.aspect = innerWidth / innerHeight; persp.updateProjectionMatrix(); orthoFrustum();
2945
+ renderer.setSize(innerWidth, innerHeight); composer.setSize(innerWidth, innerHeight);
2946
+ outlineMat.uniforms.res.value.copy(devRes()); lineMat.resolution.copy(devRes()); majorMat.resolution.copy(devRes()); accentLineMat.resolution.copy(devRes()); appliedKey = '';
2947
+ });
2948
+ render();
2949
+ document.querySelectorAll('.seg button').forEach(b => { b.hidden = !SHOW_FIGS.includes(b.dataset.fig); });
2950
+ if (SHOW_FIGS.length < 2) document.querySelector('.seg').hidden = true; // one figure: no tab bar
2951
+ setFigure(SHOW_FIGS.includes(load('fig')) ? load('fig') : SHOW_FIGS[0]);
2952
+ if (load('ortho') === '1') setOrtho(true, view().dist());
2953
+ // ── glow colour: same names and order as the 2D galleries (Ember is the 3D orange); shared choice with them via 'iso-accent' ──
2954
+ const ACCENTS3 = [['Ember', '#ff9d3c'], ['Cyan', '#22d3ee'], ['Lime', '#a3e635'], ['Violet', '#a78bfa'], ['Rose', '#fb7185'], ['Ice', '#dfe9ef']];
2955
+ const swEl = document.getElementById('swatches');
2956
+ swEl.innerHTML = ACCENTS3.map(([n, c]) => `<button role="radio" data-accent="${n.toLowerCase()}" aria-label="${n}" title="${n}" style="--sw:${c}"><i></i></button>`).join('');
2957
+ function pickAccent(k) {
2958
+ const a = ACCENTS3.find(x => x[0].toLowerCase() === k) || ACCENTS3[0]; k = a[0].toLowerCase();
2959
+ const old = accent.clone(); setAccent(a[1]);
2960
+ const swap = c => { if (c && c.isColor && c.equals(old)) c.copy(accent); };
2961
+ scene.traverse(o => [].concat(o.material || []).forEach(m => { // every part that carries the accent: emissive lights, accent lines, LEDs, glow sprites
2962
+ if (m.emissive && m.emissive.getHex() !== 0) m.emissive.copy(accent);
2963
+ swap(m.color); if (m.uniforms) Object.values(m.uniforms).forEach(u => swap(u.value)); }));
2964
+ accentLineMat.color.copy(accent); ledOnMat.color.copy(accent);
2965
+ themeHooks.forEach(f => f(theme)); // screens, labels and pools redraw with the new colour
2966
+ swEl.querySelectorAll('button').forEach(b => b.setAttribute('aria-checked', b.dataset.accent === k ? 'true' : 'false'));
2967
+ try { localStorage.setItem('iso-accent', k); } catch (e) {}
2968
+ try { if (parent !== window) parent.document.documentElement.style.setProperty('--accent', a[1]); } catch (e) {} // the /demo shell around this page follows the choice
2969
+ }
2970
+ swEl.addEventListener('click', e => { const b = e.target.closest('button[data-accent]'); if (b) { pickAccent(b.dataset.accent); b.blur(); } });
2971
+ addEventListener('storage', e => { if (e.key === 'iso-accent' && e.newValue) pickAccent(e.newValue); });
2972
+ { let k = null; try { k = localStorage.getItem('iso-accent'); } catch (e) {} pickAccent(k || 'ember'); }
2973
+ requestAnimationFrame(frame);
2974
+ </script>
2975
+ </body>
2976
+ </html>