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.