File size: 37,300 Bytes
a2b07e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d9c12
a2b07e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d9c12
 
 
 
 
 
 
 
 
a2b07e0
 
 
 
 
 
 
 
5042fc2
a2b07e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5042fc2
a2b07e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585bfe0
a2b07e0
 
 
 
 
 
 
5042fc2
a2b07e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47d9c12
 
 
 
 
 
a2b07e0
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
// The VOID car (shape inspired by the McLaren 765LT), modelled in code: the body and canopy are lofted from side-profile curves
// (a rounded superellipse cross-section at every station), the wheel wells are carved out of
// the sides, and a blueprint of grid lines can be swept away by a clipping plane to "build" it.
import * as THREE from "three";
import { mergeGeometries } from "three/addons/utils/BufferGeometryUtils.js";

const X0 = -2.3, LEN = 4.6;                 // rear tip to nose, along +x
const WHEELS = [1.42, -1.38];               // front and rear axle x (a 2.8 m wheelbase)
const WR = 0.36, ARCH = 0.44, WELL = 0.62;  // wheel radius, arch radius, how far in the wells are carved

// cosine interpolation through [u, value] keys (u from 0 at the rear to 1 at the nose)
function curve(keys, u) {
  if (u <= keys[0][0]) return keys[0][1];
  for (let i = 1; i < keys.length; i++) {
    const [u1, v1] = keys[i], [u0, v0] = keys[i - 1];
    if (u <= u1) { const s = (1 - Math.cos(Math.PI * (u - u0) / (u1 - u0))) / 2; return v0 + (v1 - v0) * s; }
  }
  return keys[keys.length - 1][1];
}
const spow = (v, p) => Math.sign(v) * Math.pow(Math.abs(v), p);

// long-tail proportions: a tall, blunt tail, wide rear haunches, a pinched waist where the door
// scoop is, wide front fenders and a low, narrow nose
const BODY = {
  x0: X0, len: LEN, n: 6, tumble: .3,
  top: [[0, .84], [.03, .93], [.12, .96], [.25, .96], [.4, .9], [.55, .84], [.66, .8], [.78, .68], [.9, .54], [.97, .44], [1, .36]],
  bot: [[0, .3], [.04, .2], [.12, .16], [.88, .16], [.96, .2], [1, .28]],
  half: [[0, .86], [.05, .95], [.18, 1.0], [.35, .96], [.5, .91], [.62, .93], [.8, .99], [.93, .93], [1, .62]],
  ends: [.012, .06], fenders: true, scoop: true,
};
// a cab-forward teardrop: the screen starts far forward, the roof peaks over the doors and the
// glass runs back in a long taper over the engine
const CANOPY = {
  x0: -1.35, len: 2.35, n: 4, tumble: .42,
  top: [[0, .92], [.12, 1.0], [.3, 1.12], [.5, 1.19], [.65, 1.17], [.8, 1.06], [.92, .92], [1, .8]],
  bot: [[0, .72], [1, .72]],
  half: [[0, .42], [.2, .6], [.5, .72], [.75, .72], [.92, .64], [1, .5]],
  ends: [.15, .06], fenders: false,
};

// the other cars in the garage, lofted the same way
export const MODELS = {
  hydra: { name: "Hydra LTE242", body: BODY, canopy: CANOPY },
  vandal: { name: "Vandal GT", note: "Muscle fastback", body: { x0: X0, len: LEN, n: 8, tumble: .2, fenders: true, scoop: false, ends: [.02, .03],
    top: [[0, .92], [.06, .98], [.2, 1.0], [.35, .98], [.5, .95], [.62, .93], [.75, .9], [.9, .84], [.98, .74], [1, .66]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]],
    half: [[0, .86], [.06, .93], [.2, .96], [.5, .92], [.8, .94], [.95, .9], [1, .72]] },
    canopy: { x0: -1.25, len: 2.0, n: 4, tumble: .35, ends: [.2, .08], fenders: false, top: [[0, .98], [.15, 1.05], [.35, 1.25], [.6, 1.28], [.8, 1.2], [.95, 1.0], [1, .95]], bot: [[0, .88], [1, .88]], half: [[0, .6], [.3, .72], [.7, .72], [1, .6]] } },
  wraith: { name: "Wraith X", note: "Wedge hypercar", body: { x0: X0, len: LEN, n: 5, tumble: .32, fenders: true, scoop: true, ends: [.01, .04],
    top: [[0, .8], [.05, .86], [.2, .88], [.4, .84], [.6, .74], [.8, .6], [.95, .45], [1, .34]], bot: [[0, .26], [.04, .16], [.12, .13], [.88, .13], [.96, .16], [1, .22]],
    half: [[0, .9], [.05, 1.0], [.2, 1.02], [.4, .96], [.6, .95], [.82, .98], [.95, .9], [1, .55]] },
    canopy: { x0: -.9, len: 2.3, n: 4, tumble: .45, ends: [.2, .05], fenders: false, top: [[0, .82], [.2, 1.0], [.5, 1.07], [.75, 1.0], [.95, .8], [1, .7]], bot: [[0, .68], [1, .62]], half: [[0, .38], [.3, .62], [.6, .66], [.9, .55], [1, .45]] } },
  titan: { name: "Titan RS", note: "Super SUV", body: { x0: X0, len: LEN, n: 10, tumble: .12, fenders: true, scoop: false, ends: [.02, .03],
    top: [[0, 1.15], [.05, 1.2], [.6, 1.2], [.75, 1.12], [.9, 1.0], [1, .9]], bot: [[0, .38], [.05, .28], [.12, .26], [.88, .26], [.95, .28], [1, .36]], half: [[0, .92], [.05, .98], [.5, 1.0], [.95, .98], [1, .85]] },
    canopy: { x0: -2.15, len: 3.3, n: 10, tumble: .15, ends: [.02, .12], fenders: false, top: [[0, 1.15], [.05, 1.62], [.7, 1.66], [.85, 1.5], [1, 1.2]], bot: [[0, 1.1], [1, 1.1]], half: [[0, .86], [.5, .9], [1, .86]] } },
  cab: { name: "Big Apple Cab", note: "A yellow cab with a jet kit", body: { x0: X0, len: LEN, n: 9, tumble: .15, fenders: true, scoop: false, ends: [.02, .03],
    top: [[0, .9], [.05, .95], [.25, .96], [.35, .95], [.65, .93], [.8, .9], [.95, .84], [1, .76]], bot: [[0, .32], [.05, .22], [.12, .18], [.88, .18], [.96, .22], [1, .3]], half: [[0, .86], [.05, .92], [.5, .93], [.95, .9], [1, .75]] },
    canopy: { x0: -1.35, len: 2.5, n: 8, tumble: .2, ends: [.1, .1], fenders: false, top: [[0, .95], [.15, 1.35], [.75, 1.38], [.9, 1.2], [1, .95]], bot: [[0, .9], [1, .9]], half: [[0, .75], [.5, .82], [1, .75]] } },
};

