@genex-ai/cli-demo 1.5.2-dev.398 → 1.6.0-dev.403

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,776 @@
1
+ // The neutral stage every asset is shown on: renderer, camera, environment,
2
+ // lights, ground shadow and the turntable. Deliberately no floor, no grid, no
3
+ // scale figure.
4
+ //
5
+ // The library is not only floor-standing props. A wall bracket, a ceiling lamp
6
+ // or a hand tool standing on a ground plane reads as a mistake rather than as a
7
+ // stage, and a grid that is right for a pallet is wrong for the next asset in.
8
+ // What replaces the floor is a LIT BACKDROP plus a soft ground shadow that has
9
+ // no edges of its own - both are presentation that is never wrong for whatever
10
+ // it is showing, and the HUD already states the real size in metres.
11
+ //
12
+ // This is the file that makes the library look like one product, which is why it
13
+ // is copied verbatim and hashed by gate G13. Edit it in
14
+ // packages/asset-viewer-template/template/src/viewer, never in an asset folder.
15
+
16
+ import * as THREE from "three";
17
+
18
+ const TURNTABLE_PERIOD_S = 12;
19
+ const MAX_DPR = 1.5;
20
+
21
+ // --- Backdrop --------------------------------------------------------------
22
+ // A pool of light behind the subject, falling off to near-black in the corners.
23
+ //
24
+ // The flat grey it replaces was measured (L* 32.5) as the one value that kept
25
+ // every asset's outline above the just-noticeable difference - a flat backdrop
26
+ // has to serve the darkest asset and the lightest one with a single number, and
27
+ // at L* 20.6 a third of the wheelie bin's outline disappeared into it.
28
+ //
29
+ // A pool removes that compromise instead of re-tuning it. The subject is
30
+ // silhouetted against POOL, which lands within a couple of points of that
31
+ // measured-safe grey, so the outline separation it was chosen for still holds;
32
+ // the darkness lives out at the corners, where no asset has any geometry. Both
33
+ // values are final sRGB - the backdrop is chrome, not lit geometry, so it is
34
+ // written straight to the output buffer with no tone mapping and no colour
35
+ // conversion applied to it.
36
+ // Raised 2026-08-13 (owner: "make the whole scene a little brighter"). The
37
+ // previous pool measured L* 23.7 - about nine points BELOW the L* 32.5 this
38
+ // comment already names as the outline-safe value, so brightening moves it onto
39
+ // that measurement rather than away from it: #444a54 measures L* 31.5. The
40
+ // corners come up with it so the whole field reads lighter, while the pool-to-
41
+ // corner ratio is kept close to what it was, which is what preserves the
42
+ // vignette instead of flattening the backdrop into one grey.
43
+ const POOL = [0x44 / 255, 0x4a / 255, 0x54 / 255];
44
+ const CORNER = [0x22 / 255, 0x25 / 255, 0x2b / 255];
45
+ // The pool sits above centre: the camera aims a little above the centroid, so
46
+ // this puts the brightest part of the backdrop behind the subject's mass.
47
+ const POOL_CENTRE_Y = 0.56;
48
+
49
+ const BACKDROP_VERT = /* glsl */ `
50
+ varying vec2 vUv;
51
+ void main() {
52
+ vUv = uv;
53
+ gl_Position = vec4(position.xy, 0.0, 1.0);
54
+ }
55
+ `;
56
+
57
+ // The dither is not decoration. A pool this wide crosses ~60 8-bit steps over
58
+ // ~900 px, which is textbook Mach banding; one pixel of ordered noise costs
59
+ // nothing and removes every ring.
60
+ const BACKDROP_FRAG = /* glsl */ `
61
+ precision highp float;
62
+ varying vec2 vUv;
63
+ uniform vec3 uPool;
64
+ uniform vec3 uCorner;
65
+ uniform float uAspect;
66
+
67
+ void main() {
68
+ vec2 p = vUv - vec2(0.5, ${POOL_CENTRE_Y.toFixed(2)});
69
+ p.x *= uAspect;
70
+ float d = length(p);
71
+ float pool = smoothstep(0.92, 0.02, d);
72
+ // The exponent is the pool's focus, and it is the ONE knob here that softens
73
+ // the light without lifting the corners. Widening the smoothstep would blur
74
+ // it too, but the outer edge is what holds the corners at CORNER - push that
75
+ // out and the darkness goes with it. Lowering the exponent spreads brightness
76
+ // through the mid-field while d >= 0.92 still lands on exactly zero.
77
+ pool = pow(pool, 1.08);
78
+ vec3 col = mix(uCorner, uPool, pool);
79
+ float n = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
80
+ col += (n - 0.5) / 255.0;
81
+ gl_FragColor = vec4(col, 1.0);
82
+ }
83
+ `;
84
+
85
+ function createBackdrop() {
86
+ const scene = new THREE.Scene();
87
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
88
+ const material = new THREE.ShaderMaterial({
89
+ vertexShader: BACKDROP_VERT,
90
+ fragmentShader: BACKDROP_FRAG,
91
+ uniforms: {
92
+ uPool: { value: new THREE.Vector3(...POOL) },
93
+ uCorner: { value: new THREE.Vector3(...CORNER) },
94
+ uAspect: { value: 1 },
95
+ },
96
+ depthTest: false,
97
+ depthWrite: false,
98
+ });
99
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
100
+ mesh.frustumCulled = false;
101
+ scene.add(mesh);
102
+ return { scene, camera, material, mesh };
103
+ }
104
+
105
+ // --- Environment -----------------------------------------------------------
106
+ // Indirect light, which is the half of a real room that a directional light
107
+ // cannot fake. Without it a metal reads as flat grey paint - metalness 1.0 has
108
+ // no diffuse term at all, so with only analytic lights it has nothing to
109
+ // reflect and renders near-black - and every unlit face is a single ambient
110
+ // constant, which is exactly what made the old turntable's dark side look like
111
+ // a cut-out.
112
+ //
113
+ // Built procedurally out of coloured panels and pre-filtered with PMREM, so the
114
+ // viewer still ships no HDRI and still loads no external file: the same rule the
115
+ // asset modules live under, applied to their host.
116
+ //
117
+ // Panel colours are set with setRGB, which is working (linear) space, NOT
118
+ // setHex, which converts from sRGB - these are light intensities above 1.0 and
119
+ // converting them would clamp the whole point of the environment away.
120
+ const ENV_PANELS = [
121
+ // [x, y, z, w, h, d, r, g, b] - a soft box key upper front-right, a broad
122
+ // cool fill to the left, a warm bounce low and front (the floor light a room
123
+ // has and this stage does not), and a dim rim behind.
124
+ [3.4, 3.0, 3.0, 4.2, 3.0, 0.1, 3.4, 3.2, 2.9],
125
+ [-4.0, 1.8, 1.4, 0.1, 4.0, 5.0, 0.9, 1.05, 1.35],
126
+ [0.0, -2.4, 2.6, 6.0, 0.1, 4.0, 0.62, 0.55, 0.48],
127
+ [0.0, 2.2, -4.2, 5.0, 3.0, 0.1, 0.5, 0.58, 0.72],
128
+ [0.0, 4.4, 0.0, 5.0, 0.1, 5.0, 0.85, 0.9, 1.0],
129
+ ];
130
+
131
+ function buildEnvironment(renderer) {
132
+ const scene = new THREE.Scene();
133
+ const disposables = [];
134
+
135
+ const shellGeo = new THREE.BoxGeometry(14, 10, 14);
136
+ const shellMat = new THREE.MeshBasicMaterial({ side: THREE.BackSide });
137
+ shellMat.color.setRGB(0.055, 0.06, 0.072);
138
+ scene.add(new THREE.Mesh(shellGeo, shellMat));
139
+ disposables.push(shellGeo, shellMat);
140
+
141
+ for (const [x, y, z, w, h, d, r, g, b] of ENV_PANELS) {
142
+ const geo = new THREE.BoxGeometry(w, h, d);
143
+ const mat = new THREE.MeshBasicMaterial();
144
+ mat.color.setRGB(r, g, b);
145
+ const mesh = new THREE.Mesh(geo, mat);
146
+ mesh.position.set(x, y, z);
147
+ scene.add(mesh);
148
+ disposables.push(geo, mat);
149
+ }
150
+
151
+ const pmrem = new THREE.PMREMGenerator(renderer);
152
+ pmrem.compileEquirectangularShader();
153
+ const target = pmrem.fromScene(scene, 0.02);
154
+ pmrem.dispose();
155
+ for (const d of disposables) d.dispose();
156
+ return target.texture;
157
+ }
158
+
159
+ // --- Ground shadow ---------------------------------------------------------
160
+ // A soft contact shadow, rendered once per fit into a texture and shown on a
161
+ // plane at the asset's base.
162
+ //
163
+ // A shadow-mapped floor was the obvious alternative and is the wrong one here:
164
+ // a floor plane has EDGES, and the moment the camera drops toward the horizon
165
+ // the stage stops being a backdrop and becomes a room with a visible tabletop
166
+ // in it. This has no edges - the darkness only exists where the asset is, and
167
+ // fades to nothing well inside the plane, so a wall bracket gets a drop shadow
168
+ // rather than a floor.
169
+ const SHADOW_RES = 512;
170
+ const SHADOW_STRENGTH = 0.74;
171
+ // Two separable passes at falling radii, in texels. The first cut ran one pass
172
+ // at 1.6 texels - about a centimetre across a metre-wide plane - which is a blur
173
+ // nobody can see.
174
+ const SHADOW_BLUR_TEXELS = [30, 12];
175
+ // How much room to leave around the shadow's own bounds, as a fraction of the
176
+ // footprint, so the blur has somewhere to spill and its soft edge is never cut
177
+ // off square.
178
+ const SHADOW_PAD = 0.26;
179
+ // The elevation the ground shadow is CAST from - deliberately steeper than the
180
+ // 35-degree key, and this is the number that decides whether the shadow exists
181
+ // as far as a viewer is concerned.
182
+ //
183
+ // Straight down (the first cut) is the honest ambient-occlusion answer and it is
184
+ // invisible: the shadow lands exactly under the asset's own footprint, so the
185
+ // asset covers every pixel of it from every angle that can see the ground at
186
+ // all. Measured on the espresso machine - a correct, well-formed shadow that
187
+ // nothing could ever see. At the key's own 35 degrees the shadow stretches 1.4x
188
+ // the asset's height and the stage turns into a room with a floor in it. 52
189
+ // throws it about 0.8x the height, which clears the silhouette and stays a pool
190
+ // rather than becoming scenery.
191
+ const SHADOW_ELEVATION_DEG = 52;
192
+
193
+ // Where the key stands, and it is a presentation decision rather than a detail.
194
+ //
195
+ // The old value put the key at the SAME azimuth as the camera's home angle -
196
+ // the light sitting exactly behind the viewer's head. That is the one placement
197
+ // that carves no form at all (every surface the camera can see is lit straight
198
+ // on) and it is also the one placement whose shadow is invisible, because the
199
+ // shadow falls along the view axis and hides behind the asset that cast it.
200
+ // Both symptoms, one cause. 45 degrees off the camera is the ordinary studio
201
+ // three-quarter key: it models the form, and it throws the shadow out to the
202
+ // side where it can be seen.
203
+ const KEY_AZIMUTH_DEG = 0;
204
+ const KEY_ELEVATION_DEG = 35;
205
+
206
+ // Every vertex is slid down its own light ray onto the ground plane, which is
207
+ // the whole trick: the pass is an ordinary render of the asset, but the asset
208
+ // arrives already flattened into the shape of its own shadow.
209
+ const SHADOW_CAST_VERT = /* glsl */ `
210
+ varying float vHeight;
211
+ uniform float uBaseY;
212
+ uniform vec2 uSlide;
213
+ void main() {
214
+ vec4 world = modelMatrix * vec4(position, 1.0);
215
+ vHeight = max(world.y - uBaseY, 0.0);
216
+ world.xz += uSlide * vHeight;
217
+ world.y = uBaseY;
218
+ gl_Position = projectionMatrix * viewMatrix * world;
219
+ }
220
+ `;
221
+ // The floor term is the difference between this working on every asset and
222
+ // working on the ones that happen to be closed solids. Height alone says "the
223
+ // nearest surface above this point is high up, so barely shade it" - but a prop
224
+ // modelled as an open shell has NO underside, so the nearest surface above its
225
+ // own footprint is the inside of its lid, and it casts almost nothing. Measured
226
+ // on the espresso machine: alpha 0.08 under the middle of a solid-looking body.
227
+ // Any occluder at all now shades to at least SHADOW_FLOOR; height decides how
228
+ // much darker it gets from there.
229
+ const SHADOW_FLOOR = 0.42;
230
+
231
+ const SHADOW_CAST_FRAG = /* glsl */ `
232
+ precision highp float;
233
+ varying float vHeight;
234
+ uniform float uFalloff;
235
+ void main() {
236
+ float a = 1.0 - clamp(vHeight / uFalloff, 0.0, 1.0);
237
+ float shade = ${SHADOW_FLOOR.toFixed(2)} + ${(1 - SHADOW_FLOOR).toFixed(2)} * pow(a, 1.5);
238
+ gl_FragColor = vec4(vec3(1.0 - shade), 1.0);
239
+ }
240
+ `;
241
+
242
+ // Fullscreen: the blur passes are screen-space and want NDC straight out.
243
+ const BLUR_VERT = /* glsl */ `
244
+ varying vec2 vUv;
245
+ void main() {
246
+ vUv = uv;
247
+ gl_Position = vec4(position.xy, 0.0, 1.0);
248
+ }
249
+ `;
250
+
251
+ // The display plane is a real object in the world and needs the real transform.
252
+ // Sharing BLUR_VERT with it - which is what the first cut did - draws the shadow
253
+ // as a screen-space rectangle instead, and since it lands in front of the asset
254
+ // at NDC depth 0 the failure does not look like a missing shadow at all.
255
+ const PLANE_VERT = /* glsl */ `
256
+ varying vec2 vUv;
257
+ void main() {
258
+ vUv = uv;
259
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
260
+ }
261
+ `;
262
+
263
+ const BLUR_FRAG = /* glsl */ `
264
+ precision highp float;
265
+ varying vec2 vUv;
266
+ uniform sampler2D uMap;
267
+ uniform vec2 uStep;
268
+ void main() {
269
+ float sum = 0.0;
270
+ sum += texture2D(uMap, vUv - uStep * 4.0).r * 0.051;
271
+ sum += texture2D(uMap, vUv - uStep * 3.0).r * 0.0918;
272
+ sum += texture2D(uMap, vUv - uStep * 2.0).r * 0.12245;
273
+ sum += texture2D(uMap, vUv - uStep * 1.0).r * 0.1531;
274
+ sum += texture2D(uMap, vUv).r * 0.1633;
275
+ sum += texture2D(uMap, vUv + uStep * 1.0).r * 0.1531;
276
+ sum += texture2D(uMap, vUv + uStep * 2.0).r * 0.12245;
277
+ sum += texture2D(uMap, vUv + uStep * 3.0).r * 0.0918;
278
+ sum += texture2D(uMap, vUv + uStep * 4.0).r * 0.051;
279
+ gl_FragColor = vec4(vec3(sum), 1.0);
280
+ }
281
+ `;
282
+
283
+ const SHADOW_SHOW_FRAG = /* glsl */ `
284
+ precision highp float;
285
+ varying vec2 vUv;
286
+ uniform sampler2D uMap;
287
+ uniform float uStrength;
288
+ void main() {
289
+ float shade = 1.0 - texture2D(uMap, vUv).r;
290
+ // Fade the plane out before its own border, so the quad's edge can never be
291
+ // what the eye finds. Radial, not per-axis: a square fade leaves four faint
292
+ // corners that read as a sheet of paper under the asset.
293
+ float edge = 1.0 - smoothstep(0.80, 1.0, length(vUv - 0.5) * 2.0);
294
+ gl_FragColor = vec4(0.0, 0.0, 0.0, shade * uStrength * edge);
295
+ }
296
+ `;
297
+
298
+ function createGroundShadow() {
299
+ const options = { depthBuffer: true, generateMipmaps: false };
300
+ const renderTarget = new THREE.WebGLRenderTarget(SHADOW_RES, SHADOW_RES, options);
301
+ const blurTarget = new THREE.WebGLRenderTarget(SHADOW_RES, SHADOW_RES, options);
302
+
303
+ // Looks DOWN, one metre above the ground plane. Down rather than up because
304
+ // the display plane's own UVs run that way: an up-looking camera mirrors the
305
+ // texture in Z, which a symmetric blob hides completely and an offset shadow
306
+ // shows immediately by landing on the wrong side of the asset.
307
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.5, 1.5);
308
+ camera.rotation.x = -Math.PI / 2;
309
+
310
+ const castMaterial = new THREE.ShaderMaterial({
311
+ vertexShader: SHADOW_CAST_VERT,
312
+ fragmentShader: SHADOW_CAST_FRAG,
313
+ uniforms: { uBaseY: { value: 0 }, uFalloff: { value: 1 }, uSlide: { value: new THREE.Vector2() } },
314
+ side: THREE.DoubleSide,
315
+ // Everything lands on one plane, so there is no depth order to resolve -
316
+ // what is wanted is the DARKEST contribution over each point, which is a min
317
+ // blend. Ordinary alpha blending would instead stack every overlapping part
318
+ // and turn a lattice into a solid.
319
+ depthTest: false,
320
+ depthWrite: false,
321
+ blending: THREE.CustomBlending,
322
+ blendEquation: THREE.MinEquation,
323
+ blendSrc: THREE.OneFactor,
324
+ blendDst: THREE.OneFactor,
325
+ });
326
+
327
+ const blurScene = new THREE.Scene();
328
+ const blurCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
329
+ const blurMaterial = new THREE.ShaderMaterial({
330
+ vertexShader: BLUR_VERT,
331
+ fragmentShader: BLUR_FRAG,
332
+ uniforms: { uMap: { value: null }, uStep: { value: new THREE.Vector2() } },
333
+ depthTest: false,
334
+ depthWrite: false,
335
+ });
336
+ const blurQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), blurMaterial);
337
+ blurQuad.frustumCulled = false;
338
+ blurScene.add(blurQuad);
339
+
340
+ const showMaterial = new THREE.ShaderMaterial({
341
+ vertexShader: PLANE_VERT,
342
+ fragmentShader: SHADOW_SHOW_FRAG,
343
+ uniforms: { uMap: { value: blurTarget.texture }, uStrength: { value: SHADOW_STRENGTH } },
344
+ transparent: true,
345
+ depthWrite: false,
346
+ });
347
+ const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), showMaterial);
348
+ plane.rotation.x = -Math.PI / 2;
349
+ plane.renderOrder = -1;
350
+
351
+ return { renderTarget, blurTarget, camera, castMaterial, blurScene, blurCamera, blurMaterial, blurQuad, plane };
352
+ }
353
+
354
+ /**
355
+ * @param {{ container: HTMLElement, deterministic?: boolean, groundShadow?: boolean }} opts
356
+ *
357
+ * groundShadow is the one presentation switch an asset owns, and it exists
358
+ * because there is no geometric way to earn it: every asset's lowest point is
359
+ * its own bounding box floor, so a chandelier "stands on" the ground exactly as
360
+ * much as a table does. Left on, a hanging or wall-mounted asset gets a smear of
361
+ * shadow floating in mid-air under it, which reads as dirt on the lens.
362
+ */
363
+ export function createStage({ container, deterministic = false, groundShadow = true }) {
364
+ const renderer = new THREE.WebGLRenderer({
365
+ antialias: true,
366
+ // The gate runner reads pixels back after the frame; three's default
367
+ // discards the buffer on present.
368
+ preserveDrawingBuffer: deterministic,
369
+ });
370
+ renderer.setPixelRatio(deterministic ? 1 : Math.min(globalThis.devicePixelRatio || 1, MAX_DPR));
371
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
372
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
373
+ // Down from 1.0. The environment is a whole extra light source on top of what
374
+ // the old preset already had, and left at 1.0 the first pass blew the espresso
375
+ // machine's brushed steel to near-white - the roughness map was still there,
376
+ // it was just above the point where the highlight rolls off.
377
+ renderer.toneMappingExposure = 0.88;
378
+ // Shadows are on. The old preset had none because there was no floor for one
379
+ // to fall on, but that argument only ever covered the CAST shadow: with a real
380
+ // environment doing the ambient, self-shadowing is what tells an overhang from
381
+ // a recess, and the ground shadow below has no floor in it.
382
+ renderer.shadowMap.enabled = true;
383
+ // PCF, not PCFSoft: three deprecated PCFSoftShadowMap and now warns on it,
384
+ // which G7 counts as a failure. No loss here - the soft half of the look is
385
+ // the ground shadow below, and what the map is for is self-shadowing, where
386
+ // a crisp terminator is the point.
387
+ renderer.shadowMap.type = THREE.PCFShadowMap;
388
+ container.appendChild(renderer.domElement);
389
+
390
+ const scene = new THREE.Scene();
391
+ scene.background = null;
392
+ scene.environment = buildEnvironment(renderer);
393
+ // Raised 0.78 -> 0.94 for the 2026-08-13 brightness pass. The environment is
394
+ // the knob to reach for here rather than toneMappingExposure: exposure scales
395
+ // the specular highlight too, and 1.0 is the value already measured to blow
396
+ // the espresso machine's brushed steel (see below). Environment intensity
397
+ // lifts the diffuse and the ambient occlusion-facing sides - the parts that
398
+ // actually read as "dark" - and leaves the highlight roll-off where it is.
399
+ scene.environmentIntensity = 0.94;
400
+
401
+ const backdrop = createBackdrop();
402
+ const ground = groundShadow ? createGroundShadow() : null;
403
+
404
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.02, 200);
405
+
406
+ // The environment is now the PRIMARY light and the analytic lights are down
407
+ // hard from what they were (key 2.2 → 1.25, fill 0.85 → 0.2). The old preset
408
+ // had to carry the whole image on three lamps; this only needs a key sharp
409
+ // enough to cast and to put one specular edge on a curve.
410
+ //
411
+ // The hemisphere light is gone rather than reduced. It was one flat constant
412
+ // standing in for a room, and the environment has a room in it - a top panel,
413
+ // a floor bounce and a cool wall - so keeping both would just be adding the
414
+ // approximation back on top of the thing that replaced it.
415
+ // 1.65 -> 1.85 with the 2026-08-13 brightness pass: a small bump so the key
416
+ // keeps its lead over the raised environment. Lifting the environment alone
417
+ // flattens the form, because the lit side and the shaded side rise together.
418
+ const key = new THREE.DirectionalLight(0xfff3e2, 1.85);
419
+ key.castShadow = true;
420
+ key.shadow.mapSize.set(2048, 2048);
421
+ scene.add(key);
422
+ scene.add(key.target);
423
+
424
+ const fill = new THREE.DirectionalLight(0xe4eaf2, 0.2);
425
+ scene.add(fill);
426
+
427
+ // Everything the VIEWER draws that is not the asset. Hidden while gate G2
428
+ // counts draw calls, so the stage never spends the asset's budget.
429
+ const chrome = new THREE.Group();
430
+ chrome.name = "viewer-chrome";
431
+ if (ground) chrome.add(ground.plane);
432
+ scene.add(chrome);
433
+
434
+ const assetRoot = new THREE.Group();
435
+ assetRoot.name = "asset-root";
436
+ scene.add(assetRoot);
437
+
438
+ const orbit = createOrbit(camera, renderer.domElement, () => {
439
+ dirty = true;
440
+ });
441
+
442
+ let turntable = !deterministic;
443
+ let dirty = true;
444
+ let running = false;
445
+ let last = 0;
446
+
447
+ function resize() {
448
+ const w = container.clientWidth || 1;
449
+ const h = container.clientHeight || 1;
450
+ renderer.setSize(w, h, false);
451
+ camera.aspect = w / h;
452
+ camera.updateProjectionMatrix();
453
+ backdrop.material.uniforms.uAspect.value = w / h;
454
+ dirty = true;
455
+ }
456
+ const resizeObserver = new ResizeObserver(resize);
457
+ resizeObserver.observe(container);
458
+ resize();
459
+
460
+ function placeLights(box) {
461
+ const size = box.getSize(new THREE.Vector3());
462
+ const centre = box.getCenter(new THREE.Vector3());
463
+ const r = Math.max(size.length() * 0.6, 0.5);
464
+ const az = THREE.MathUtils.degToRad(KEY_AZIMUTH_DEG);
465
+ const el = THREE.MathUtils.degToRad(KEY_ELEVATION_DEG);
466
+ key.position.set(
467
+ centre.x + Math.cos(el) * Math.cos(az) * r * 4,
468
+ centre.y + Math.sin(el) * r * 4,
469
+ centre.z + Math.cos(el) * Math.sin(az) * r * 4,
470
+ );
471
+ key.target.position.copy(centre);
472
+ key.target.updateMatrixWorld();
473
+ fill.position.set(centre.x - r * 3, centre.y + r * 1.6, centre.z - r * 3.4);
474
+
475
+ // Fit the shadow camera to the asset instead of leaving it at three's 5-unit
476
+ // default: a 0.36 m traffic cone inside a 10 m frustum spends its whole 2048
477
+ // map on empty space and comes out as a staircase.
478
+ const extent = Math.max(size.length() * 0.62, 0.05);
479
+ const cam = key.shadow.camera;
480
+ cam.left = -extent;
481
+ cam.right = extent;
482
+ cam.top = extent;
483
+ cam.bottom = -extent;
484
+ cam.near = Math.max(r * 4 - extent * 2, 0.01);
485
+ cam.far = r * 8 + extent * 2;
486
+ cam.updateProjectionMatrix();
487
+ // Both biases scale with the asset: a constant that stops acne on a 3.6 m
488
+ // lantern peels the shadow clean off a 0.36 m cone.
489
+ key.shadow.bias = -0.00035 * Math.max(extent, 0.2);
490
+ key.shadow.normalBias = extent * 0.012;
491
+ }
492
+
493
+ /** Re-render the ground shadow. Cheap, and only on fit/explode - the
494
+ * turntable moves the CAMERA, so the shadow itself never changes. */
495
+ function bakeGroundShadow(box) {
496
+ if (!ground) return;
497
+ const size = box.getSize(new THREE.Vector3());
498
+ const centre = box.getCenter(new THREE.Vector3());
499
+ const baseY = box.min.y;
500
+ const falloff = Math.max(Math.min(size.y, Math.max(size.x, size.z)) * 0.9, 0.05);
501
+
502
+ // Where the top of the asset's shadow lands, and therefore how much ground
503
+ // the bake has to cover.
504
+ const el = THREE.MathUtils.degToRad(SHADOW_ELEVATION_DEG);
505
+ const az = THREE.MathUtils.degToRad(KEY_AZIMUTH_DEG);
506
+ const cot = Math.cos(el) / Math.sin(el);
507
+ const slideX = -Math.cos(az) * cot;
508
+ const slideZ = -Math.sin(az) * cot;
509
+ const reachX = slideX * size.y;
510
+ const reachZ = slideZ * size.y;
511
+
512
+ const pad = Math.max(size.x, size.z) * SHADOW_PAD;
513
+ const minX = Math.min(box.min.x, box.min.x + reachX) - pad;
514
+ const maxX = Math.max(box.max.x, box.max.x + reachX) + pad;
515
+ const minZ = Math.min(box.min.z, box.min.z + reachZ) - pad;
516
+ const maxZ = Math.max(box.max.z, box.max.z + reachZ) + pad;
517
+ // One square extent, so the texture's texels stay square and the two blur
518
+ // radii mean the same distance along both axes.
519
+ const half = Math.max(maxX - minX, maxZ - minZ) / 2 || 0.5;
520
+ const shadowCentre = new THREE.Vector3((minX + maxX) / 2, baseY, (minZ + maxZ) / 2);
521
+
522
+ ground.camera.left = -half;
523
+ ground.camera.right = half;
524
+ ground.camera.top = half;
525
+ ground.camera.bottom = -half;
526
+ ground.camera.position.set(shadowCentre.x, baseY + 1, shadowCentre.z);
527
+ ground.camera.updateProjectionMatrix();
528
+ ground.camera.updateMatrixWorld();
529
+
530
+ ground.castMaterial.uniforms.uBaseY.value = baseY;
531
+ ground.castMaterial.uniforms.uFalloff.value = falloff;
532
+ ground.castMaterial.uniforms.uSlide.value.set(slideX, slideZ);
533
+
534
+ const previousTarget = renderer.getRenderTarget();
535
+ const chromeWasVisible = chrome.visible;
536
+ const shadowsWereOn = renderer.shadowMap.enabled;
537
+ chrome.visible = false;
538
+ renderer.shadowMap.enabled = false;
539
+ scene.overrideMaterial = ground.castMaterial;
540
+ const clear = new THREE.Color(0xffffff);
541
+ const previousClear = renderer.getClearColor(new THREE.Color());
542
+ const previousAlpha = renderer.getClearAlpha();
543
+ renderer.setClearColor(clear, 1);
544
+ renderer.setRenderTarget(ground.renderTarget);
545
+ renderer.clear();
546
+ renderer.render(scene, ground.camera);
547
+ scene.overrideMaterial = null;
548
+
549
+ // Separable blur, ping-ponging between the two targets. Each radius is one
550
+ // horizontal pass and one vertical, and every pair leaves the result back in
551
+ // renderTarget - so however many radii the list holds, the display plane
552
+ // always samples the same texture.
553
+ for (const texels of SHADOW_BLUR_TEXELS) {
554
+ const step = texels / 4 / SHADOW_RES; // the kernel reaches +-4 steps
555
+ ground.blurMaterial.uniforms.uMap.value = ground.renderTarget.texture;
556
+ ground.blurMaterial.uniforms.uStep.value.set(step, 0);
557
+ renderer.setRenderTarget(ground.blurTarget);
558
+ renderer.render(ground.blurScene, ground.blurCamera);
559
+
560
+ ground.blurMaterial.uniforms.uMap.value = ground.blurTarget.texture;
561
+ ground.blurMaterial.uniforms.uStep.value.set(0, step);
562
+ renderer.setRenderTarget(ground.renderTarget);
563
+ renderer.render(ground.blurScene, ground.blurCamera);
564
+ }
565
+ ground.blurMaterial.uniforms.uMap.value = null;
566
+ ground.plane.material.uniforms.uMap.value = ground.renderTarget.texture;
567
+
568
+ renderer.setRenderTarget(previousTarget);
569
+ renderer.setClearColor(previousClear, previousAlpha);
570
+ renderer.shadowMap.enabled = shadowsWereOn;
571
+ chrome.visible = chromeWasVisible;
572
+
573
+ ground.plane.scale.set(half * 2, half * 2, 1);
574
+ ground.plane.position.set(shadowCentre.x, baseY + Math.max(size.y, 0.1) * 0.0015, shadowCentre.z);
575
+ ground.plane.updateMatrixWorld();
576
+ }
577
+
578
+ /** One frame: the backdrop first, then the scene over it. */
579
+ function draw() {
580
+ // autoClear is restored immediately: the gate passes render their own scenes
581
+ // into their own targets through this same renderer and rely on it.
582
+ renderer.autoClear = true;
583
+ backdrop.material.uniforms.uAspect.value = camera.aspect;
584
+ renderer.render(backdrop.scene, backdrop.camera);
585
+ renderer.autoClear = false;
586
+ renderer.render(scene, camera);
587
+ renderer.autoClear = true;
588
+ }
589
+
590
+ return {
591
+ renderer,
592
+ scene,
593
+ camera,
594
+ assetRoot,
595
+ chrome,
596
+ orbit,
597
+
598
+ /** Frame the camera, place the lights, bake the ground shadow. */
599
+ fit(box, mul = 1) {
600
+ // Shadows are the viewer's business, not the asset's - the modules already
601
+ // expose castShadow/receiveShadow for a game, and this turns them on for
602
+ // the presentation without the module having to know.
603
+ assetRoot.traverse((obj) => {
604
+ if (obj.isMesh) {
605
+ obj.castShadow = true;
606
+ obj.receiveShadow = true;
607
+ }
608
+ });
609
+ placeLights(box);
610
+ bakeGroundShadow(box);
611
+ orbit.frame(box, mul);
612
+ dirty = true;
613
+ },
614
+
615
+ /** Re-bake after the parts moved (explode), where the shadow really changed. */
616
+ refreshShadow(box) {
617
+ bakeGroundShadow(box);
618
+ dirty = true;
619
+ },
620
+
621
+ setTurntable(v) {
622
+ turntable = v;
623
+ dirty = true;
624
+ return turntable;
625
+ },
626
+ toggleTurntable() {
627
+ return this.setTurntable(!turntable);
628
+ },
629
+ get turntable() {
630
+ return turntable;
631
+ },
632
+ invalidate() {
633
+ dirty = true;
634
+ },
635
+ renderOnce() {
636
+ draw();
637
+ dirty = false;
638
+ },
639
+ start(onFrame) {
640
+ if (running) return;
641
+ running = true;
642
+ last = 0;
643
+ const tick = (t) => {
644
+ if (!running) return;
645
+ const dt = last ? Math.min((t - last) / 1000, 0.1) : 0;
646
+ last = t;
647
+ if (turntable && !orbit.dragging) {
648
+ orbit.spherical.theta += (Math.PI * 2 * dt) / TURNTABLE_PERIOD_S;
649
+ orbit.apply();
650
+ }
651
+ onFrame?.(dt);
652
+ // Render on demand: a still view costs nothing, which matters on a
653
+ // phone looking at a prop that is not moving.
654
+ if (dirty) {
655
+ draw();
656
+ dirty = false;
657
+ }
658
+ requestAnimationFrame(tick);
659
+ };
660
+ requestAnimationFrame(tick);
661
+ },
662
+ stop() {
663
+ running = false;
664
+ },
665
+ dispose() {
666
+ running = false;
667
+ resizeObserver.disconnect();
668
+ scene.environment?.dispose();
669
+ backdrop.mesh.geometry.dispose();
670
+ backdrop.material.dispose();
671
+ if (ground) {
672
+ ground.renderTarget.dispose();
673
+ ground.blurTarget.dispose();
674
+ ground.castMaterial.dispose();
675
+ ground.blurMaterial.dispose();
676
+ ground.blurQuad.geometry.dispose();
677
+ ground.plane.geometry.dispose();
678
+ ground.plane.material.dispose();
679
+ }
680
+ renderer.dispose();
681
+ },
682
+ };
683
+ }
684
+
685
+ /**
686
+ * Camera orbit, hand-rolled so the viewer needs no three/examples addon -
687
+ * the same rule the asset modules live under, applied to their host.
688
+ */
689
+ function createOrbit(camera, dom, onChange) {
690
+ const target = new THREE.Vector3();
691
+ const spherical = new THREE.Spherical(4, Math.PI * 0.38, Math.PI * 0.25);
692
+ const home = { radius: 4, phi: Math.PI * 0.38, theta: Math.PI * 0.25 };
693
+ let dragging = false;
694
+ let lastX = 0;
695
+ let lastY = 0;
696
+ let pointerId = null;
697
+
698
+ function apply() {
699
+ spherical.phi = THREE.MathUtils.clamp(spherical.phi, 0.08, Math.PI * 0.495);
700
+ spherical.radius = THREE.MathUtils.clamp(spherical.radius, home.radius * 0.25, home.radius * 6);
701
+ camera.position.setFromSpherical(spherical).add(target);
702
+ camera.lookAt(target);
703
+ onChange?.();
704
+ }
705
+
706
+ dom.addEventListener("pointerdown", (e) => {
707
+ if (pointerId !== null) return;
708
+ pointerId = e.pointerId;
709
+ dragging = true;
710
+ lastX = e.clientX;
711
+ lastY = e.clientY;
712
+ dom.setPointerCapture(e.pointerId);
713
+ });
714
+ dom.addEventListener("pointermove", (e) => {
715
+ if (!dragging || e.pointerId !== pointerId) return;
716
+ // Drag right turns the model to the right: one pan convention, everywhere.
717
+ spherical.theta -= (e.clientX - lastX) * 0.006;
718
+ spherical.phi -= (e.clientY - lastY) * 0.006;
719
+ lastX = e.clientX;
720
+ lastY = e.clientY;
721
+ apply();
722
+ });
723
+ const release = (e) => {
724
+ if (e.pointerId !== pointerId) return;
725
+ dragging = false;
726
+ pointerId = null;
727
+ };
728
+ dom.addEventListener("pointerup", release);
729
+ dom.addEventListener("pointercancel", release);
730
+ dom.addEventListener(
731
+ "wheel",
732
+ (e) => {
733
+ e.preventDefault();
734
+ spherical.radius *= Math.exp(e.deltaY * 0.0012);
735
+ apply();
736
+ },
737
+ { passive: false },
738
+ );
739
+
740
+ return {
741
+ get dragging() {
742
+ return dragging;
743
+ },
744
+ spherical,
745
+ target,
746
+ apply,
747
+ frame(box, distanceMul = 1) {
748
+ const centre = box.getCenter(new THREE.Vector3());
749
+ const size = box.getSize(new THREE.Vector3());
750
+ target.copy(centre);
751
+ // Aim slightly above the centroid, which seats the model a little below
752
+ // the middle of frame: a tall prop (the 3.6 m lantern) reads better with
753
+ // its mass low than dead-centred.
754
+ target.y = box.min.y + size.y * 0.55;
755
+
756
+ // Fit the bounding sphere against whichever field of view is narrower -
757
+ // on a phone in portrait that is the horizontal one, and framing off the
758
+ // vertical alone is what crops an asset off the sides.
759
+ const sphere = Math.max(size.length() * 0.5 + Math.abs(target.y - centre.y), 0.05);
760
+ const vFov = THREE.MathUtils.degToRad(camera.fov);
761
+ const hFov = 2 * Math.atan(Math.tan(vFov / 2) * Math.max(camera.aspect, 0.2));
762
+ home.radius = (sphere / Math.sin(Math.min(vFov, hFov) / 2)) * 1.22 * distanceMul;
763
+
764
+ spherical.radius = home.radius;
765
+ spherical.phi = home.phi;
766
+ spherical.theta = home.theta;
767
+ apply();
768
+ },
769
+ reset() {
770
+ spherical.radius = home.radius;
771
+ spherical.phi = home.phi;
772
+ spherical.theta = home.theta;
773
+ apply();
774
+ },
775
+ };
776
+ }