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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/index.js +288 -373
  2. package/package.json +1 -1
  3. package/templates/asset-viewer/asset.config.json +25 -0
  4. package/templates/asset-viewer/genex-asset.example.json +222 -0
  5. package/templates/asset-viewer/index.html +200 -0
  6. package/templates/asset-viewer/package.json +24 -0
  7. package/templates/asset-viewer/public/fonts/Geist-variable.woff2 +0 -0
  8. package/templates/asset-viewer/public/fonts/GeistMono-variable.woff2 +0 -0
  9. package/templates/asset-viewer/shared-files.sha256.json +14 -0
  10. package/templates/asset-viewer/src/asset/PLACEHOLDER.ts +237 -0
  11. package/templates/asset-viewer/src/main.js +149 -0
  12. package/templates/asset-viewer/src/viewer/gates-overlay.js +521 -0
  13. package/templates/asset-viewer/src/viewer/hud.js +73 -0
  14. package/templates/asset-viewer/src/viewer/stage.js +760 -0
  15. package/templates/asset-viewer/tools/emit-manifest.mjs +653 -0
  16. package/templates/asset-viewer/tools/gates.mjs +682 -0
  17. package/templates/asset-viewer/tools/stamp-manifest.mjs +134 -0
  18. package/templates/asset-viewer/vite.config.js +24 -0
  19. package/templates/skills/genex-ai-texture/SKILL.md +1 -1
  20. package/templates/skills/genex-ai-video/SKILL.md +1 -1
  21. package/templates/skills/genex-asset-author/SKILL.md +101 -0
  22. package/templates/skills/genex-game-director/references/routing-map.md +1 -1
  23. package/templates/skills/genex-getting-started/SKILL.md +2 -2
  24. package/templates/skills/genex-threejs-visual-validation/SKILL.md +3 -7
  25. package/templates/skills/genex-updates/SKILL.md +1 -1
  26. package/templates/skills/genex-monetization/SKILL.md +0 -261