// a (nu+1) x nv grid of points on the lofted surface
function loft(spec, nu, nv) {
  const P = new Float32Array((nu + 1) * nv * 3);
  for (let i = 0; i <= nu; i++) {
    const u = i / nu, x = spec.x0 + spec.len * u;
    const t = curve(spec.top, u), b = curve(spec.bot, u), w = curve(spec.half, u);
    const c = (t + b) / 2, h = (t - b) / 2;
    const e = Math.pow(Math.max(0, Math.min(1, u / spec.ends[0], (1 - u) / spec.ends[1])), .35);
    for (let j = 0; j < nv; j++) {
      const th = (j / nv) * Math.PI * 2, cz = Math.cos(th), sy = Math.sin(th);
      let z = w * spow(cz, 2 / spec.n), y = h * spow(sy, 2 / spec.n);
      if (sy > 0) z *= 1 - spec.tumble * sy * sy;
      if (spec.fenders && sy > 0) for (const xw of WHEELS) y += (xw > 0 ? .2 : .14) * Math.exp(-(((x - xw) / .55) ** 2)) * Math.abs(cz) ** 3;
      z *= e; y = c + y * e;
      if (spec.scoop && Math.abs(cz) > .3) {          // the big intake carved into each door, deepest at its front
        const xr = (x + .35) / .62, yr = (y - .6) / .17;
        if (Math.abs(xr) < 1 && Math.abs(yr) < 1) z *= 1 - .17 * (1 - xr * xr) * Math.sqrt(1 - yr * yr) * (1.15 - .3 * xr);
      }
      if (spec.fenders) for (const xw of WHEELS) {
        if ((x - xw) ** 2 + (y - WR) ** 2 < ARCH * ARCH && Math.abs(z) > WELL) z = Math.sign(z) * WELL;
      }
      const k = (i * nv + j) * 3; P[k] = x; P[k + 1] = y; P[k + 2] = z;
    }
  }
  return { P, nu, nv };
}

function surface({ P, nu, nv }) {
  const idx = [];
  for (let i = 0; i < nu; i++) for (let j = 0; j < nv; j++) {
    const a = i * nv + j, b = i * nv + (j + 1) % nv, c = (i + 1) * nv + j, d = (i + 1) * nv + (j + 1) % nv;
    idx.push(a, c, b, b, c, d);
  }
  const g = new THREE.BufferGeometry();
  g.setAttribute("position", new THREE.BufferAttribute(P, 3));
  g.setIndex(idx); g.computeVertexNormals();
  return g;
}

// blueprint: every few rings across the car, and every few lines along it
function gridLines({ P, nu, nv }, ringStep, lineStep) {
  const pts = [], at = (i, j) => { const k = (i * nv + j) * 3; return [P[k], P[k + 1], P[k + 2]]; };
  for (let i = 0; i <= nu; i += ringStep) for (let j = 0; j < nv; j++) pts.push(...at(i, j), ...at(i, (j + 1) % nv));
  for (let j = 0; j < nv; j += lineStep) for (let i = 0; i < nu; i++) pts.push(...at(i, j), ...at(i + 1, j));
  const g = new THREE.BufferGeometry();
  g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
  return g;
}

