PageSourceSearch

https://www.nestifyapp.org/nestie3d.js?v=212

js nestifyapp.org collected 2026-10-01 16:18:03 UTC 32,994 bytes, 723 lines download raw bytes

1/* ============================================================
2   NESTIE IN THREE DIMENSIONS — the hero's object, built rather
3   than photographed.
4
5   🔴 THERE IS NO MODEL FILE, AND THERE DOES NOT NEED TO BE. Nestie
6   is a rounded slab with a raised rim: two extruded profiles and a
7   picture. That is a shape a browser can build from numbers alone,
8   so it is built from numbers alone — no .glb to keep in sync with
9   the industrial design, no render farm, and the screens are
10   whatever PNGs are in assets/device/. Swap a texture and the
11   product is showing a different app.
12
13   🔴 THREE LAYERS: a blue metal rim that STANDS PROUD, a white face
14   recessed behind it, the screen inside that. Two extrusions, not
15   one — the rim is a ring extruded deeper than the slab it
16   surrounds, so it has a top surface of its own and drops a hairline
17   of shadow onto the white. Earlier versions collapsed the first two
18   into a single white slab and none of them read as hardware; a flat
19   face has one outline, and this object has two.
20
21   🔴 TWO UNITS, STAGGERED IN DEPTH. The front one shows the app, the
22   one behind shows the wallpaper — a single unit can only ever say
23   what the product looks like doing ONE thing.
24
25   🔴 A CLASSIC SCRIPT, NOT A MODULE, and vendor/three.js is built
26   for that (see vendor/build-three.py). three.js has been ESM-only
27   since r150, a module needs an HTTP origin, and device.html opened
28   by double-clicking it is a file:// origin — so the module was
29   blocked by CORS, silently, while every other script on the page
30   carried on working. The hero fell back to the drawing and there
31   was nothing in the console to say why.
32
33   🔴 EVERY EXIT IS NAMED. Each way this file can fail ends in the
34   same picture — assets/device/shop.jpg, the still render underneath
35   — and that picture is indistinguishable from "the 3D never
36   shipped". So each abort says which one it was, once, and leaves
37   window.nestie3d behind for anyone checking.
38   ============================================================ */
39(function () {
40  /* 这里曾经有一条 `if (手机) return;`,是 2026-08-30 排整机黑屏时下的。
41     🔴 那次的根因后来查清了,是首页 meet 段与 cards 段同屏共存的纹理峰值
42     (见 meet.js 的 is-spent / content-visibility),跟本页无关——本页
43     根本没有那两段。这条 gate 是那轮恐慌的**误伤**,用户没要求过,现已撤回。
44
45     教训记在这儿:按「症状出现在手机上」就关掉手机上所有重的东西,会顺手
46     关掉一批与根因无关的功能,而且因为症状确实消失了,看起来像是修对了。 */
47  const THREE = window.THREE;
48  const UNITLIB = window.NestieUnit;
49  if (!THREE || !UNITLIB) {
50    window.nestie3d = { ok: false, why: THREE ? 'no-unit' : 'no-three' };
51    console.warn('[nestie3d] keeping the still render — '
52      + (THREE ? 'nestie-unit.js' : 'vendor/three.js')
53      + ' did not load. Both must come BEFORE this file in device.html.');
54    return;
55  }
56
57  const fig = document.querySelector('.dv__hero-fig');
58  const canvas = document.getElementById('nestie3d');
59  if (!fig || !canvas) return;
60
61  const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
62
63  /* 🔴 THE OBJECT ITSELF IS NOT DESCRIBED HERE ANY MORE. Geometry,
64     materials and the room moved to nestie-unit.js when the home page's
65     hub started drawing the same housing — two copies of a bevel, a rim
66     ratio and an anodising colour drift apart the first time one of them
67     is tuned. What is left in this file is the SHOT: where the camera
68     stands, where the two units are put, and what the scroll does to
69     either of it.
70
71     One dimension is still needed here, because the layout is written
72     in device widths. */
73  const BODY_W = UNITLIB.DIMS.BODY_W;
74
75  /* ---------- renderer ----------
76     🔴 The constructor is a silent exit. No WebGL, a blocklisted driver,
77     too many live contexts on the tab — it throws, the whole IIFE
78     unwinds, and the only evidence is a hero that looks like it was
79     never built. */
80  let renderer;
81  try {
82    renderer = new THREE.WebGLRenderer({
83      canvas,
84      antialias: true,
85      alpha: true,               // the page's own ground shows through
86      powerPreference: 'high-performance',
87    });
88  } catch (e) {
89    window.nestie3d = { ok: false, why: 'no-webgl' };
90    console.warn('[nestie3d] keeping the still render — no WebGL context: '
91      + ((e && e.message) || e));
92    return;
93  }
94  /* The screen textures are 1600×900 and contain small interface type, so a
95     1× drawing buffer is visibly soft on a 3× phone: the browser has to
96     enlarge every rendered pixel across three physical pixels. This page
97     owns only this one WebGL scene (unlike the animation-heavy home page),
98     so a capped 2× buffer is the useful balance — four times the pixels of
99     the old phone render, while still avoiding the nine-times cost of 3×. */
100  renderer.setPixelRatio(Math.min(2, devicePixelRatio || 1));
101  renderer.toneMapping = THREE.ACESFilmicToneMapping;
102  renderer.toneMappingExposure = 1.04;
103  renderer.outputColorSpace = THREE.SRGBColorSpace;
104
105  const scene = new THREE.Scene();
106
107  /* ---------- the camera, and the layout it frames ----------
108     🔴 EVERY NUMBER IN THIS BLOCK IS AUTHORED, not derived, and it comes
109     from a written layout spec. Earlier versions computed the camera
110     backwards from a frame width so the object would land at exactly the
111     still's size; that contract is gone, because the composition is now
112     the thing being served and the size falls out of it.
113
114     The spec is written in DEVICE WIDTHS — "device width = 1.0" — so
115     everything is multiplied by UNIT. That indirection is the whole
116     reason the layout survived the housing growing a metal rim: change
117     the body and the composition scales with it instead of drifting.
118
119     🔴 A LONG LENS MEANS DISTANCE, NOT A SMALL FOV, and that is what
120     makes the FOV safe to change. Perspective is a function of camera
121     POSITION alone; the FOV only crops. So 4.2 device widths — the
122     spec's distance, and the whole source of its flat foreshortening —
123     is untouched, and the framing was solved on the FOV and the look-at,
124     neither of which can alter the shape of anything.
125
126     🔴 The spec's look-at did not survive. (0.1, -0.1) was picked by eye
127     and left the content running to NDC -1.15 on the left and -1.10 at
128     the bottom — off the frame on two sides. This is the measured centre
129     of the two units' projected bounds. */
130  const UNIT = BODY_W;                 // the spec's "device width = 1.0"
131  const CAM = { x: 0, y: 0.35, z: 4.2, fov: 21 };
132  const LOOK = { x: -0.116, y: -0.141 };
133
134  const camera = new THREE.PerspectiveCamera(CAM.fov, 16 / 10, 0.1, 100);
135  camera.position.set(CAM.x * UNIT, CAM.y * UNIT, CAM.z * UNIT);
136  camera.lookAt(LOOK.x * UNIT, LOOK.y * UNIT, 0);
137
138  scene.environment = UNITLIB.makeEnvironment(THREE, renderer);
139
140  const key = UNITLIB.makeKey(THREE);
141  scene.add(key);
142
143  const built = UNITLIB.makeUnit(THREE);
144  const front = built.unit;
145  const screenMat = built.screenMat;
146
147  /* (the shadow lived here — a drawn, feathered pill on a camera-facing
148     plane under each unit, with its texture, makeShadow(), the two meshes
149     and the budget they were sized against. The pair floats in plain
150     daylight now: no ground and nothing under it.) */
151
152  /* ---------- the second unit ----------
153     🔴 TWO OBJECTS, TWO PICTURES. One shows the app and one shows the
154     wallpaper, and that pairing is the whole argument for the second
155     slab: a single unit can only ever say what the product looks like
156     doing ONE thing.
157
158     🔴 A CLONE, not a second build. Object3D.clone() shares geometry and
159     material by reference, so the second housing costs one more draw call
160     and no memory: same body, same rim. The ONLY thing it does not share
161     is the screen's material, which is replaced on the clone — that is
162     the one difference there is supposed to be.
163
164     🔴 THE BACK UNIT'S RIM WAS BLACK FOR A WHILE, and the reason it is not
165     is worth keeping because it is a property of the material model, not
166     a matter of taste. Setting the clone's rim to --nes-ink rendered a
167     HOLE: measured off the render, the darkest pixel on that rim came back
168     #030303. At `metalness: 1` there is no diffuse term at all — the base
169     colour only tints what the surface REFLECTS — so anywhere a softbox is
170     not pointing there is simply nothing to see. Real black anodised
171     aluminium never goes that dark; it sits around #2e3136 in shadow and
172     it is the SHEEN that reads as black, not the value.
173
174     Lifting it worked (#3a4048, metalness 0.92 to put a floor under the
175     shadow side, envMapIntensity 1.55 to put the softboxes back), and the
176     pair still read as two objects rather than two finishes of one part.
177     So both housings wear the one rim again. If a dark variant is ever
178     wanted: it is those three numbers, not one colour. */
179  const screenMatAlt = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false });
180
181  function makeAlt() {
182    const unit = front.clone();
183    const sc = unit.getObjectByName('screen');
184    if (sc) sc.material = screenMatAlt;
185    return unit;   // placed in draw(), like the front one
186  }
187
188  let alt = makeAlt();
189
190  /* ---------- the composition ----------
191     Still a child of one group. The parent carries what is genuinely
192     shared — the entrance, the idle drift, the pointer — and the two
193     units carry their own placement. */
194  const pair = new THREE.Group();
195  /* back unit first: nothing here is opaque over anything else, so depth
196     sorting does the work, but a reader should not have to check that to
197     know what is behind what */
198  pair.add(alt);
199  pair.add(front);
200  scene.add(pair);
201
202  /* ---------- framing ---------- */
203  let halfH = 1;
204
205  function frame() {
206    const w = canvas.clientWidth;
207    const h = canvas.clientHeight;
208    if (!w || !h) return false;
209    renderer.setSize(w, h, false);
210    /* 🔴 The aspect, and nothing else. The camera's position and target
211       are the layout's and are set once; this used to solve for
212       position.z so a given world width filled the frame, which is the
213       contract that went away with the authored composition. */
214    camera.aspect = w / h;
215    camera.updateProjectionMatrix();
216    halfH = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.position.length();
217    return true;
218  }
219
220  /* ---------- the two devices, in the layout's own words ----------
221     Angles in degrees because the spec is in degrees; positions in device
222     widths because the spec is in device widths. DEG and UNIT convert at
223     the point of use, so a line here can be compared against the spec
224     character for character.
225
226       A  FRONT, the app screen. Turned, low and left.
227       B  BACK, the wallpaper. Nearly frontal, high and right.
228
229     🔴 THE SPEC'S x AND y DISAGREED WITH THE SPEC'S OWN OVERLAP, and the
230     overlap won. As written it put A at -0.75 and B at +0.55 — 1.30
231     device widths between their centres, which is more than one device,
232     so they cannot touch. And the same spec says A's top-right corner
233     covers B's bottom-left. Both cannot be true; the overlap is the one
234     that is visible in a picture and checkable in a render, so the
235     coordinates were solved backwards from it.
236
237     🔴 THE z GAP IS 1.05 DEVICE WIDTHS, and it has been 1.0 and then 1.6
238     on the way here. The stagger is the only thing carrying the
239     arrangement's third dimension — the lens is long on purpose, so
240     foreshortening within each unit is deliberately slight, and if the
241     two are nearly the same size on screen there is nothing left to say
242     they are not side by side. That argument took it to 1.6, where the
243     back unit renders at 68% of the front one, and 68% is not depth any
244     more, it is two different products. 1.05 puts it at 78%: far enough
245     to read as behind, near enough to read as the same object.
246
247     The look-at is re-solved every time this moves. Pulling the front
248     unit toward the camera makes it bigger and pushes the pair's
249     projected centre, so a gap change that leaves LOOK alone clips a
250     corner — at 1.6 with the old look-at the worst point sat at 0.999 of
251     the frame, which is to say exactly on the edge.
252
253     🔴 Rotation order is ZYX so the ROLL is applied last, in screen space
254     — which is what "counterclockwise in screen" means and what the
255     default XYZ order would not have done. All three sign conventions
256     were checked against the spec's descriptions before anything was
257     built: +yaw sends the right edge away, +roll lifts the right side,
258     -pitch leans the top back. */
259  const DEG = Math.PI / 180;
260
261  const A = {
262    x: -0.344, y: -0.221, z: 0.55,
263    yaw: 25 * DEG,     // left edge toward camera, right edge recedes
264    roll: 8 * DEG,     // counterclockwise in screen, right side higher
265    pitch: -5 * DEG,   // leaning back slightly
266  };
267
268  /* 🔴 THIS UNIT IS THE HIGHEST THING IN THE SCENE, so its y sets the top
269     of the whole composition — which is what crosses "Heads up." in the
270     hero. It is the direct lever on how much of that word the object
271     covers, and a better one than the figure's margin in device.css: the
272     margin separates the type and the object by moving the whole figure,
273     this moves only the corner that is doing the covering.
274
275     y went 0.122 → 0.06 → 0 → -0.06 → 0.09. The three steps down were
276     answering "it covers too much" against a browser that had cached the
277     old script, so they were all aimed at a picture that was already two
278     revisions stale; -0.06 put the object 17px CLEAR of the word, which
279     was never the brief. 0.09 is the first value chosen against what is
280     actually on screen.
281
282     Measured at 1440x900 — headless with SwiftShader, and reduced motion
283     forced so the entrance is settled and the render is deterministic.
284     Word ink and object are separated by darkness, since they overlap:
285
286       word ink (dark), strong through            y 206
287       the object's pale housing first appears    y 194
288       the "p" descender runs to                  y 220
289
290     so the corner crosses the last ~13px of the letters — the bottom 14%
291     of a 90px cap height — and then the descender. Enough to read as
292     standing IN FRONT of the word; not enough to take a letter.
293
294     🔴 THE NEIGHBOURS, if this wants one more nudge: 0.06 puts the corner
295     at y 210, below the baseline, touching the descender only; 0.122 (the
296     original) put it around y 180, a good 25px into the letters, which is
297     the "too much" this started from.
298
299     🔴 THE OVERLAP WITH A is the composition's own argument, not a side
300     effect. The spec's test is that A's top-right corner covers B's
301     bottom-left, and every value above keeps it: they share roughly a
302     0.34 × 0.45 patch in device widths here.
303
304     🔴 The look-at is NOT re-solved for any of this. That warning (on A,
305     above) is about clipping, and this unit's travel has been well inside
306     the frame at both ends. */
307  const B = {
308    x: 0.32, y: 0.09, z: -0.50,
309    yaw: -8 * DEG,
310    /* 🔴 MINUS 10°, and the sign is a correction. It went to +10 first on
311       the reasoning that the pair should lean together and opposite signs
312       would read as two objects that had fallen over in different
313       directions. Fine reasoning, wrong reference: the back unit leans
314       the OTHER way, and the two of them opening slightly apart is what
315       stops the pair reading as one object photocopied. */
316    roll: -10 * DEG,
317    pitch: -5 * DEG,
318  };
319
320  /* ---------- the choreography ----------
321     p is the pinned hero's own progress, 0 → 1, handed over by device.js
322     so that the ground and the object are never reading two different
323     clocks.
324
325     🔴 THE SCROLL BARELY TOUCHES IT — a few degrees, and that is the
326     whole brief. Earlier passes had the composition open out, spread and
327     swing over the rail, on the theory that a pinned section owes the
328     reader something for the scroll it is taking. It does not. What the
329     scroll should give is the small parallax of walking past a thing on a
330     shelf, not a performance. */
331  let p = 0;
332  let mx = 0;
333  let my = 0;   // pointer, -1 → 1
334  let px = 0;
335  let py = 0;   // damped
336
337  addEventListener('nestie:progress', (e) => { p = e.detail.p; if (REDUCED) draw(); });
338
339  if (!REDUCED) {
340    addEventListener('pointermove', (e) => {
341      mx = (e.clientX / innerWidth) * 2 - 1;
342      my = (e.clientY / innerHeight) * 2 - 1;
343    }, { passive: true });
344  }
345
346  /* 🔴 CUBIC, and a quintic was tried and thrown out. At t = 0.4 a
347     quintic is already 92% of the way there and spends the remaining 60%
348     of the duration on the last 8% — which is true and is exactly the
349     problem: the last 8% of a 0.28 rise is 2 mm on screen. The whole
350     entrance was over in half a second and the second after it was a
351     still frame, which is a snap dressed up as a settle. */
352  const easeOut = (t) => 1 - Math.pow(1 - t, 3);
353
354  /* ---------- the entrance: IT RISES ----------
355     🔴 THE LIGHT MOVING ACROSS IT IS THE POINT, not the travel. A
356     pre-rendered film gets that for free, because the camera really did
357     move. We get it by turning the ROOM: scene.environmentRotation sweeps
358     and unwinds over the rise, which drags every reflection across the
359     metal while it settles. One line, and it is the difference between
360     "expensive" and "a picture sliding up".
361
362     🔴 It starts LOW, not off-screen. The canvas cross-fades in over the
363     still, so whatever the object is doing at frame zero is what the
364     still dissolves into.
365
366     🔴 Driven by frames, not by a clock. Date.now() at boot would have
367     the entrance already half over on a slow first paint, and would run
368     at a different rate on a 120 Hz display. */
369  const ENTRY = 72;                              // frames, ~1.2s at 60Hz
370  const RISE = {
371    y: -0.28,        // how far below it starts
372    /* 🔴 THE ENTRANCE NO LONGER CHANGES SIZE, and that is the fix for
373       "it shrinks as it comes down, and the shrink looks wrong". It used
374       to arrive 6% over scale and settle back (10% before that), which
375       reads as the object getting SMALLER while it travels — and a
376       physical thing that moves a few centimetres does not change size,
377       so the eye reads the scale as the object being replaced by a
378       smaller one rather than as it settling.
379
380       What replaces it is a TURN. The pair arrives a few degrees off its
381       resting yaw and unwinds into it, which is the same amount of
382       "something is still moving" bought with a motion an object can
383       actually make. It also pairs with the roll below (`lean`) and the
384       room sweeping across the metal, so the whole entrance is now one
385       gesture — a thing rotating into place — instead of a move plus a
386       resize. */
387    turn: 0.10,      // radians of yaw, unwinding  (~5.7°)
388    lean: 0.085,     // extra tilt, unwinding
389    sweep: 0.60,     // radians the room turns through
390  };
391  /* 🔴 Reduced motion starts at the END of the entrance. The loop is what
392     advances this counter and reduced motion never runs the loop — leave
393     it at 0 and the pair sits permanently at its overshoot, which is the
394     entrance's first frame frozen forever. */
395  let entryFrame = REDUCED ? ENTRY : 0;
396
397  /* 🔴 A very slow drift, and it has to be BARELY perceptible. A floating
398     object that is perfectly motionless is one that has been pasted in.
399     Two sines a prime-ish ratio apart so the loop never visibly repeats;
400     the whole excursion is under a degree. If you can watch it move, it
401     is too much. */
402  let tick = 0;
403
404  function draw() {
405    /* the arch: out and back, so the far end of the rail is not a third
406       state the reader has to re-learn on the way up.
407
408       🔴 The two yaws it drives (below) went 0.05/0.04 → 0.09/0.075 —
409       about 5° at the peak, up from 3. They were set when the scroll
410       ALSO pushed the pair back in z; with the recede gone they were the
411       only thing left responding to the scroll, and 3° of yaw on a pad
412       this size is a move you have to be told about. These two numbers
413       are the dial if it wants to be more or less. */
414    const arch = Math.sin(Math.min(1, p / 0.88) * Math.PI);
415    const away = easeOut(Math.min(1, p));
416
417    px += (mx - px) * 0.06;
418    py += (my - py) * 0.06;
419
420    const e = 1 - easeOut(Math.min(1, entryFrame / ENTRY));
421    const floatY = Math.sin(tick * 0.0062) * 0.012;
422    const floatR = Math.sin(tick * 0.0041 + 1.1) * 0.008;
423
424    scene.environmentRotation.y = RISE.sweep * e;
425
426    /* the parent carries only what is shared; the roll is per device */
427    pair.rotation.z = RISE.lean * e + floatR;
428    /* the entrance's turn is added to the pointer's own yaw rather than
429       replacing it: someone whose cursor is already off-centre when the
430       page opens should not have the object snap when the entrance ends */
431    pair.rotation.y = px * 0.10 + floatR * 0.6 + RISE.turn * e;
432    pair.rotation.x = py * 0.05;
433    /* 🔴 THE SCROLL NO LONGER MOVES IT IN Z. This was
434       `pair.position.z = -0.26 * away` — the object receding as the
435       section was scrolled, on the reasoning that receding is honester
436       than scaling. In perspective those are the same picture: the thing
437       gets smaller. It is the second half of the same complaint the
438       entrance's overshoot was ("it shrinks and the shrink looks
439       wrong"), and it shows up on any window short enough for this
440       section to be taller than it — that is when device.js has a real
441       travel to measure and `away` stops being zero.
442
443       What carries the scroll instead is the TURN that was already
444       there: `arch` swings each pad's yaw out and back a few degrees
445       (below), so scrolling still moves the object, it just moves it the
446       way a solid thing moves. y still drifts a hair — 0.04 of a unit,
447       under a millimetre on screen — because a turn with no travel at
448       all reads as a hinge. */
449    pair.position.y = 0.04 * away + floatY + RISE.y * e;
450    /* 🔴 NO scale line. The pair is left at 1 for the life of the page —
451       see RISE above for why the overshoot went. If something here ever
452       wants to grow, it is the camera's job, not the object's. */
453
454    front.rotation.order = 'ZYX';
455    front.rotation.set(A.pitch, A.yaw + 0.09 * arch, A.roll);
456    front.position.set(A.x * UNIT, A.y * UNIT, A.z * UNIT);
457
458    if (alt) {
459      alt.rotation.order = 'ZYX';
460      alt.rotation.set(B.pitch, B.yaw - 0.075 * arch, B.roll);
461      alt.position.set(B.x * UNIT, B.y * UNIT, B.z * UNIT);
462    }
463
464    key.position.set(2.4 + px * 0.9, 2.4 - py * 0.7, 3.0);
465
466    renderer.render(scene, camera);
467  }
468
469  let running = false;
470  function loop() {
471    if (!running) return;
472    tick += 1;
473    if (entryFrame < ENTRY) entryFrame += 1;
474    draw();
475    requestAnimationFrame(loop);
476  }
477
478  /* 🔴 Off-screen it does not run. This is one section of a long page and
479     a WebGL loop three screenfuls above the reader is a laptop fan for
480     nothing. Reduced motion never starts the loop — it draws one frame
481     per progress event, so the pair still turns with the scroll but
482     nothing moves on its own and the pointer is not followed. */
483  const io = new IntersectionObserver((es) => {
484    const on = es[0].isIntersecting;
485    if (on === running) return;
486    running = on && !REDUCED;
487    if (running) loop();
488    else if (on) draw();
489  }, { threshold: 0 });
490
491  const WHY = {
492    tainted:
493      'the screen image cannot be used as a WebGL texture from a file:// '
494      + 'page. Chrome gives every local file its own opaque origin, so the '
495      + 'PNG is cross-origin to device.html and texImage2D refuses it. '
496      + 'Nothing is wrong with the page — serve it over http and the hero '
497      + 'renders. From this folder: `python3 -m http.server 8899`.',
498    missing: 'the screen image did not load. The hero keeps the still render.',
499  };
500
501  function fail(why) {
502    running = false;
503    io.disconnect();
504    canvas.classList.remove('is-ready');
505    renderer.dispose();
506    window.nestie3d = { ok: false, why: why };
507    console.warn('[nestie3d] keeping the still render — ' + (WHY[why] || why));
508  }
509
510  /* ---------- a downloaded model, if there is one ----------
511     🔴 The procedural housing above is the FLOOR, not the ceiling — the
512     same contract every other asset on this page has. Drop a .glb at the
513     path in the canvas's data-model and it replaces the built one; leave
514     the slot empty and nothing is fetched.
515
516     🔴 NORMALISED BY ITS BOUNDING BOX, not trusted. A model off the
517     internet arrives at whatever scale, in whatever units, facing
518     whichever way its author called forward. glTF says Y-up, but
519     exporters from 3ds Max and SketchUp write Z-up anyway — the first one
520     tested here measured 44 × 3.7 × 60, a tablet lying on its back, which
521     drops in as a one-pixel line edge-on to the camera and reads as "the
522     loader is broken".
523
524     A display is the one shape where up is decidable: exactly one axis is
525     far shorter than the other two, that axis is its thickness, and
526     thickness points at the camera.
527
528     🔴 THREE NESTED GROUPS, one transform each, and that is not ceremony.
529     Centring an object that is also rotated means subtracting a
530     world-space offset from a node whose own axes are turned. And the two
531     corrections get a group EACH: three.js composes 'XYZ' as Rx·Ry·Rz, so
532     the Z term is applied to the vertex FIRST and the X term second,
533     while the code decides whether Z is needed by measuring a box it
534     believes X has already fixed. Measured on that same model, the two
535     corrections came out as a cyclic permutation.
536
537     🔴 The correction goes on a group ABOVE the model, never on the model
538     itself: `root.rotation.x = ...` OVERWRITES whatever rotation the file
539     put there, and Z-up exports routinely bake -90° into their scene
540     node.
541
542     🔴 FINDING THE SCREEN CANNOT BE AUTOMATED, and this does not pretend
543     otherwise. It looks for a mesh whose name says screen/display/glass,
544     and when that misses it PRINTS EVERY NAME IN THE FILE rather than
545     silently leaving the device switched off. */
546  const MODEL = canvas.dataset.model;
547  let screenTargets = [screenMat];
548
549  function paintScreen(tex) {
550    for (const m of screenTargets) {
551      if (!m) continue;
552      m.map = tex;
553      m.color = new THREE.Color(0xffffff);
554      m.toneMapped = false;
555      m.needsUpdate = true;
556    }
557  }
558
559  function adoptModel(root, tex) {
560    const wrap = new THREE.Group();
561    const pivot = new THREE.Group();
562    const spin = new THREE.Group();
563    const orient = new THREE.Group();
564    orient.add(root);
565    spin.add(orient);
566    pivot.add(spin);
567    wrap.add(pivot);
568
569    let s = new THREE.Box3().setFromObject(wrap).getSize(new THREE.Vector3());
570    if (!s.x || !s.y || !s.z) throw new Error('model has no size');
571
572    const axes = [['x', s.x], ['y', s.y], ['z', s.z]].sort((a, b) => a[1] - b[1]);
573    const thin = axes[0][0];
574    if (thin === 'y') orient.rotation.x = -Math.PI / 2;
575    else if (thin === 'x') orient.rotation.y = Math.PI / 2;
576
577    wrap.updateMatrixWorld(true);
578    s = new THREE.Box3().setFromObject(wrap).getSize(new THREE.Vector3());
579    if (s.y > s.x) {
580      spin.rotation.z = Math.PI / 2;
581      wrap.updateMatrixWorld(true);
582      s = new THREE.Box3().setFromObject(wrap).getSize(new THREE.Vector3());
583    }
584
585    const centre = new THREE.Box3().setFromObject(wrap).getCenter(new THREE.Vector3());
586    pivot.position.copy(centre).negate();
587
588    const halfW = halfH * camera.aspect;
589    const fit = Math.min((halfW * 2 * 0.86) / s.x, (halfH * 2 * 0.86) / s.y);
590    wrap.scale.setScalar(fit);
591
592    /* 🔴 glTF UVs RUN THE OTHER WAY UP. TextureLoader hands back flipY
593       true, which is what three's own PlaneGeometry expects; the glTF
594       spec says the opposite, so the same texture object on a downloaded
595       mesh comes out upside down. It cannot just be flipped in place —
596       the built housing may still be using it — so the model gets a
597       clone, which shares the image. */
598    const modelTex = tex.clone();
599    modelTex.flipY = false;
600    modelTex.colorSpace = THREE.SRGBColorSpace;
601    modelTex.needsUpdate = true;
602
603    const named = (canvas.dataset.screenMesh || '').toLowerCase();
604    const GUESS = /screen|display|glass|lcd|monitor|panel/i;
605    const found = [];
606    const all = [];
607    root.traverse((o) => {
608      if (!o.isMesh) return;
609      const mats = Array.isArray(o.material) ? o.material : [o.material];
610      all.push(o.name || '(unnamed)');
611      const hit = named
612        ? (o.name || '').toLowerCase() === named
613        : GUESS.test(o.name || '') || mats.some((m) => m && GUESS.test(m.name || ''));
614      if (hit) found.push(...mats);
615    });
616
617    if (found.length) {
618      screenTargets = found;
619    } else {
620      console.warn(
621        '[nestie3d] loaded ' + MODEL + ' but could not tell which mesh is the '
622        + 'screen. Meshes in the file:\n  ' + all.join('\n  ')
623        + '\nPut the right name in data-screen-mesh on #nestie3d.',
624      );
625    }
626
627    front.clear();
628    front.add(wrap);
629    paintScreen(modelTex);
630    window.nestie3d = { ok: true, model: MODEL, meshes: all };
631  }
632
633  /* the wallpaper for the unit behind. Loaded separately and never fatal:
634     if it is missing the alt simply shows white and the front unit is
635     unaffected. One missing file must not take the hero with it. */
636  if (canvas.dataset.screenAlt) {
637    new THREE.TextureLoader().load(
638      canvas.dataset.screenAlt,
639      (tex) => {
640        tex.colorSpace = THREE.SRGBColorSpace;
641        tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
642        screenMatAlt.map = tex;
643        screenMatAlt.needsUpdate = true;
644        try { draw(); } catch (err) { /* see the note on the main texture */ }
645      },
646      undefined,
647      () => {},
648    );
649  }
650
651  new THREE.TextureLoader().load(
652    canvas.dataset.screen || 'assets/device/device1.webp',
653    (tex) => {
654      tex.colorSpace = THREE.SRGBColorSpace;
655      tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
656      screenMat.map = tex;
657      screenMat.needsUpdate = true;
658
659      if (MODEL && THREE.GLTFLoader) {
660        new THREE.GLTFLoader().load(
661          MODEL,
662          (gltf) => {
663            try {
664              adoptModel(gltf.scene, tex);
665              draw();
666            } catch (err) {
667              console.warn('[nestie3d] ' + MODEL + ' loaded but could not be '
668                + 'used, keeping the built housing: '
669                + ((err && err.message) || err));
670            }
671          },
672          undefined,
673          /* no file, or a broken one: the built housing is already on
674             screen and stays there. This is not an error. */
675          () => {},
676        );
677      }
678
679      /* 🔴 The upload is where file:// dies, not the fetch. The <img>
680         loads perfectly well off the disk — this callback runs, the
681         texture object is real — and then the first render throws
682         SecurityError deep inside texImage2D. Which is why this one
683         draw() is wrapped and the rest are not. */
684      try {
685        draw();
686        window.nestie3d = { ok: true };
687      } catch (err) {
688        screenMat.map = null;
689        screenMat.needsUpdate = true;
690        fail(err && err.name === 'SecurityError'
691          ? 'tainted' : (err && err.message) || 'render failed');
692      }
693    },
694    undefined,
695    () => fail('missing'),
696  );
697
698  /* 🔴 Boot is IDEMPOTENT and can be reached from either direction. An
699     earlier version ran it once at the end and gave up if the canvas had
700     no size yet — not a hypothetical: the canvas is sized in % of a
701     figure whose own width is a min() against 100svh, and on a cold load
702     with the webfont still landing that can measure zero. The result was
703     a hero that had built a whole scene, was rendering it every frame,
704     and never faded it in. */
705  let booted = false;
706  function boot() {
707    if (booted || !frame()) return;
708    booted = true;
709    draw();
710    /* the still underneath goes only once there is something to replace
711       it with — one frame of nothing between the two is a flicker at the
712       top of the page */
713    requestAnimationFrame(() => canvas.classList.add('is-ready'));
714    io.observe(fig);
715  }
716
717  new ResizeObserver(() => {
718    if (!booted) { boot(); return; }
719    if (frame()) draw();
720  }).observe(canvas);
721
722  boot();
723})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.