@@ -0,0 +1,521 @@
1
+ // Measurement, and the hooks tools/gates.mjs drives under ?gates=1.
2
+ //
3
+ // This is NOT a debug bypass (AGENTS.md law 19). The viewer IS the product
4
+ // here, the numbers it exposes are the numbers the manifest publishes, and the
5
+ // hooks unlock no behaviour a visitor does not already have - they only ask the
6
+ // scene what it weighs. `measureStatic` is used by the plain viewer too, to
7
+ // fill the HUD.
8
+ //
9
+ // Copied verbatim into every asset project and hashed by gate G13.
10
+
11
+ import * as THREE from "three";
12
+
13
+ const SILHOUETTE_SIZE = 320;
14
+
15
+ export function isGatesMode() {
16
+ return new URLSearchParams(globalThis.location?.search ?? "").get("gates") === "1";
17
+ }
18
+
19
+ function textureBytes(texture) {
20
+ const image = texture.image;
21
+ if (!image) return 0;
22
+ if (image.data && typeof image.data.byteLength === "number") return image.data.byteLength;
23
+ const w = image.width ?? 0;
24
+ const h = image.height ?? 0;
25
+ return w * h * 4;
26
+ }
27
+
28
+ function collectMaterials(material, into) {
29
+ if (!material) return;
30
+ if (Array.isArray(material)) material.forEach((m) => collectMaterials(m, into));
31
+ else into.add(material);
32
+ }
33
+
34
+ /**
35
+ * Everything measurable without a render: the numbers gates G1, G3, G4, G5, G6
36
+ * and G15 read, plus the HUD's.
37
+ *
38
+ * @param {THREE.Object3D} root
39
+ */
40
+ export function measureStatic(root) {
41
+ root.updateMatrixWorld(true);
42
+
43
+ const geometries = new Set();
44
+ const materials = new Set();
45
+ const textures = new Set();
46
+ let triangles = 0;
47
+ let vertices = 0;
48
+ let meshes = 0;
49
+ let instancedMeshes = 0;
50
+ let staticDrawCalls = 0;
51
+
52
+ root.traverse((obj) => {
53
+ if (!obj.isMesh && !obj.isPoints && !obj.isLine) return;
54
+ const geometry = obj.geometry;
55
+ if (!geometry) return;
56
+ const instances = obj.isInstancedMesh ? obj.count : 1;
57
+ if (obj.isInstancedMesh) instancedMeshes += 1;
58
+ else if (obj.isMesh) meshes += 1;
59
+
60
+ if (!geometries.has(geometry)) {
61
+ geometries.add(geometry);
62
+ vertices += geometry.getAttribute("position")?.count ?? 0;
63
+ }
64
+ if (obj.isMesh) {
65
+ const index = geometry.getIndex();
66
+ const count = index ? index.count : (geometry.getAttribute("position")?.count ?? 0);
67
+ triangles += (count / 3) * instances;
68
+ }
69
+ // Geometry groups only cost extra draws when the material is an ARRAY -
70
+ // a BoxGeometry carries six groups and still renders in one call under a
71
+ // single material, which is what once made the HUD disagree with the
72
+ // manifest by a factor of two.
73
+ staticDrawCalls += Array.isArray(obj.material) ? Math.max(1, geometry.groups?.length ?? 1) : 1;
74
+ collectMaterials(obj.material, materials);
75
+ });
76
+
77
+ for (const material of materials) {
78
+ for (const value of Object.values(material)) {
79
+ if (value && value.isTexture) textures.add(value);
80
+ }
81
+ }
82
+
83
+ const box = new THREE.Box3().setFromObject(root);
84
+ const size = box.getSize(new THREE.Vector3());
85
+ const centre = box.getCenter(new THREE.Vector3());
86
+
87
+ // G6: a NaN in an attribute, or a non-finite bounding sphere, is what makes a
88
+ // mesh vanish nondeterministically under frustum culling.
89
+ const nanAttributes = [];
90
+ const badBoundingSpheres = [];
91
+ for (const geometry of geometries) {
92
+ for (const name of ["position", "normal", "uv"]) {
93
+ const attribute = geometry.getAttribute(name);
94
+ if (!attribute) continue;
95
+ const array = attribute.array;
96
+ for (let i = 0; i < array.length; i += 1) {
97
+ if (!Number.isFinite(array[i])) {
98
+ nanAttributes.push(`${geometry.name || geometry.type}.${name}[${i}]`);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ geometry.computeBoundingSphere();
104
+ const radius = geometry.boundingSphere?.radius;
105
+ if (!Number.isFinite(radius) || !(radius > 0)) {
106
+ badBoundingSpheres.push(geometry.name || geometry.type);
107
+ }
108
+ }
109
+
110
+ return {
111
+ triangles: Math.round(triangles),
112
+ vertices,
113
+ meshes,
114
+ instancedMeshes,
115
+ drawCalls: staticDrawCalls,
116
+ materials: materials.size,
117
+ textures: textures.size,
118
+ textureBytes: [...textures].reduce((sum, t) => sum + textureBytes(t), 0),
119
+ maxTextureDimension: [...textures].reduce(
120
+ (max, t) => Math.max(max, t.image?.width ?? 0, t.image?.height ?? 0),
121
+ 0,
122
+ ),
123
+ boundingBox: { min: box.min.toArray(), max: box.max.toArray() },
124
+ sizeMeters: size.toArray(),
125
+ centre: centre.toArray(),
126
+ nanAttributes,
127
+ badBoundingSpheres,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * G10's fingerprint: a stable hash over every geometry's vertex data, in
133
+ * traverse order. Two fresh page loads producing different hashes means the
134
+ * factory is not deterministic.
135
+ */
136
+ export function vertexHash(root) {
137
+ // FNV-1a, two independent offsets so the digest is 64 bits wide.
138
+ let a = 0x811c9dc5;
139
+ let b = 0x01000193;
140
+ const feed = (bytes) => {
141
+ for (let i = 0; i < bytes.length; i += 1) {
142
+ a = ((a ^ bytes[i]) * 0x01000193) >>> 0;
143
+ b = ((b + bytes[i]) * 0x85ebca6b) >>> 0;
144
+ }
145
+ };
146
+ const seen = new Set();
147
+ root.traverse((obj) => {
148
+ const geometry = obj.geometry;
149
+ if (!geometry || seen.has(geometry)) return;
150
+ seen.add(geometry);
151
+ for (const name of ["position", "normal", "uv", "color"]) {
152
+ const attribute = geometry.getAttribute(name);
153
+ if (!attribute) continue;
154
+ feed(new Uint8Array(attribute.array.buffer, attribute.array.byteOffset, attribute.array.byteLength));
155
+ }
156
+ const index = geometry.getIndex();
157
+ if (index) feed(new Uint8Array(index.array.buffer, index.array.byteOffset, index.array.byteLength));
158
+ });
159
+ return (a >>> 0).toString(16).padStart(8, "0") + (b >>> 0).toString(16).padStart(8, "0");
160
+ }
161
+
162
+ /**
163
+ * G17: does each triangle's WINDING agree with the normal the module supplies?
164
+ *
165
+ * This is the gate that actually catches the reversed-ring defect. G16 (below) was
166
+ * written for it first and measured as insufficient: reversing a CLOSED band does
167
+ * not empty the render, it just draws the far interior wall instead, so pixel
168
+ * coverage barely moves. Only an open surface or a cap disappears outright.
169
+ *
170
+ * Winding is checked against the shaded normal instead, which needs no renderer at
171
+ * all: cross(b-a, c-a) . normal(a) must be positive. These modules author analytic
172
+ * normals, so disagreement means the triangle is drawn facing the opposite way from
173
+ * the way it is lit - back-face culled where it should be solid, or lit inside-out
174
+ * where it is double-sided. Verified against a deliberately reversed part: 144 of
175
+ * 144 triangles disagree, while every intact part scores 0.
176
+ */
177
+ function windingConsistency(root) {
178
+ const meshes = [];
179
+ root.traverse((o) => {
180
+ if (!o.isMesh || !o.geometry) return;
181
+ const geometry = o.geometry;
182
+ const position = geometry.getAttribute("position");
183
+ const normal = geometry.getAttribute("normal");
184
+ if (!position || !normal) return;
185
+ const index = geometry.getIndex();
186
+ const count = index ? index.count : position.count;
187
+ const at = (i) => (index ? index.getX(i) : i);
188
+
189
+ const a = new THREE.Vector3();
190
+ const b = new THREE.Vector3();
191
+ const c = new THREE.Vector3();
192
+ const ab = new THREE.Vector3();
193
+ const ac = new THREE.Vector3();
194
+ const face = new THREE.Vector3();
195
+ const shaded = new THREE.Vector3();
196
+ const tmp = new THREE.Vector3();
197
+
198
+ let flipped = 0;
199
+ let tested = 0;
200
+ for (let i = 0; i + 2 < count; i += 3) {
201
+ a.fromBufferAttribute(position, at(i));
202
+ b.fromBufferAttribute(position, at(i + 1));
203
+ c.fromBufferAttribute(position, at(i + 2));
204
+ ab.subVectors(b, a);
205
+ ac.subVectors(c, a);
206
+ face.crossVectors(ab, ac);
207
+ if (face.lengthSq() === 0) continue; // degenerate sliver: no opinion
208
+ // Average all THREE corner normals, not just the first. At a sharp crease the
209
+ // corners carry very different averaged normals, and on an oblique triangle a
210
+ // single corner can disagree with a correctly-wound face - measured as 6 of 576
211
+ // triangles on the step ladder's treads, i.e. 1.04%, just over a 1% line and
212
+ // entirely an artefact of the measurement. The margin does the same job for the
213
+ // near-perpendicular case: only a clear disagreement counts.
214
+ shaded.fromBufferAttribute(normal, at(i));
215
+ tmp.fromBufferAttribute(normal, at(i + 1));
216
+ shaded.add(tmp);
217
+ tmp.fromBufferAttribute(normal, at(i + 2));
218
+ shaded.add(tmp);
219
+ if (shaded.lengthSq() === 0) continue; // opposed normals: no usable opinion
220
+ tested += 1;
221
+ if (face.normalize().dot(shaded.normalize()) < -0.05) flipped += 1;
222
+ }
223
+ meshes.push({
224
+ name: o.name || "(unnamed)",
225
+ triangles: tested,
226
+ flipped,
227
+ share: tested ? flipped / tested : 0,
228
+ });
229
+ });
230
+ return meshes;
231
+ }
232
+
233
+ /**
234
+ * G16: is every named part actually VISIBLE?
235
+ *
236
+ * Measured, not theorised: a sub-assembly lofted with its rings in the reverse
237
+ * order gets face normals pointing inward, so it is entirely back-face culled and
238
+ * renders as nothing - while still counting its meshes, its triangles and its draw
239
+ * calls. On the steel drum both bungs were invisible for three build cycles with
240
+ * G1-G15 reading all-pass throughout. Every count-based gate is structurally blind
241
+ * to it, because the geometry really is there.
242
+ *
243
+ * Each part is judged ALONE, with the camera fitted to that part's own bounds, so
244
+ * a rivet is held to the same standard as a barrel and a part that is legitimately
245
+ * enclosed inside another (a lamp inside a lantern) is not punished for it. The
246
+ * probe material copies each mesh's real `side`, so a deliberately double-sided
247
+ * shell - fabric, foliage - is judged the way it will actually be drawn rather
248
+ * than being failed for a convention it never adopted.
249
+ */
250
+ function partVisibility(stage, root, views) {
251
+ const { renderer } = stage;
252
+ const nodes = root.userData?.assetRuntime?.nodes ?? {};
253
+ const names = Object.keys(nodes);
254
+ if (names.length === 0) return [];
255
+
256
+ const originalVisible = new Map();
257
+ const originalMaterial = new Map();
258
+ root.traverse((o) => {
259
+ originalVisible.set(o, o.visible);
260
+ if (o.isMesh) {
261
+ originalMaterial.set(o, o.material);
262
+ const first = Array.isArray(o.material) ? o.material[0] : o.material;
263
+ o.material = new THREE.MeshBasicMaterial({ color: 0x000000, side: first?.side ?? THREE.FrontSide });
264
+ }
265
+ });
266
+
267
+ const scene = new THREE.Scene();
268
+ scene.background = new THREE.Color(0xffffff);
269
+ const parent = root.parent;
270
+ scene.add(root);
271
+
272
+ const target = new THREE.WebGLRenderTarget(SILHOUETTE_SIZE, SILHOUETTE_SIZE);
273
+ const buffer = new Uint8Array(SILHOUETTE_SIZE * SILHOUETTE_SIZE * 4);
274
+ const previousTarget = renderer.getRenderTarget();
275
+ const total = SILHOUETTE_SIZE * SILHOUETTE_SIZE;
276
+ const results = [];
277
+
278
+ for (const name of names) {
279
+ const part = nodes[name];
280
+ if (!part || !part.isObject3D) {
281
+ results.push({ name, bestShare: 0, skipped: "not an Object3D" });
282
+ continue;
283
+ }
284
+ // Hide every named part, then re-open only the path from the root down to this
285
+ // one: `nodes` may be nested, so blanket-hiding siblings could hide an ancestor.
286
+ for (const other of names) if (nodes[other]?.isObject3D) nodes[other].visible = false;
287
+ part.visible = true;
288
+ for (let a = part.parent; a && a !== root.parent; a = a.parent) a.visible = true;
289
+
290
+ const box = new THREE.Box3().setFromObject(part);
291
+ if (box.isEmpty()) {
292
+ results.push({ name, bestShare: 0, skipped: "empty bounds" });
293
+ continue;
294
+ }
295
+ const centre = box.getCenter(new THREE.Vector3());
296
+ const radius = Math.max(box.getSize(new THREE.Vector3()).length() * 0.5, 1e-4);
297
+ const camera = new THREE.PerspectiveCamera(35, 1, radius * 0.05, radius * 40);
298
+ const distance = radius / Math.tan(THREE.MathUtils.degToRad(35 / 2));
299
+ const elevation = THREE.MathUtils.degToRad(20);
300
+
301
+ let best = 0;
302
+ for (let i = 0; i < views; i += 1) {
303
+ const theta = (i / views) * Math.PI * 2;
304
+ camera.position.set(
305
+ centre.x + Math.cos(elevation) * Math.cos(theta) * distance,
306
+ centre.y + Math.sin(elevation) * distance,
307
+ centre.z + Math.cos(elevation) * Math.sin(theta) * distance,
308
+ );
309
+ camera.lookAt(centre);
310
+ renderer.setRenderTarget(target);
311
+ renderer.clear();
312
+ renderer.render(scene, camera);
313
+ renderer.readRenderTargetPixels(target, 0, 0, SILHOUETTE_SIZE, SILHOUETTE_SIZE, buffer);
314
+ let covered = 0;
315
+ for (let p = 0; p < buffer.length; p += 4) {
316
+ if (buffer[p] < 200 || buffer[p + 1] < 200 || buffer[p + 2] < 200) covered += 1;
317
+ }
318
+ if (covered > best) best = covered;
319
+ }
320
+ results.push({ name, bestPixels: best, bestShare: best / total });
321
+ }
322
+
323
+ renderer.setRenderTarget(previousTarget);
324
+ target.dispose();
325
+ scene.remove(root);
326
+ parent?.add(root);
327
+ root.traverse((o) => {
328
+ if (originalVisible.has(o)) o.visible = originalVisible.get(o);
329
+ if (o.isMesh && originalMaterial.has(o)) {
330
+ o.material.dispose();
331
+ o.material = originalMaterial.get(o);
332
+ }
333
+ });
334
+ return results;
335
+ }
336
+
337
+ /**
338
+ * G9: silhouette pixel area at eight turntable views, rendered flat black on
339
+ * white so a flat plane wearing a texture cannot fake volume.
340
+ */
341
+ function silhouetteAreas(stage, root, views) {
342
+ const { renderer } = stage;
343
+ const parent = root.parent;
344
+ const scene = new THREE.Scene();
345
+ scene.background = new THREE.Color(0xffffff);
346
+ scene.overrideMaterial = new THREE.MeshBasicMaterial({ color: 0x000000 });
347
+ scene.add(root);
348
+
349
+ const target = new THREE.WebGLRenderTarget(SILHOUETTE_SIZE, SILHOUETTE_SIZE);
350
+ const box = new THREE.Box3().setFromObject(root);
351
+ const centre = box.getCenter(new THREE.Vector3());
352
+ const radius = Math.max(box.getSize(new THREE.Vector3()).length() * 0.5, 0.05);
353
+ const camera = new THREE.PerspectiveCamera(35, 1, radius * 0.05, radius * 40);
354
+ const distance = radius / Math.tan(THREE.MathUtils.degToRad(35 / 2));
355
+ const elevation = THREE.MathUtils.degToRad(14);
356
+
357
+ const previousTarget = renderer.getRenderTarget();
358
+ const buffer = new Uint8Array(SILHOUETTE_SIZE * SILHOUETTE_SIZE * 4);
359
+ const areas = [];
360
+ for (let i = 0; i < views; i += 1) {
361
+ const theta = (i / views) * Math.PI * 2;
362
+ camera.position.set(
363
+ centre.x + Math.cos(elevation) * Math.cos(theta) * distance,
364
+ centre.y + Math.sin(elevation) * distance,
365
+ centre.z + Math.cos(elevation) * Math.sin(theta) * distance,
366
+ );
367
+ camera.lookAt(centre);
368
+ renderer.setRenderTarget(target);
369
+ renderer.clear();
370
+ renderer.render(scene, camera);
371
+ renderer.readRenderTargetPixels(target, 0, 0, SILHOUETTE_SIZE, SILHOUETTE_SIZE, buffer);
372
+ let covered = 0;
373
+ for (let p = 0; p < buffer.length; p += 4) {
374
+ if (buffer[p] < 200 || buffer[p + 1] < 200 || buffer[p + 2] < 200) covered += 1;
375
+ }
376
+ areas.push(covered);
377
+ }
378
+ renderer.setRenderTarget(previousTarget);
379
+ target.dispose();
380
+ scene.overrideMaterial.dispose();
381
+
382
+ scene.remove(root);
383
+ parent?.add(root);
384
+ return areas;
385
+ }
386
+
387
+ /**
388
+ * G12: build a second instance, upload it, dispose it, and check that the
389
+ * renderer's geometry and texture counts come back to where they started.
390
+ */
391
+ function disposalCheck(stage, factory) {
392
+ const { renderer, scene, camera } = stage;
393
+ renderer.render(scene, camera);
394
+ const before = { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures };
395
+
396
+ const probe = factory();
397
+ scene.add(probe);
398
+ renderer.render(scene, camera);
399
+ const peak = { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures };
400
+
401
+ const dispose = probe.userData?.dispose;
402
+ const hasDispose = typeof dispose === "function";
403
+ if (hasDispose) dispose();
404
+ scene.remove(probe);
405
+ renderer.render(scene, camera);
406
+ const after = { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures };
407
+
408
+ return {
409
+ hasDispose,
410
+ before,
411
+ peak,
412
+ after,
413
+ uploadedGeometries: peak.geometries - before.geometries,
414
+ passed: hasDispose && after.geometries === before.geometries && after.textures === before.textures,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Empirical draw calls, counted for the ASSET alone. Three things the stage
420
+ * draws are excluded, because none of them is the asset's cost to carry and a
421
+ * game pays none of them:
422
+ *
423
+ * - the shadow pass. renderer.info.render.calls accumulates it, and with it
424
+ * counted every asset was reported at exactly double.
425
+ * - the viewer's own chrome (the ground-shadow plane).
426
+ * - the backdrop, which is drawn into a separate scene by stage.draw() and so
427
+ * is already absent from a bare renderer.render(scene, camera) like this one.
428
+ *
429
+ * Frustum culling is suspended for the pass, or a part off-camera would flatter
430
+ * the number.
431
+ */
432
+ function measureDrawCalls(stage, root) {
433
+ const culled = [];
434
+ root.traverse((obj) => {
435
+ culled.push([obj, obj.frustumCulled]);
436
+ obj.frustumCulled = false;
437
+ });
438
+ const shadows = stage.renderer.shadowMap.enabled;
439
+ const chromeVisible = stage.chrome ? stage.chrome.visible : false;
440
+ stage.renderer.shadowMap.enabled = false;
441
+ if (stage.chrome) stage.chrome.visible = false;
442
+ stage.renderer.render(stage.scene, stage.camera);
443
+ const calls = stage.renderer.info.render.calls;
444
+ stage.renderer.shadowMap.enabled = shadows;
445
+ if (stage.chrome) stage.chrome.visible = chromeVisible;
446
+ for (const [obj, value] of culled) obj.frustumCulled = value;
447
+ stage.renderer.render(stage.scene, stage.camera);
448
+ return calls;
449
+ }
450
+
451
+ /**
452
+ * Publish the measurements on window.__ASSET_GATES__ for tools/gates.mjs.
453
+ * Only called under ?gates=1.
454
+ */
455
+ export function installGateHooks({ stage, root, factory, config, statics }) {
456
+ const measurements = statics ?? measureStatic(root);
457
+ const api = {
458
+ ready: true,
459
+ runner: "asset-gates@1",
460
+ slug: config.slug,
461
+ name: config.name,
462
+ threeRevision: THREE.REVISION,
463
+ statedSizeMeters: config.statedSizeMeters,
464
+ geometry: {
465
+ triangles: measurements.triangles,
466
+ vertices: measurements.vertices,
467
+ meshes: measurements.meshes,
468
+ instancedMeshes: measurements.instancedMeshes,
469
+ drawCalls: measureDrawCalls(stage, root),
470
+ materials: measurements.materials,
471
+ textures: measurements.textures,
472
+ textureBytes: measurements.textureBytes,
473
+ },
474
+ maxTextureDimension: measurements.maxTextureDimension,
475
+ space: {
476
+ boundingBox: measurements.boundingBox,
477
+ sizeMeters: measurements.sizeMeters,
478
+ centre: measurements.centre,
479
+ },
480
+ numeric: {
481
+ nanAttributes: measurements.nanAttributes,
482
+ badBoundingSpheres: measurements.badBoundingSpheres,
483
+ },
484
+ vertexHash: vertexHash(root),
485
+ userData: {
486
+ hasAssetRuntime: !!root.userData?.assetRuntime,
487
+ hasAssetInfo: !!root.userData?.assetInfo,
488
+ hasDispose: typeof root.userData?.dispose === "function",
489
+ nodeNames: Object.keys(root.userData?.assetRuntime?.nodes ?? {}),
490
+ rootName: root.name,
491
+ },
492
+ silhouettes(views = 8) {
493
+ return silhouetteAreas(stage, root, views);
494
+ },
495
+ partVisibility(views = 8) {
496
+ return partVisibility(stage, root, views);
497
+ },
498
+ winding() {
499
+ return windingConsistency(root);
500
+ },
501
+ disposal() {
502
+ return disposalCheck(stage, factory);
503
+ },
504
+ renderViews(views = 8) {
505
+ // Exercise every angle once so G7 sees any shader or culling warning the
506
+ // asset only produces from a particular direction.
507
+ const box = new THREE.Box3().setFromObject(root);
508
+ for (let i = 0; i < views; i += 1) {
509
+ stage.orbit.spherical.theta = (i / views) * Math.PI * 2;
510
+ stage.orbit.spherical.phi = i % 2 === 0 ? Math.PI * 0.42 : Math.PI * 0.2;
511
+ stage.orbit.apply();
512
+ stage.renderOnce();
513
+ }
514
+ stage.fit(box, config.viewer?.cameraDistanceMul ?? 1);
515
+ stage.renderOnce();
516
+ return views;
517
+ },
518
+ };
519
+ globalThis.__ASSET_GATES__ = api;
520
+ return api;
521
+ }
@@ -0,0 +1,73 @@
1
+ // The viewer HUD: name, real-world dimensions, budget numbers, MIT chip.
2
+ // Pure CSS out of index.html - this is viewer chrome, not a game HUD, so the
3
+ // sprite-HUD pipeline does not apply.
4
+ //
5
+ // Copied verbatim into every asset project and hashed by gate G13.
6
+
7
+ const NUM = new Intl.NumberFormat("en-US");
8
+
9
+ function chip(label, value) {
10
+ const el = document.createElement("span");
11
+ el.className = "chip";
12
+ if (value == null) {
13
+ el.textContent = label;
14
+ } else {
15
+ el.append(`${label} `);
16
+ const strong = document.createElement("strong");
17
+ strong.textContent = value;
18
+ el.append(strong);
19
+ }
20
+ return el;
21
+ }
22
+
23
+ function formatSizeMeters(size) {
24
+ if (!size) return " - ";
25
+ return `${size.map((n) => n.toFixed(2)).join(" × ")} m`;
26
+ }
27
+
28
+ /**
29
+ * A zero-height flex item that fills the row, so everything after it starts on
30
+ * a new line. The two groups mean different things - what the object IS (size,
31
+ * triangles) and what it COSTS to draw - and left to wrap on its own the split
32
+ * lands wherever this particular asset's digits happen to reach.
33
+ */
34
+ function lineBreak() {
35
+ const el = document.createElement("span");
36
+ el.className = "chip-break";
37
+ return el;
38
+ }
39
+
40
+ export function createHud(root) {
41
+ const nameEl = root.querySelector("#hud-name");
42
+ const summaryEl = root.querySelector("#hud-summary");
43
+ const rowsEl = root.querySelector("#hud-rows");
44
+
45
+ return {
46
+ setAsset({ name, summary }) {
47
+ nameEl.textContent = name;
48
+ summaryEl.textContent = summary ?? "";
49
+ },
50
+ /**
51
+ * @param {{ sizeMeters: number[]|null, triangles: number, drawCalls: number, materials: number }} stats
52
+ */
53
+ setStats(stats) {
54
+ rowsEl.replaceChildren(
55
+ chip("size", formatSizeMeters(stats.sizeMeters)),
56
+ chip("tris", NUM.format(stats.triangles)),
57
+ lineBreak(),
58
+ chip("draws", NUM.format(stats.drawCalls)),
59
+ chip("materials", NUM.format(stats.materials)),
60
+ );
61
+ const license = chip("MIT");
62
+ license.classList.add("chip--license");
63
+ rowsEl.append(license);
64
+ },
65
+ };
66
+ }
67
+
68
+ export function showFault(message) {
69
+ const el = document.getElementById("fault");
70
+ if (!el) return;
71
+ el.style.display = "grid";
72
+ el.textContent = message;
73
+ }