export function buildCar(model = "hydra") {
  const M = MODELS[model] || MODELS.hydra, BS = M.body, CS = M.canopy;
  const car = new THREE.Group();
  const solid = new THREE.Group(), wire = new THREE.Group();
  car.add(solid, wire);

  // the reveal: solid shows behind the scan plane, blueprint in front of it
  const solidClip = new THREE.Plane(new THREE.Vector3(-1, 0, 0), -3);
  const wireClip = new THREE.Plane(new THREE.Vector3(1, 0, 0), 3);
  const clip = [solidClip];

  const flakes = (() => {                                     // a fine noise normal map: the metallic flake that sparkles under the clear coat
    const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"), img = g.createImageData(256, 256);
    for (let i = 0; i < 256 * 256; i++) { const a = Math.random() * 6.28, r = Math.random() * .55; img.data[i * 4] = 128 + Math.cos(a) * r * 127; img.data[i * 4 + 1] = 128 + Math.sin(a) * r * 127; img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255; }
    g.putImageData(img, 0, 0); const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(30, 30); return t;
  })();
  const paint = new THREE.MeshPhysicalMaterial({ color: 0x0d0d10, metalness: .6, roughness: .32, normalMap: flakes, normalScale: new THREE.Vector2(.12, .12), clearcoat: 1, clearcoatRoughness: .03, envMapIntensity: 1.5, clippingPlanes: clip });
  const glass = new THREE.MeshPhysicalMaterial({ color: 0x0a0d12, metalness: .2, roughness: .02, clearcoat: 1, envMapIntensity: 1.8, transparent: true, opacity: .6, depthWrite: false, clippingPlanes: clip });  // tinted, so you can see the cabin
  const carbon = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, metalness: .4, roughness: .55, clippingPlanes: clip });
  const rubber = new THREE.MeshStandardMaterial({ color: 0x0a0a0a, roughness: .9, clippingPlanes: clip });
  const rim = new THREE.MeshStandardMaterial({ color: 0x121215, metalness: 1, roughness: .16, envMapIntensity: 1.6, clippingPlanes: clip });
  const caliper = new THREE.MeshStandardMaterial({ color: 0xc1121f, metalness: .3, roughness: .4, clippingPlanes: clip });
  const front = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xe8fbff).multiplyScalar(3), toneMapped: false, clippingPlanes: clip });
  const rear = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xff2a3a).multiplyScalar(2.6), toneMapped: false, clippingPlanes: clip });
  const trim = new THREE.MeshPhysicalMaterial({ color: 0xc1121f, metalness: .4, roughness: .3, clearcoat: 1, clippingPlanes: clip });   // the red accents
  const ti2 = new THREE.MeshStandardMaterial({ color: 0x8d9099, metalness: 1, roughness: .25, side: THREE.DoubleSide, clippingPlanes: clip });
  const pipeOf = () => new THREE.CylinderGeometry(.06, .06, .2, 20, 1, true).rotateZ(Math.PI / 2);
  const lineMat = new THREE.LineBasicMaterial({ color: 0xeef0f5, transparent: true, opacity: .75, clippingPlanes: [wireClip] });

  const bodyHi = loft(BS, 220, 96), canHi = loft(CS, 90, 64);
  const bodyMesh = new THREE.Mesh(surface(bodyHi), paint), canMesh = new THREE.Mesh(surface(canHi), glass);
  solid.add(bodyMesh, canMesh);
  wire.add(new THREE.LineSegments(gridLines(loft(BS, 110, 48), 5, 4), lineMat), new THREE.LineSegments(gridLines(loft(CS, 40, 32), 5, 4), lineMat));
  const topAt = u => curve(BS.top, u), halfAt = u => curve(BS.half, u);

  const add = (geo, mat, x, y, z, parent = solid) => { const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); parent.add(m); return m; };

  // details: eye-socket headlights, hood vents, splitter, side blades and sills, flying
  // buttresses, engine louvers, a full-width wing, a mesh tail with four high exhausts, a diffuser
  const beam = (a, b, w, h, mat) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, a.distanceTo(b), h), mat); m.position.copy(a).add(b).multiplyScalar(.5); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize()); solid.add(m); return m; };
  const exhausts = [];
  if (model === "hydra") {
    for (const s of [-1, 1]) {
      const sock = add(new THREE.SphereGeometry(.2, 28, 14), carbon, 1.97, .52, s * .62);
      sock.scale.set(.7, .42, 1.15); sock.rotation.y = s * .55; sock.rotation.x = s * .25;
      const led = add(new THREE.BoxGeometry(.03, .022, .3), front, 2.04, .56, s * .64); led.rotation.y = s * .6; led.rotation.x = s * -.35;
      add(new THREE.BoxGeometry(.03, .02, .12), front, 2.07, .49, s * .5).rotation.y = s * .25;
      const vent = add(new THREE.BoxGeometry(.36, .012, .24), carbon, 1.5, .64, s * .3); vent.rotation.z = -.43;
      const blade = add(new THREE.BoxGeometry(.62, .07, .02), carbon, .88, .4, s * .975); blade.rotation.z = .22;
      add(new THREE.BoxGeometry(2.05, .07, .03), carbon, -.05, .21, s * .955);
      add(new THREE.BoxGeometry(2.05, .012, .034), trim, -.05, .248, s * .956);                // red pinstripe on the sill
      beam(new THREE.Vector3(-.45, 1.1, s * .6), new THREE.Vector3(-2.05, .95, s * .86), .07, .11, paint);
      const tl = add(new THREE.BoxGeometry(.03, .03, .56), rear, -2.29, .82, s * .52); tl.rotation.x = s * .05;
      add(new THREE.BoxGeometry(.03, .2, .03), rear, -2.28, .72, s * .8);
    }
    for (let k = 0; k < 6; k++) add(new THREE.BoxGeometry(.06, .015, .92), carbon, -1.42 - k * .11, .975, 0);
    add(new THREE.BoxGeometry(.3, .03, 1.62), carbon, 2.06, .175, 0);
    add(new THREE.BoxGeometry(.02, .032, 1.62), trim, 2.2, .175, 0);                           // red splitter lip
    const wing = add(new THREE.BoxGeometry(.38, .03, 1.94), carbon, -2.12, 1.02, 0); wing.rotation.z = .12;
    for (const z of [-.97, .97]) add(new THREE.BoxGeometry(.42, .16, .02), trim, -2.1, .98, z);
    add(new THREE.BoxGeometry(.04, .32, 1.5), carbon, -2.3, .58, 0);
    for (let k = -3; k <= 3; k++) add(new THREE.BoxGeometry(.6, .2, .025), carbon, -2.06, .25, k * .2).rotation.z = -.42;
    const ti = new THREE.MeshStandardMaterial({ color: 0x6d7079, metalness: 1, roughness: .3, side: THREE.DoubleSide, clippingPlanes: clip });
    const pipe = new THREE.CylinderGeometry(.05, .05, .22, 24, 1, true).rotateZ(Math.PI / 2), soot = new THREE.CircleGeometry(.045, 20).rotateY(-Math.PI / 2);
    for (const y of [.6, .72]) for (const z of [-.065, .065]) { add(pipe, ti, -2.28, y, z); add(soot, rubber, -2.37, y, z); }
    for (const y of [.6, .72]) for (const z of [-.065, .065]) exhausts.push([-2.38, y, z]);
  } else {
    // shared details: headlights, tail lights, a splitter, sills, mirrors, twin exhausts
    const nose = X0 + LEN, ny = topAt(.96) - .1, rearY = topAt(.03) - .12;
    for (const s of [-1, 1]) {
      add(new THREE.BoxGeometry(.05, .07, .36), front, nose - .1, ny, s * halfAt(.94) * .62).rotation.y = s * .35;
      add(new THREE.BoxGeometry(.05, .08, .5), rear, X0 + .03, rearY, s * halfAt(.04) * .62);
      add(new THREE.BoxGeometry(2.1, .07, .03), carbon, 0, curve(BS.bot, .5) + .05, s * (halfAt(.5) - .02));
      const mir = add(new THREE.BoxGeometry(.12, .08, .16), paint, CS.x0 + CS.len * .82, curve(CS.bot, .8) + .06, s * (curve(CS.half, .8) + .1)); mir.rotation.y = s * .2;
      add(pipeOf(), ti2, X0 + .02, curve(BS.bot, .05) + .1, s * .35); exhausts.push([X0 - .08, curve(BS.bot, .05) + .1, s * .35]);
    }
    add(new THREE.BoxGeometry(.3, .03, halfAt(.97) * 1.6), carbon, nose - .2, curve(BS.bot, .97) + .02, 0);
    add(new THREE.BoxGeometry(.02, .032, halfAt(.97) * 1.6), trim, nose - .06, curve(BS.bot, .97) + .02, 0);
    add(new THREE.BoxGeometry(.02, .05, halfAt(.02) * 1.4), rear, X0 + .01, rearY + .07, 0);
    if (model === "cab") {                                    // the roof sign and the checker stripe
      const signT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#fff6d0"; g.fillRect(0, 0, 256, 64); g.fillStyle = "#111"; g.font = "900 40px sans-serif"; g.textAlign = "center"; g.fillText("TAXI", 128, 48); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
      const sign = add(new THREE.BoxGeometry(.55, .22, .9), new THREE.MeshBasicMaterial({ map: signT, toneMapped: false, color: new THREE.Color(1.6, 1.6, 1.6), clippingPlanes: clip }), CS.x0 + CS.len * .45, curve(CS.top, .45) + .1, 0);
      sign.rotation.y = Math.PI / 2;
    }
  }
  add(new THREE.BoxGeometry(3.6, .03, 1.7), carbon, -.1, .16, 0); // flat floor

  // wheels: a rounded tyre, a deep-dish rim with twin spokes and a centre lock, a brake disc
  // (all spinning) and a fixed caliper
  const wheels = [];
  const V2 = (x, y) => new THREE.Vector2(x, y);
  const tyreG = new THREE.LatheGeometry([V2(.265, -.15), V2(.32, -.156), V2(.35, -.145), V2(.362, -.115), V2(WR + .004, 0), V2(.362, .115), V2(.35, .145), V2(.32, .156), V2(.265, .15)], 72).rotateX(Math.PI / 2);
  const barrelG = new THREE.LatheGeometry([V2(.262, .15), V2(.27, .13), V2(.268, -.13), V2(.24, -.14)], 64).rotateX(Math.PI / 2);
  const lipG = new THREE.TorusGeometry(.266, .009, 8, 64);
  const discG = new THREE.CylinderGeometry(.2, .2, .03, 48).rotateX(Math.PI / 2), hatG = new THREE.CylinderGeometry(.09, .09, .06, 32).rotateX(Math.PI / 2);
  const nutG = new THREE.CylinderGeometry(.045, .045, .05, 6).rotateX(Math.PI / 2);
  const spokeG = new THREE.BoxGeometry(.026, .2, .028).translate(0, .155, 0);
  const disc = new THREE.MeshStandardMaterial({ color: 0x5a5c62, metalness: .8, roughness: .45, clippingPlanes: clip, emissive: 0xff4a10, emissiveIntensity: 0 });     // glows when the brakes are hammered
  const wireWheel = new THREE.EdgesGeometry(new THREE.CylinderGeometry(WR, WR, .3, 28).rotateX(Math.PI / 2), 20);
  for (const xw of WHEELS) for (const s of [-1, 1]) {
    const spin = new THREE.Group(); spin.position.set(xw, WR, s * .83); solid.add(spin);
    spin.add(new THREE.Mesh(tyreG, rubber), new THREE.Mesh(barrelG, carbon));
    const lip = new THREE.Mesh(lipG, rim); lip.position.z = s * .14; spin.add(lip);
    const d = new THREE.Mesh(discG, disc); d.position.z = -s * .04; spin.add(d);
    const hat = new THREE.Mesh(hatG, rim); hat.position.z = s * .04; spin.add(hat);
    const nut = new THREE.Mesh(nutG, caliper); nut.position.z = s * .12; spin.add(nut);
    for (let k = 0; k < 5; k++) for (const off of [-.11, .11]) {            // five twin spokes, dished in toward the hub
      const sp = new THREE.Mesh(spokeG, rim); sp.position.z = s * .1;
      sp.rotation.z = k / 5 * Math.PI * 2 + off; sp.rotation.x = s * .12; spin.add(sp);
    }
    const cal = add(new THREE.BoxGeometry(.24, .13, .08), caliper, xw - .16, WR + .13, s * .7); cal.rotation.z = .65;
    const wl = new THREE.LineSegments(wireWheel, lineMat); wl.position.set(xw, WR, s * .83); wire.add(wl);
    wheels.push(spin);
  }

  // the glowing scan plane that sweeps along the car during the build
  const scanTex = (() => {
    const c = document.createElement("canvas"); c.width = 4; c.height = 128;
    const g = c.getContext("2d"), gr = g.createLinearGradient(0, 0, 0, 128);
    gr.addColorStop(0, "rgba(255,255,255,0)"); gr.addColorStop(.5, "rgba(255,255,255,1)"); gr.addColorStop(1, "rgba(255,255,255,0)");
    g.fillStyle = gr; g.fillRect(0, 0, 4, 128); return new THREE.CanvasTexture(c);
  })();
  const scan = new THREE.Mesh(new THREE.PlaneGeometry(2.6, 1.6), new THREE.MeshBasicMaterial({ map: scanTex, transparent: true, opacity: .0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, toneMapped: false }));
  scan.rotation.y = Math.PI / 2; scan.rotation.z = Math.PI / 2; scan.position.y = .7; car.add(scan);

  // flight kit: four thrusters that swing from straight down (lift-off) to straight back (cruise),
  // and two swept wings that slide out of the sides, with nav lights on the tips
  const housing = new THREE.MeshStandardMaterial({ color: 0x18181c, metalness: .85, roughness: .3 });
  const core = new THREE.MeshBasicMaterial({ color: new THREE.Color(0xdff6ff).multiplyScalar(4), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
  const outer = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4f8dff).multiplyScalar(2.2), transparent: true, opacity: .55, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false, side: THREE.DoubleSide });
  const nozG = new THREE.CylinderGeometry(.12, .16, .26, 24, 1, true), ringG = new THREE.TorusGeometry(.15, .025, 8, 28).rotateX(Math.PI / 2);
  const flameG = new THREE.ConeGeometry(.14, 1, 20, 1, true).rotateX(Math.PI).translate(0, -.5, 0);
  const thrusters = [];
  for (const [x, y] of [[.9, .3], [-.95, .38]]) for (const s of [-1, 1]) {
    const pivot = new THREE.Group(); pivot.position.set(x, y, s * 1.0); car.add(pivot);
    const noz = new THREE.Mesh(nozG, housing); noz.position.y = -.05;
    const ring = new THREE.Mesh(ringG, front); ring.position.y = -.18;
    const fo = new THREE.Mesh(flameG, outer), fc = new THREE.Mesh(flameG, core);
    fo.position.y = fc.position.y = -.18; fc.scale.set(.45, 1, .45);
    pivot.add(noz, ring, fo, fc); pivot.scale.setScalar(0);
    thrusters.push({ pivot, fo, fc, seed: Math.random() * 10 });
  }
  const glowLight = new THREE.PointLight(0x6aa8ff, 0, 9, 2); glowLight.position.set(0, -.4, 0); car.add(glowLight);

  const wingShape = new THREE.Shape([[.7, 0], [-1.05, 0], [-1.35, 2.25], [-.75, 2.25]].map(([a, b]) => new THREE.Vector2(a, b)));
  const wingG = new THREE.ExtrudeGeometry(wingShape, { depth: .05, bevelEnabled: true, bevelThickness: .015, bevelSize: .02, bevelSegments: 2 }).rotateX(-Math.PI / 2);
  const wings = [];
  for (const s of [-1, 1]) {
    const w = new THREE.Mesh(wingG, paint); w.position.set(-.15, .6, s * .85); w.scale.set(1, 1, 0); car.add(w);
    const nav = new THREE.Mesh(new THREE.SphereGeometry(.05, 12, 8), new THREE.MeshBasicMaterial({ color: new THREE.Color(s > 0 ? 0x22ff66 : 0xff2233).multiplyScalar(3), toneMapped: false }));
    nav.position.set(-1.1, .04, -2.25); w.add(nav);
    const edge = new THREE.Mesh(new THREE.BoxGeometry(1.8, .02, .02), front); edge.position.set(-.18, .06, -.02); w.add(edge);
    wings.push({ w, s, nav });
  }

  // fewer draw calls: the fixed detail parts (lights, vents, splitter, sills...) and each wheel's parts are merged per material
  const mergeStatic = (grp, keep) => {
    const by = new Map();
    for (const o of [...grp.children]) {
      if (!o.isMesh || keep.has(o) || o.children.length || o.material.map) continue;
      o.updateMatrix(); const g = o.geometry.index ? o.geometry.toNonIndexed() : o.geometry.clone(); g.applyMatrix4(o.matrix);
      for (const k of Object.keys(g.attributes)) if (k !== "position" && k !== "normal") g.deleteAttribute(k);
      if (!g.attributes.normal) g.computeVertexNormals();
      if (!by.has(o.material)) by.set(o.material, []); by.get(o.material).push(g); grp.remove(o);
    }
    for (const [m, list] of by) { const mesh = new THREE.Mesh(mergeGeometries(list), m); mesh.castShadow = true; grp.add(mesh); }
  };
  mergeStatic(solid, new Set([bodyMesh, canMesh]));
  for (const w of wheels) mergeStatic(w, new Set());
  // ---- garage parts: spoilers, liveries, exhaust flames ----
  const spoilers = new THREE.Group(); solid.add(spoilers);
  // active aero: a carbon wing stowed in the tail that rises at speed and flips up as an air brake under hard braking
  const aero = new THREE.Group(), aeroWing = new THREE.Group(); solid.add(aero); aero.add(aeroWing);
  { const y0 = topAt(.05), w = Math.max(1.4, halfAt(.05) * 1.9), x = X0 + .3;
    aero.position.set(x, y0 - .02, 0);
    const blade = new THREE.Mesh(new THREE.BoxGeometry(.42, .03, w), carbon); blade.position.x = -.06; aeroWing.add(blade);
    for (const z of [-1, 1]) { const ep = new THREE.Mesh(new THREE.BoxGeometry(.46, .14, .02), carbon); ep.position.set(-.06, .03, z * w / 2); aeroWing.add(ep); }
    const ledG = new THREE.Mesh(new THREE.BoxGeometry(.02, .012, w * .9), rear); ledG.position.set(-.27, 0, 0); aeroWing.add(ledG);
    for (const z of [-.3, .3]) { const st = new THREE.Mesh(new THREE.BoxGeometry(.05, .4, .04).translate(0, -.2, 0), carbon); st.position.set(0, 0, z * w); aeroWing.add(st); }
    aero.userData = { lift: 0, tilt: 0 }; }
  function setSpoiler(k) {
    spoilers.clear();
    const y0 = topAt(.04), w = Math.max(1.5, halfAt(.05) * 2), x = X0 + .18;
    const part = (geo, m, px, py, pz, rz = 0) => { const o = new THREE.Mesh(geo, m); o.position.set(px, py, pz); o.rotation.z = rz; spoilers.add(o); o.castShadow = true; return o; };
    if (k === "ducktail") part(new THREE.BoxGeometry(.32, .05, w * .9), paint, x + .05, y0 + .04, 0, .28);
    if (k === "gt") { part(new THREE.BoxGeometry(.45, .035, w + .25), carbon, x - .05, y0 + .42, 0, .1); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.08, .42, .03), carbon, x, y0 + .21, z * w * .3); part(new THREE.BoxGeometry(.5, .2, .02), trim, x - .05, y0 + .42, z * (w + .25) / 2); } }
    if (k === "swan") { part(new THREE.BoxGeometry(.5, .04, w + .3), carbon, x - .1, y0 + .55, 0, .14); for (const z of [-1, 1]) { part(new THREE.BoxGeometry(.06, .6, .03), carbon, x + .05, y0 + .3, z * w * .28, -.35); part(new THREE.BoxGeometry(.55, .24, .02), trim, x - .1, y0 + .55, z * (w + .3) / 2); } }
    if (k === "rally") { part(new THREE.BoxGeometry(.4, .04, w * .95), paint, x + .25, topAt(.12) + .12, 0, .05); for (const z of [-1, 1]) part(new THREE.BoxGeometry(.32, .14, .02), paint, x + .25, topAt(.12) + .06, z * w * .46); }
    mergeStatic(spoilers, new Set());
  }
  const liveryMat = new THREE.MeshPhysicalMaterial({ color: 0xffffff, roughness: .3, clearcoat: 1, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2 });
  const livery = new THREE.Group(); solid.add(livery);
  const LP = bodyHi.P, LNV = bodyHi.nv, LNU = bodyHi.nu, pt = (i, j) => { const k = (i * LNV + ((j % LNV) + LNV) % LNV) * 3; return [LP[k], LP[k + 1], LP[k + 2]]; };
  const patch = (i0, i1, js, push) => {                 // a strip of the body surface, lifted a hair off the paint
    const pos = [];
    for (let i = i0; i < i1; i++) for (let a = 0; a < js.length - 1; a++) {
      const q = [pt(i, js[a]), pt(i + 1, js[a]), pt(i + 1, js[a + 1]), pt(i, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]);
      pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]);
    }
    const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.computeVertexNormals();
    livery.add(new THREE.Mesh(g, liveryMat));
  };
  const T4 = LNV / 4;
  function setLivery(k, hex) {
    livery.clear(); liveryMat.color.set(hex || "#ffffff");
    if (k === "stripes") { patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]); }
    if (k === "center") patch(4, LNU - 2, [T4 - 2, T4 - 1, T4, T4 + 1, T4 + 2], [0, .006, 0]);
    if (k === "side") { patch(20, LNU - 30, [-1, 0, 1], [0, 0, .006]); patch(20, LNU - 30, [LNV / 2 - 1, LNV / 2, LNV / 2 + 1], [0, 0, .006]); }
    if (k === "race") {
      patch(4, LNU - 2, [T4 - 5, T4 - 4, T4 - 3], [0, .006, 0]); patch(4, LNU - 2, [T4 + 3, T4 + 4, T4 + 5], [0, .006, 0]);
      const c = document.createElement("canvas"); c.width = c.height = 128; const g = c.getContext("2d"); g.fillStyle = "#fff"; g.beginPath(); g.arc(64, 64, 60, 0, 7); g.fill(); g.fillStyle = "#111"; g.font = "900 70px sans-serif"; g.textAlign = "center"; g.fillText("42", 64, 89);
      const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace;
      for (const side of [0, LNV / 2]) { const [x, y, z] = pt(Math.round(LNU * .5), side); const d = new THREE.Mesh(new THREE.CircleGeometry(.2, 24), new THREE.MeshBasicMaterial({ map: t, transparent: true, clippingPlanes: clip })); d.position.set(x, y + .02, z + Math.sign(z) * .012); d.rotation.y = z > 0 ? 0 : Math.PI; livery.add(d); }
    }
    mergeStatic(livery, new Set());
  }
  const flameMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(0x4fa8ff).multiplyScalar(3), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false });
  const flames = exhausts.map(([x, y, z]) => { const f = new THREE.Mesh(new THREE.ConeGeometry(.07, 1, 12, 1, true).rotateZ(Math.PI / 2).translate(-.5, 0, 0), flameMat); f.position.set(x, y, z); f.visible = false; solid.add(f); return f; });

  const bodyBase = bodyMesh.geometry.attributes.position.array.slice(), canBase = canMesh.geometry.attributes.position.array.slice();
  const wear = { total: 0, burn: 0, cracked: false }; let paintHex = "#0d0d10";
  const crackTex = () => {                                  // a starburst of cracks on the screen
    const c = document.createElement("canvas"); c.width = c.height = 256; const g = c.getContext("2d"); g.fillStyle = "#9aa4ae"; g.fillRect(0, 0, 256, 256);
    g.strokeStyle = "rgba(255,255,255,.95)"; g.lineWidth = 1.2;
    for (let n = 0; n < 3; n++) { const ox = 60 + Math.random() * 140, oy = 60 + Math.random() * 140; for (let k = 0; k < 14; k++) { let x = ox, y = oy, a = Math.random() * 6.28; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 7; j++) { a += (Math.random() - .5) * .7; x += Math.cos(a) * 14; y += Math.sin(a) * 14; g.lineTo(x, y); } g.stroke(); } for (let r = 8; r < 50; r += 12) { g.beginPath(); g.arc(ox, oy, r, 0, 6.3); g.stroke(); } }
    const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(1.5, 1.5); return t;
  };
  // ---- finishing details: panel seams (doors, hood), LED light strips, number plates ----
  {
    const seamMat = new THREE.MeshBasicMaterial({ color: 0x050505, side: THREE.DoubleSide, clippingPlanes: clip, polygonOffset: true, polygonOffsetFactor: -2, transparent: true, opacity: .85 });
    const seamPatch = (i0, js, push) => {
      const pos = [];
      for (let a = 0; a < js.length - 1; a++) { const q = [pt(i0, js[a]), pt(i0 + 1, js[a]), pt(i0 + 1, js[a + 1]), pt(i0, js[a + 1])].map(([x, y, z]) => [x, y + push[1], z + Math.sign(z) * push[2]]); pos.push(...q[0], ...q[1], ...q[2], ...q[0], ...q[2], ...q[3]); }
      const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); solid.add(new THREE.Mesh(g, seamMat));
    };
    const sideJ = (c) => Array.from({ length: 11 }, (_, k) => c - 5 + k);           // the side of the car from the sill up to the waist
    for (const u of [.36, .55]) { const i = Math.round(LNU * u); seamPatch(i, sideJ(0), [0, 0, .004]); seamPatch(i, sideJ(LNV / 2), [0, 0, .004]); }
    const hoodI = Math.round(LNU * .8); seamPatch(hoodI, Array.from({ length: 13 }, (_, k) => T4 - 6 + k), [0, .004, 0]);
    // LED strips: a light bar across the nose and a full-width tail bar
    const nose = X0 + LEN;
    const drl = new THREE.Mesh(new THREE.BoxGeometry(.03, .025, halfAt(.93) * 1.5), front); drl.position.set(nose - .14, topAt(.93) - .02, 0); solid.add(drl);
    const tail = new THREE.Mesh(new THREE.BoxGeometry(.03, .04, halfAt(.03) * 1.7), rear); tail.position.set(X0 + .02, topAt(.03) - .07, 0); solid.add(tail);
    // number plates
    const plateT = (() => { const c = document.createElement("canvas"); c.width = 256; c.height = 64; const g = c.getContext("2d"); g.fillStyle = "#f4f4f0"; g.fillRect(0, 0, 256, 64); g.strokeStyle = "#111"; g.lineWidth = 4; g.strokeRect(3, 3, 250, 58); g.fillStyle = "#111"; g.font = "900 40px Inter, sans-serif"; g.textAlign = "center"; g.fillText("VOID·242", 128, 47); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();
    const plateM = new THREE.MeshStandardMaterial({ map: plateT, roughness: .4, metalness: .2, clippingPlanes: clip });
    const pf = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pf.position.set(nose + .005, curve(BS.bot, .98) + .14, 0); pf.rotation.y = Math.PI / 2; solid.add(pf);
    const pr = new THREE.Mesh(new THREE.PlaneGeometry(.52, .13), plateM); pr.position.set(X0 - .005, curve(BS.bot, .03) + .22, 0); pr.rotation.y = -Math.PI / 2; solid.add(pr);
  }
  return {
    group: car, wheels, clip, glowLight, materials: { carbon, front }, model, name: M.name,
    setSpoiler, setLivery,
    setTint(k) { const T = { light: [0x1a2a3a, .32, .1], dark: [0x0a0d12, .6, .1], limo: [0x030405, .9, .1], mirror: [0x9aa4b0, .88, .95] }[k] || [0x0a0d12, .6, .1]; glass.color.setHex(T[0]); glass.opacity = T[1]; glass.metalness = T[2]; },
    setFlameColor(hex) { flameMat.color.set(hex).multiplyScalar(3); },
    setFlame(power, time) { for (const f of flames) { f.visible = power > .02; f.scale.set(.4 + power * (1.1 + .3 * Math.sin(time * 60 + f.position.z * 9)), 1 + power * .6, 1 + power * .6); } },
    // power 0..1 is flame size, angle 0 = pointing down, 1 = pointing back, deploy 0..1 brings them out
    setThrust(power, angle, deploy, time) {
      for (const t of thrusters) {
        t.pivot.scale.setScalar(Math.max(.0001, deploy)); t.pivot.visible = deploy > .005;
        t.pivot.rotation.z = -angle * Math.PI / 2;
        const f = power * (.82 + .18 * Math.sin(time * 41 + t.seed) * Math.sin(time * 23 + t.seed * 3));
        t.fo.scale.set(1 + power * .3, Math.max(.001, f * 2.6), 1 + power * .3);
        t.fc.scale.set(.45, Math.max(.001, f * 1.5), .45);
        t.fo.visible = t.fc.visible = power > .01;
      }
      glowLight.intensity = power * 30;
    },
    setWings(t, time) {
      for (const { w, s, nav } of wings) {
        w.scale.z = Math.max(.0001, t) * -s; w.rotation.x = s * (1 - t) * .4; w.visible = t > .01;
        nav.visible = t > .6 && (Math.sin(time * 6 + s) > -.2);
      }
    },
    setReveal(t) {
      const x = t >= .999 ? 1e5 : -2.7 + 5.4 * t;   // once built, never clip (the car drives far from the origin)
      solidClip.constant = x; wireClip.constant = -x;
      scan.position.x = x; scan.material.opacity = t > .01 && t < .99 ? .55 : 0;
      wire.visible = t < .999;
    },
    setPaint(hex) { paint.color.set(hex); paintHex = hex; if (wear.burn) paint.color.lerp(new THREE.Color(0x141210), wear.burn); },
    // damage you can see: dents pushed into the bodywork where the hit was, a cracking windscreen, scorched paint
    damage(local, power) {
      wear.total += power;
      for (const [mesh, kk] of [[bodyMesh, 1], [canMesh, .6]]) {
        const pos = mesh.geometry.attributes.position, r = .55 + Math.min(1.1, power * .025), depth = Math.min(.32, power * .006) * kk;
        let hit = false;
        for (let i = 0; i < pos.count; i++) {
          const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i), d = Math.hypot(x - local.x, (y - local.y) * 1.4, z - local.z);
          if (d > r) continue;
          // pushed in toward the middle of the car (and back from the nose or tail on a head-on hit)
          const f = (1 - d / r) ** 2 * depth, dy = .6 - y, dz = -z, l = Math.hypot(dy, dz) || 1, ax = Math.abs(local.x) > 1.5 ? -Math.sign(local.x) * 1.4 : 0;
          pos.setXYZ(i, x + ax * f, y + dy / l * f * .7, z + dz / l * f * 1.3); hit = true;
        }
        if (hit) { pos.needsUpdate = true; mesh.geometry.computeVertexNormals(); }
      }
      if (wear.total > 25 && !wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; }
      wear.burn = Math.min(.85, Math.max(wear.burn, (wear.total - 60) / 160)); if (wear.burn > 0) { paint.color.set(paintHex).lerp(new THREE.Color(0x141210), wear.burn); paint.roughness = Math.max(paint.roughness, .3 + wear.burn * .6); }
    },
    wreck() { wear.burn = .9; paint.color.set(0x161412); paint.roughness = .95; paint.clearcoat = 0; glass.opacity = .25; if (!wear.cracked) { wear.cracked = true; glass.map = crackTex(); glass.needsUpdate = true; } spoilers.visible = false; },
    repair() {
      bodyMesh.geometry.attributes.position.array.set(bodyBase); bodyMesh.geometry.attributes.position.needsUpdate = true; bodyMesh.geometry.computeVertexNormals();
      canMesh.geometry.attributes.position.array.set(canBase); canMesh.geometry.attributes.position.needsUpdate = true; canMesh.geometry.computeVertexNormals();
      wear.total = 0; wear.burn = 0; wear.cracked = false; glass.map = null; glass.needsUpdate = true; paint.color.set(paintHex); spoilers.visible = true;
    },
    get wear() { return wear; },
    setFinish(f) {
      const F = { gloss: [.6, .32, 1, .12], satin: [.4, .5, .35, .06], matte: [.15, .8, 0, 0] }[f] || [.6, .32, 1, .12];
      paint.metalness = F[0]; paint.roughness = F[1]; paint.clearcoat = F[2]; paint.normalScale.setScalar(F[3]);
    },
    setAccent(hex) { caliper.color.set(hex); },
    // brakes: the tail lights flare and the discs glow when you brake hard at speed
    setBrakes(k, heat) { rear.color.setRGB(2.6 * (1 + k * 1.2), .42 * (1 + k * 1.2), .58 * (1 + k)); disc.emissiveIntensity += ((heat || 0) * 2.2 - disc.emissiveIntensity) * .08; },
    // the active wing: up above 110 km/h, air-brake tilt when braking hard; it stays stowed if a fixed spoiler is fitted
    setAero(kmh, braking, dt) { const A = aero.userData, on = !spoilers.children.length; aero.visible = on; if (!on) return;
      A.lift += ((kmh > 110 || braking > .5 && kmh > 60 ? 1 : 0) - A.lift) * Math.min(1, dt * 3); A.tilt += ((braking > .5 && kmh > 60 ? 1 : 0) - A.tilt) * Math.min(1, dt * 5);
      aeroWing.position.y = .03 + A.lift * .38; aeroWing.rotation.z = .08 + A.tilt * .95; aeroWing.visible = A.lift > .02; },
    setTrim(hex) { trim.color.set(hex); },
    setRim(hex) { rim.color.set(hex); },
    setLight(hex) { front.color.set(hex).multiplyScalar(3); },
  };
}