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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/NOTICE +9 -0
- package/README.md +164 -2
- package/assets/ISO-GLOW-LICENSE +22 -0
- package/assets/figure.js +1253 -0
- package/assets/shell-3d.html +2976 -0
- package/bin/archivision.js +151 -0
- package/examples/shop/architecture.md +85 -0
- package/examples/shop/shop.arch.json +1273 -0
- package/lib/arch/ai.js +110 -0
- package/lib/arch/inputs.js +107 -0
- package/lib/arch/model.js +152 -0
- package/lib/arch/prompt.js +29 -0
- package/lib/arch/schema.js +80 -0
- package/lib/index.js +13 -0
- package/lib/layout.js +117 -0
- package/lib/page.js +35 -0
- package/lib/repo/automap.js +352 -0
- package/lib/repo/build.js +347 -0
- package/lib/repo/facts.js +160 -0
- package/lib/repo/roots.js +18 -0
- package/lib/repo/scan/compare.js +93 -0
- package/lib/repo/scan/discover.js +221 -0
- package/lib/repo/scan/git.js +61 -0
- package/lib/repo/scan/go.js +157 -0
- package/lib/repo/scan/index.js +124 -0
- package/lib/repo/scan/lambda.js +93 -0
- package/lib/repo/scan/manifests.js +338 -0
- package/lib/repo/scan/next.js +46 -0
- package/lib/repo/scan/playwright.js +20 -0
- package/lib/repo/scan/prisma.js +81 -0
- package/lib/repo/scan/python.js +188 -0
- package/lib/repo/scan/ripwire.js +231 -0
- package/lib/repo/scan/tsc.js +381 -0
- package/lib/scan-schema.js +106 -0
- package/package.json +25 -4
- package/python/scan.py +2047 -0
|
@@ -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>
|