@genex-ai/cli-demo 1.5.2-dev.398 → 1.6.0-dev.399
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.
- package/dist/index.js +288 -10
- package/package.json +1 -1
- package/templates/asset-viewer/asset.config.json +25 -0
- package/templates/asset-viewer/genex-asset.example.json +222 -0
- package/templates/asset-viewer/index.html +200 -0
- package/templates/asset-viewer/package.json +24 -0
- package/templates/asset-viewer/public/fonts/Geist-variable.woff2 +0 -0
- package/templates/asset-viewer/public/fonts/GeistMono-variable.woff2 +0 -0
- package/templates/asset-viewer/shared-files.sha256.json +14 -0
- package/templates/asset-viewer/src/asset/PLACEHOLDER.ts +237 -0
- package/templates/asset-viewer/src/main.js +149 -0
- package/templates/asset-viewer/src/viewer/gates-overlay.js +521 -0
- package/templates/asset-viewer/src/viewer/hud.js +73 -0
- package/templates/asset-viewer/src/viewer/stage.js +760 -0
- package/templates/asset-viewer/tools/emit-manifest.mjs +653 -0
- package/templates/asset-viewer/tools/gates.mjs +682 -0
- package/templates/asset-viewer/tools/stamp-manifest.mjs +134 -0
- package/templates/asset-viewer/vite.config.js +24 -0
- package/templates/skills/genex-asset-author/SKILL.md +101 -0
- package/templates/skills/genex-game-director/references/routing-map.md +1 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// The headless gate runner - AG-859 §7, gates G1…G17.
|
|
2
|
+
//
|
|
3
|
+
// Serves the built dist/ on an ephemeral port, drives it with headless
|
|
4
|
+
// chromium, and measures the asset instead of trusting it. A gate failure
|
|
5
|
+
// blocks the stamp, which blocks the deploy.
|
|
6
|
+
//
|
|
7
|
+
// exit 0 every gate passed
|
|
8
|
+
// exit 1 a gate failed
|
|
9
|
+
// exit 2 the runner itself could not complete
|
|
10
|
+
//
|
|
11
|
+
// Copied verbatim into every asset project and hashed by gate G13. Edit it in
|
|
12
|
+
// packages/asset-viewer-template/template/tools.
|
|
13
|
+
//
|
|
14
|
+
// Usage: node tools/gates.mjs [--dist dist] [--out .gates/report.json]
|
|
15
|
+
// [--skip-paste-run] [--views 8]
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import fsp from "node:fs/promises";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
MANIFEST_FILENAME,
|
|
27
|
+
MANIFEST_MAX_BYTES,
|
|
28
|
+
PARITY_FILENAME,
|
|
29
|
+
findAssetSource,
|
|
30
|
+
hashSharedFiles,
|
|
31
|
+
parseImports,
|
|
32
|
+
readAssetConfig,
|
|
33
|
+
scanForbidden,
|
|
34
|
+
sha256,
|
|
35
|
+
usesBareMathRandom,
|
|
36
|
+
validateManifest,
|
|
37
|
+
} from "./emit-manifest.mjs";
|
|
38
|
+
|
|
39
|
+
const PROJECT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
40
|
+
const RUNNER_ID = "asset-gates@1";
|
|
41
|
+
const SILHOUETTE_VIEWS_DEFAULT = 8;
|
|
42
|
+
/** img2threejs DEFAULT_COLLAPSE_RATIO: below this a view is a collapsed silhouette. */
|
|
43
|
+
const COLLAPSE_RATIO_DEFAULT = 0.15;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// tiny helpers
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const args = { dist: "dist", out: path.join(".gates", "report.json"), views: SILHOUETTE_VIEWS_DEFAULT, pasteRun: true };
|
|
51
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === "--dist") args.dist = argv[++i];
|
|
54
|
+
else if (a === "--out") args.out = argv[++i];
|
|
55
|
+
else if (a === "--views") args.views = Number(argv[++i]);
|
|
56
|
+
else if (a === "--skip-paste-run") args.pasteRun = false;
|
|
57
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
58
|
+
else throw new Error(`unknown flag: ${a}`);
|
|
59
|
+
}
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MIME = {
|
|
64
|
+
".html": "text/html; charset=utf-8",
|
|
65
|
+
".js": "text/javascript; charset=utf-8",
|
|
66
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
67
|
+
".css": "text/css; charset=utf-8",
|
|
68
|
+
".json": "application/json; charset=utf-8",
|
|
69
|
+
".png": "image/png",
|
|
70
|
+
".jpg": "image/jpeg",
|
|
71
|
+
".svg": "image/svg+xml",
|
|
72
|
+
".wasm": "application/wasm",
|
|
73
|
+
".ktx2": "image/ktx2",
|
|
74
|
+
".glb": "model/gltf-binary",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function serveDir(root) {
|
|
78
|
+
const server = http.createServer(async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
81
|
+
let rel = decodeURIComponent(url.pathname);
|
|
82
|
+
if (rel.endsWith("/")) rel += "index.html";
|
|
83
|
+
const abs = path.join(root, path.normalize(rel).replace(/^([/\\])+/, ""));
|
|
84
|
+
if (!abs.startsWith(root)) {
|
|
85
|
+
res.writeHead(403).end("forbidden");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const body = await fsp.readFile(abs);
|
|
89
|
+
res.writeHead(200, { "content-type": MIME[path.extname(abs)] ?? "application/octet-stream" }).end(body);
|
|
90
|
+
} catch {
|
|
91
|
+
res.writeHead(404).end("not found");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
server.on("error", reject);
|
|
96
|
+
server.listen(0, "127.0.0.1", () => {
|
|
97
|
+
const { port } = server.address();
|
|
98
|
+
resolve({ server, origin: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadChromium() {
|
|
104
|
+
const mod = await import("playwright-core");
|
|
105
|
+
return (mod.default ?? mod).chromium;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function launchOptions() {
|
|
109
|
+
return {
|
|
110
|
+
args: [
|
|
111
|
+
// Headless chromium has no GPU; SwiftShader is what gives it WebGL2.
|
|
112
|
+
"--enable-unsafe-swiftshader",
|
|
113
|
+
"--use-angle=swiftshader",
|
|
114
|
+
"--disable-lcd-text",
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function run(cmd, args, cwd) {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
const child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
122
|
+
let out = "";
|
|
123
|
+
child.stdout.on("data", (d) => (out += d));
|
|
124
|
+
child.stderr.on("data", (d) => (out += d));
|
|
125
|
+
child.on("error", (err) => resolve({ code: -1, output: String(err) }));
|
|
126
|
+
child.on("close", (code) => resolve({ code, output: out }));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* SwiftShader narrates its own performance characteristics ("GPU stall due to
|
|
132
|
+
* ReadPixels") whenever the runner reads a frame back. That is the harness
|
|
133
|
+
* talking about itself on a software GPU, not the asset misbehaving, so G7
|
|
134
|
+
* ignores driver PERFORMANCE messages only - every driver error and every
|
|
135
|
+
* three.js warning still fails the gate.
|
|
136
|
+
*/
|
|
137
|
+
function isHarnessNoise(text) {
|
|
138
|
+
return /GL Driver Message \((?:OpenGL|OpenGL ES|Vulkan), Performance,/.test(text);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function pct(measured, stated) {
|
|
142
|
+
return stated === 0 ? 0 : ((measured - stated) / stated) * 100;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function round(value, places = 4) {
|
|
146
|
+
const f = 10 ** places;
|
|
147
|
+
return Math.round(value * f) / f;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// G8 - paste-and-run
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The Copy-code button's promise, tested the way a stranger would exercise it:
|
|
156
|
+
* a scratch project whose only resolvable dependency is three, the manifest's
|
|
157
|
+
* source.code pasted verbatim, `tsc --noEmit --strict`, then a real headless
|
|
158
|
+
* render that has to put pixels on the screen.
|
|
159
|
+
*/
|
|
160
|
+
async function pasteAndRun({ manifest, browser, projectDir }) {
|
|
161
|
+
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), "genex-asset-g8-"));
|
|
162
|
+
const detail = { scratch };
|
|
163
|
+
try {
|
|
164
|
+
const filename = manifest.source.filename;
|
|
165
|
+
const base = filename.replace(/\.ts$/, "");
|
|
166
|
+
await fsp.mkdir(path.join(scratch, "src"), { recursive: true });
|
|
167
|
+
await fsp.mkdir(path.join(scratch, "node_modules", "@types"), { recursive: true });
|
|
168
|
+
|
|
169
|
+
// Only three is linked in. If the pasted module reaches for anything else,
|
|
170
|
+
// both the typecheck and the bundle fail - which is the point.
|
|
171
|
+
const link = async (from, to) => {
|
|
172
|
+
const src = path.join(projectDir, "node_modules", from);
|
|
173
|
+
if (!fs.existsSync(src)) throw new Error(`G8 needs ${from} installed in the asset project (npm install first)`);
|
|
174
|
+
await fsp.symlink(src, path.join(scratch, "node_modules", to), "dir");
|
|
175
|
+
};
|
|
176
|
+
await link("three", "three");
|
|
177
|
+
await link(path.join("@types", "three"), path.join("@types", "three"));
|
|
178
|
+
|
|
179
|
+
await fsp.writeFile(path.join(scratch, "src", filename), manifest.source.code, "utf8");
|
|
180
|
+
await fsp.writeFile(
|
|
181
|
+
path.join(scratch, "package.json"),
|
|
182
|
+
JSON.stringify({ name: "asset-paste-run", private: true, version: "0.0.0", type: "module" }, null, 2),
|
|
183
|
+
"utf8",
|
|
184
|
+
);
|
|
185
|
+
await fsp.writeFile(
|
|
186
|
+
path.join(scratch, "tsconfig.json"),
|
|
187
|
+
JSON.stringify(
|
|
188
|
+
{
|
|
189
|
+
compilerOptions: {
|
|
190
|
+
target: "ES2022",
|
|
191
|
+
module: "ESNext",
|
|
192
|
+
moduleResolution: "bundler",
|
|
193
|
+
// No node types: an asset module must compile in a plain browser or
|
|
194
|
+
// worker project, which is where most adopters will paste it.
|
|
195
|
+
types: [],
|
|
196
|
+
lib: ["ES2022", "DOM"],
|
|
197
|
+
strict: true,
|
|
198
|
+
noEmit: true,
|
|
199
|
+
skipLibCheck: true,
|
|
200
|
+
},
|
|
201
|
+
include: ["src"],
|
|
202
|
+
},
|
|
203
|
+
null,
|
|
204
|
+
2,
|
|
205
|
+
),
|
|
206
|
+
"utf8",
|
|
207
|
+
);
|
|
208
|
+
// A plain object, deliberately NOT `defineConfig` - importing 'vite' here
|
|
209
|
+
// would need vite resolvable from the scratch project, and the whole point
|
|
210
|
+
// of G8 is that three is the only thing that resolves.
|
|
211
|
+
await fsp.writeFile(
|
|
212
|
+
path.join(scratch, "vite.config.js"),
|
|
213
|
+
'export default { base: "./", build: { outDir: "dist", target: "es2022" }, logLevel: "error" };\n',
|
|
214
|
+
"utf8",
|
|
215
|
+
);
|
|
216
|
+
await fsp.writeFile(
|
|
217
|
+
path.join(scratch, "index.html"),
|
|
218
|
+
'<!doctype html><html><body><script type="module" src="./src/main.ts"></script></body></html>\n',
|
|
219
|
+
"utf8",
|
|
220
|
+
);
|
|
221
|
+
await fsp.writeFile(
|
|
222
|
+
path.join(scratch, "src", "main.ts"),
|
|
223
|
+
[
|
|
224
|
+
"import * as THREE from 'three';",
|
|
225
|
+
`import { ${manifest.source.entry} } from './${base}';`,
|
|
226
|
+
"",
|
|
227
|
+
"const CLEAR = 0x101820;",
|
|
228
|
+
"const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true });",
|
|
229
|
+
"renderer.setSize(320, 320, false);",
|
|
230
|
+
"renderer.setClearColor(CLEAR, 1);",
|
|
231
|
+
"document.body.appendChild(renderer.domElement);",
|
|
232
|
+
"const scene = new THREE.Scene();",
|
|
233
|
+
"scene.add(new THREE.HemisphereLight(0xffffff, 0x333333, 2.0));",
|
|
234
|
+
`const model = ${manifest.source.entry}();`,
|
|
235
|
+
"scene.add(model);",
|
|
236
|
+
"const box = new THREE.Box3().setFromObject(model);",
|
|
237
|
+
"const centre = box.getCenter(new THREE.Vector3());",
|
|
238
|
+
"const radius = Math.max(box.getSize(new THREE.Vector3()).length() * 0.5, 0.05);",
|
|
239
|
+
"const camera = new THREE.PerspectiveCamera(40, 1, radius * 0.02, radius * 40);",
|
|
240
|
+
"camera.position.set(centre.x + radius * 2.2, centre.y + radius * 1.2, centre.z + radius * 2.2);",
|
|
241
|
+
"camera.lookAt(centre);",
|
|
242
|
+
"renderer.render(scene, camera);",
|
|
243
|
+
"const gl = renderer.getContext();",
|
|
244
|
+
"const pixels = new Uint8Array(320 * 320 * 4);",
|
|
245
|
+
"gl.readPixels(0, 0, 320, 320, gl.RGBA, gl.UNSIGNED_BYTE, pixels);",
|
|
246
|
+
"let lit = 0;",
|
|
247
|
+
"for (let i = 0; i < pixels.length; i += 4) {",
|
|
248
|
+
" const dr = Math.abs(pixels[i] - 0x10), dg = Math.abs(pixels[i + 1] - 0x18), db = Math.abs(pixels[i + 2] - 0x20);",
|
|
249
|
+
" if (dr + dg + db > 12) lit += 1;",
|
|
250
|
+
"}",
|
|
251
|
+
"(window as unknown as { __PASTE_RUN__: unknown }).__PASTE_RUN__ = { nonBackgroundPixels: lit, entry: model.name };",
|
|
252
|
+
"",
|
|
253
|
+
].join("\n"),
|
|
254
|
+
"utf8",
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const bin = (name) => path.join(projectDir, "node_modules", ".bin", name);
|
|
258
|
+
const tsc = await run(bin("tsc"), ["--noEmit", "--project", "tsconfig.json"], scratch);
|
|
259
|
+
detail.typecheck = { code: tsc.code, output: tsc.output.trim().slice(0, 4000) };
|
|
260
|
+
if (tsc.code !== 0) {
|
|
261
|
+
return { passed: false, measured: "tsc --noEmit --strict failed", detail };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const build = await run(bin("vite"), ["build"], scratch);
|
|
265
|
+
detail.build = { code: build.code, output: build.output.trim().slice(-4000) };
|
|
266
|
+
if (build.code !== 0) {
|
|
267
|
+
return { passed: false, measured: "vite build of the pasted module failed", detail };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const site = await serveDir(path.join(scratch, "dist"));
|
|
271
|
+
try {
|
|
272
|
+
const page = await browser.newPage({ viewport: { width: 360, height: 360 } });
|
|
273
|
+
const errors = [];
|
|
274
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
275
|
+
page.on("console", (m) => {
|
|
276
|
+
if (m.type() === "error" && !isHarnessNoise(m.text())) errors.push(m.text());
|
|
277
|
+
});
|
|
278
|
+
await page.goto(site.origin, { waitUntil: "load" });
|
|
279
|
+
await page.waitForFunction(() => Boolean(window.__PASTE_RUN__), null, { timeout: 30_000 });
|
|
280
|
+
const result = await page.evaluate(() => window.__PASTE_RUN__);
|
|
281
|
+
await page.close();
|
|
282
|
+
detail.render = result;
|
|
283
|
+
detail.errors = errors;
|
|
284
|
+
const passed = result.nonBackgroundPixels >= 1 && errors.length === 0;
|
|
285
|
+
return {
|
|
286
|
+
passed,
|
|
287
|
+
measured: `${result.nonBackgroundPixels} non-background px, ${errors.length} console errors`,
|
|
288
|
+
detail,
|
|
289
|
+
};
|
|
290
|
+
} finally {
|
|
291
|
+
await site.close();
|
|
292
|
+
}
|
|
293
|
+
} finally {
|
|
294
|
+
await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// main
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
async function main() {
|
|
303
|
+
const args = parseArgs(process.argv.slice(2));
|
|
304
|
+
if (args.help) {
|
|
305
|
+
console.log("usage: node tools/gates.mjs [--dist dist] [--out .gates/report.json] [--views 8] [--skip-paste-run]");
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const distDir = path.resolve(PROJECT_DIR, args.dist);
|
|
310
|
+
const manifestPath = path.join(distDir, MANIFEST_FILENAME);
|
|
311
|
+
if (!fs.existsSync(manifestPath)) {
|
|
312
|
+
throw new Error(`no ${MANIFEST_FILENAME} in ${args.dist} - run \`npm run build\` first`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const config = readAssetConfig(PROJECT_DIR);
|
|
316
|
+
const manifest = JSON.parse(await fsp.readFile(manifestPath, "utf8"));
|
|
317
|
+
const source = findAssetSource(PROJECT_DIR);
|
|
318
|
+
|
|
319
|
+
const results = [];
|
|
320
|
+
const add = (id, name, threshold, passed, measured, detail) =>
|
|
321
|
+
results.push({ id, name, threshold, measured, passed, ...(detail ? { detail } : {}) });
|
|
322
|
+
|
|
323
|
+
// --- G13, G11, G10a: pure filesystem, no browser needed -------------------
|
|
324
|
+
const parityPath = path.join(PROJECT_DIR, PARITY_FILENAME);
|
|
325
|
+
if (fs.existsSync(parityPath)) {
|
|
326
|
+
const recorded = JSON.parse(await fsp.readFile(parityPath, "utf8"));
|
|
327
|
+
const actual = hashSharedFiles(PROJECT_DIR);
|
|
328
|
+
const drifted = Object.keys(recorded.files ?? {}).filter((rel) => recorded.files[rel] !== actual[rel]);
|
|
329
|
+
add(
|
|
330
|
+
"G13",
|
|
331
|
+
"shared-viewer-parity",
|
|
332
|
+
"sha256 of the shared viewer files equals the template's record",
|
|
333
|
+
drifted.length === 0,
|
|
334
|
+
drifted.length === 0 ? "in sync" : `drifted: ${drifted.join(", ")}`,
|
|
335
|
+
{ templateVersion: recorded.templateVersion ?? null, drifted },
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
add("G13", "shared-viewer-parity", "viewer-parity.json present", false, `${PARITY_FILENAME} is missing`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const imports = parseImports(source.code);
|
|
342
|
+
const forbidden = scanForbidden(source.code);
|
|
343
|
+
const importsOk = imports.length === 1 && imports[0] === "three";
|
|
344
|
+
add(
|
|
345
|
+
"G11",
|
|
346
|
+
"self-containment",
|
|
347
|
+
'imports === ["three"]; no document/window/fetch/TextureLoader/CanvasTexture/three-examples/http',
|
|
348
|
+
importsOk && forbidden.length === 0,
|
|
349
|
+
importsOk && forbidden.length === 0
|
|
350
|
+
? "three only"
|
|
351
|
+
: `imports: [${imports.join(", ")}]${forbidden.length ? `; banned: ${forbidden.join(", ")}` : ""}`,
|
|
352
|
+
{ imports, forbidden, note: "scanned with comments stripped, so the MIT header cannot trip a rule" },
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// --- browser-measured gates ----------------------------------------------
|
|
356
|
+
const chromium = await loadChromium();
|
|
357
|
+
let browser;
|
|
358
|
+
try {
|
|
359
|
+
browser = await chromium.launch(launchOptions());
|
|
360
|
+
} catch (err) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`could not launch chromium: ${err.message}\nInstall the browser once with: npx playwright-core install chromium`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const site = await serveDir(distDir);
|
|
367
|
+
let measured;
|
|
368
|
+
let secondVertexHash = null;
|
|
369
|
+
const consoleIssues = [];
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const page = await browser.newPage({ viewport: { width: 1024, height: 768 } });
|
|
373
|
+
page.on("pageerror", (e) => consoleIssues.push({ type: "pageerror", text: String(e) }));
|
|
374
|
+
page.on("console", (m) => {
|
|
375
|
+
const type = m.type();
|
|
376
|
+
if (type !== "error" && type !== "warning") return;
|
|
377
|
+
const text = m.text();
|
|
378
|
+
if (isHarnessNoise(text)) return;
|
|
379
|
+
consoleIssues.push({ type, text });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await page.goto(`${site.origin}/?gates=1`, { waitUntil: "load" });
|
|
383
|
+
await page.waitForFunction(() => Boolean(window.__ASSET_GATES__?.ready), null, { timeout: 60_000 });
|
|
384
|
+
|
|
385
|
+
measured = await page.evaluate(() => {
|
|
386
|
+
const g = window.__ASSET_GATES__;
|
|
387
|
+
return {
|
|
388
|
+
geometry: g.geometry,
|
|
389
|
+
maxTextureDimension: g.maxTextureDimension,
|
|
390
|
+
space: g.space,
|
|
391
|
+
numeric: g.numeric,
|
|
392
|
+
vertexHash: g.vertexHash,
|
|
393
|
+
userData: g.userData,
|
|
394
|
+
threeRevision: g.threeRevision,
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const silhouettes = await page.evaluate((views) => window.__ASSET_GATES__.silhouettes(views), args.views);
|
|
399
|
+
const partVisibility = await page.evaluate(
|
|
400
|
+
(views) => (window.__ASSET_GATES__.partVisibility ? window.__ASSET_GATES__.partVisibility(views) : []),
|
|
401
|
+
args.views,
|
|
402
|
+
);
|
|
403
|
+
const winding = await page.evaluate(() =>
|
|
404
|
+
window.__ASSET_GATES__.winding ? window.__ASSET_GATES__.winding() : [],
|
|
405
|
+
);
|
|
406
|
+
const disposal = await page.evaluate(() => window.__ASSET_GATES__.disposal());
|
|
407
|
+
await page.evaluate((views) => window.__ASSET_GATES__.renderViews(views), args.views);
|
|
408
|
+
await page.close();
|
|
409
|
+
|
|
410
|
+
// G10b: a second, entirely fresh page evaluates the module again. Unseeded
|
|
411
|
+
// randomness shows up here and nowhere else.
|
|
412
|
+
const secondContext = await browser.newContext({ viewport: { width: 640, height: 480 } });
|
|
413
|
+
const secondPage = await secondContext.newPage();
|
|
414
|
+
await secondPage.goto(`${site.origin}/?gates=1`, { waitUntil: "load" });
|
|
415
|
+
await secondPage.waitForFunction(() => Boolean(window.__ASSET_GATES__?.ready), null, { timeout: 60_000 });
|
|
416
|
+
secondVertexHash = await secondPage.evaluate(() => window.__ASSET_GATES__.vertexHash);
|
|
417
|
+
await secondContext.close();
|
|
418
|
+
|
|
419
|
+
// --- G1 triangle band --------------------------------------------------
|
|
420
|
+
const [bandLow, bandHigh] = config.triBand;
|
|
421
|
+
add(
|
|
422
|
+
"G1",
|
|
423
|
+
"triangle-band",
|
|
424
|
+
`${bandLow}..${bandHigh}`,
|
|
425
|
+
measured.geometry.triangles >= bandLow && measured.geometry.triangles <= bandHigh,
|
|
426
|
+
measured.geometry.triangles,
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// --- G2 draw calls -----------------------------------------------------
|
|
430
|
+
const drawCallMax = config.drawCallMax ?? 8;
|
|
431
|
+
add("G2", "draw-calls", `<= ${drawCallMax}`, measured.geometry.drawCalls <= drawCallMax, measured.geometry.drawCalls);
|
|
432
|
+
|
|
433
|
+
// --- G3 materials ------------------------------------------------------
|
|
434
|
+
const materialMax = config.materialMax ?? 4;
|
|
435
|
+
add("G3", "material-count", `<= ${materialMax}`, measured.geometry.materials <= materialMax, measured.geometry.materials);
|
|
436
|
+
|
|
437
|
+
// --- G4 stated dims ----------------------------------------------------
|
|
438
|
+
const stated = config.statedSizeMeters;
|
|
439
|
+
const deltas = measured.space.sizeMeters.map((v, i) => round(pct(v, stated[i]), 3));
|
|
440
|
+
add(
|
|
441
|
+
"G4",
|
|
442
|
+
"stated-dims",
|
|
443
|
+
"every axis within ±5% of asset.config.json",
|
|
444
|
+
deltas.every((d) => Math.abs(d) <= 5),
|
|
445
|
+
deltas.map((d) => `${d > 0 ? "+" : ""}${d}%`).join(" / "),
|
|
446
|
+
{ statedSizeMeters: stated, sizeMeters: measured.space.sizeMeters.map((v) => round(v)) },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// --- G5 origin ---------------------------------------------------------
|
|
450
|
+
const minY = measured.space.boundingBox.min[1];
|
|
451
|
+
const [cx, , cz] = measured.space.centre;
|
|
452
|
+
const originOk = Math.abs(minY) <= 0.001 && Math.abs(cx) <= 0.01 && Math.abs(cz) <= 0.01;
|
|
453
|
+
add(
|
|
454
|
+
"G5",
|
|
455
|
+
"origin-base-centre",
|
|
456
|
+
"|bbox.min.y| <= 1 mm; |centre.x|, |centre.z| <= 10 mm",
|
|
457
|
+
originOk,
|
|
458
|
+
`min.y ${round(minY * 1000, 2)} mm · centre ${round(cx * 1000, 2)} / ${round(cz * 1000, 2)} mm`,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// --- G6 numeric sanity -------------------------------------------------
|
|
462
|
+
const numericOk = measured.numeric.nanAttributes.length === 0 && measured.numeric.badBoundingSpheres.length === 0;
|
|
463
|
+
add(
|
|
464
|
+
"G6",
|
|
465
|
+
"numeric-sanity",
|
|
466
|
+
"no NaN/Infinity in position/normal/uv; every boundingSphere.radius finite and > 0",
|
|
467
|
+
numericOk,
|
|
468
|
+
numericOk
|
|
469
|
+
? "clean"
|
|
470
|
+
: `${measured.numeric.nanAttributes.length} bad attributes, ${measured.numeric.badBoundingSpheres.length} bad spheres`,
|
|
471
|
+
measured.numeric,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// --- G7 console clean --------------------------------------------------
|
|
475
|
+
add(
|
|
476
|
+
"G7",
|
|
477
|
+
"console-clean",
|
|
478
|
+
"zero console errors/warnings across load + 8-view render",
|
|
479
|
+
consoleIssues.length === 0,
|
|
480
|
+
consoleIssues.length === 0 ? "clean" : `${consoleIssues.length} message(s)`,
|
|
481
|
+
consoleIssues.slice(0, 20),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// --- G9 multi-angle coverage -------------------------------------------
|
|
485
|
+
const collapseRatio = config.silhouetteCollapseRatio ?? COLLAPSE_RATIO_DEFAULT;
|
|
486
|
+
const maxArea = Math.max(...silhouettes, 1);
|
|
487
|
+
const minRatio = Math.min(...silhouettes.map((a) => a / maxArea));
|
|
488
|
+
add(
|
|
489
|
+
"G9",
|
|
490
|
+
"multi-angle-coverage",
|
|
491
|
+
`every view >= ${Math.round(collapseRatio * 100)}% of the widest view`,
|
|
492
|
+
silhouettes.every((a) => a > 0) && minRatio >= collapseRatio,
|
|
493
|
+
`min ${round(minRatio * 100, 1)}% over ${silhouettes.length} views`,
|
|
494
|
+
{ areas: silhouettes },
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// --- G16 every named part is visible -----------------------------------
|
|
498
|
+
// The one defect class every count-based gate is structurally blind to: a part
|
|
499
|
+
// whose rings were lofted in reverse order has inward normals, is entirely
|
|
500
|
+
// back-face culled, and renders as NOTHING - while still contributing its
|
|
501
|
+
// meshes, triangles and draw calls. Measured on the steel drum, where both
|
|
502
|
+
// bungs were invisible for three build cycles with G1-G15 all green. G17 is what
|
|
503
|
+
// actually catches it; this gate only sees a part that renders nothing at all.
|
|
504
|
+
// The threshold is an ABSOLUTE pixel count, deliberately not a share of the
|
|
505
|
+
// frame. The camera fits each part's own bounding box, and for a spatially
|
|
506
|
+
// distributed repeat - 28 rivets across a ladder, 24 nails across a pallet - that
|
|
507
|
+
// box is nearly the whole asset while each instance is millimetres, so a
|
|
508
|
+
// share-based floor fails perfectly good parts. A long thin pivot rod fails the
|
|
509
|
+
// same way. This gate asks one question: does the part render AT ALL.
|
|
510
|
+
const PART_MIN_PIXELS = 16;
|
|
511
|
+
const dark = partVisibility.filter((p) => !p.skipped && (p.bestPixels ?? 0) < PART_MIN_PIXELS);
|
|
512
|
+
const litValues = partVisibility.filter((p) => !p.skipped).map((p) => p.bestPixels ?? 0);
|
|
513
|
+
add(
|
|
514
|
+
"G16",
|
|
515
|
+
"part-visibility",
|
|
516
|
+
`every named part lights >= ${PART_MIN_PIXELS} px when viewed alone`,
|
|
517
|
+
partVisibility.length > 0 && dark.length === 0,
|
|
518
|
+
partVisibility.length === 0
|
|
519
|
+
? "no named parts to check"
|
|
520
|
+
: dark.length
|
|
521
|
+
? `invisible: ${dark.map((p) => `${p.name} (${p.bestPixels ?? 0} px)`).join(", ")}`
|
|
522
|
+
: `${partVisibility.length} part(s), dimmest ${litValues.length ? Math.min(...litValues) : 0} px`,
|
|
523
|
+
{ parts: partVisibility },
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// --- G17 winding agrees with the supplied normals ----------------------
|
|
527
|
+
// The gate that actually catches a reversed ring stack. Pure geometry, no
|
|
528
|
+
// renderer: a triangle whose winding disagrees with its own shaded normal is
|
|
529
|
+
// drawn facing the opposite way from the way it is lit. G16 alone cannot see
|
|
530
|
+
// this on a closed surface, which is why both gates exist.
|
|
531
|
+
const WINDING_MAX_SHARE = 0.01;
|
|
532
|
+
const wrongWay = winding.filter((m) => m.share > WINDING_MAX_SHARE);
|
|
533
|
+
const worstWinding = winding.length ? Math.max(...winding.map((m) => m.share)) : 0;
|
|
534
|
+
add(
|
|
535
|
+
"G17",
|
|
536
|
+
"winding-vs-normals",
|
|
537
|
+
`every mesh has <= ${WINDING_MAX_SHARE * 100}% of triangles wound against their normal`,
|
|
538
|
+
winding.length > 0 && wrongWay.length === 0,
|
|
539
|
+
winding.length === 0
|
|
540
|
+
? "no meshes with normals to check"
|
|
541
|
+
: wrongWay.length
|
|
542
|
+
? `reversed: ${wrongWay.map((m) => `${m.name} ${m.flipped}/${m.triangles}`).join(", ")}`
|
|
543
|
+
: `${winding.length} mesh(es), worst ${round(worstWinding * 100, 2)}%`,
|
|
544
|
+
{ meshes: winding },
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// --- G10 determinism ---------------------------------------------------
|
|
548
|
+
const sourceOnDisk = sha256(source.code);
|
|
549
|
+
const bareRandom = usesBareMathRandom(source.code);
|
|
550
|
+
const deterministic = sourceOnDisk === manifest.source.sha256 && secondVertexHash === measured.vertexHash && !bareRandom;
|
|
551
|
+
add(
|
|
552
|
+
"G10",
|
|
553
|
+
"determinism",
|
|
554
|
+
"stable source sha256 + identical vertex hash across two fresh evaluations, no bare Math.random()",
|
|
555
|
+
deterministic,
|
|
556
|
+
deterministic
|
|
557
|
+
? `vertexHash ${measured.vertexHash}`
|
|
558
|
+
: bareRandom
|
|
559
|
+
? "source calls Math.random() directly"
|
|
560
|
+
: `hash drift: ${measured.vertexHash} vs ${secondVertexHash}`,
|
|
561
|
+
{ sourceSha256: sourceOnDisk, vertexHash: measured.vertexHash, secondVertexHash, bareRandom },
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// --- G12 disposal ------------------------------------------------------
|
|
565
|
+
add(
|
|
566
|
+
"G12",
|
|
567
|
+
"disposal",
|
|
568
|
+
"renderer.info.memory returns to baseline after root.userData.dispose()",
|
|
569
|
+
disposal.passed,
|
|
570
|
+
disposal.hasDispose
|
|
571
|
+
? `geometries ${disposal.before.geometries} → ${disposal.peak.geometries} → ${disposal.after.geometries}; textures ${disposal.before.textures} → ${disposal.peak.textures} → ${disposal.after.textures}`
|
|
572
|
+
: "root.userData.dispose is not a function",
|
|
573
|
+
disposal,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// --- G15 texture budget ------------------------------------------------
|
|
577
|
+
const textureBytesMax = config.textureBytesMax ?? 4 * 1024 * 1024;
|
|
578
|
+
const maxDimension = config.maxTextureDimension ?? 1024;
|
|
579
|
+
const textureOk = measured.geometry.textureBytes <= textureBytesMax && measured.maxTextureDimension <= maxDimension;
|
|
580
|
+
add(
|
|
581
|
+
"G15",
|
|
582
|
+
"texture-budget",
|
|
583
|
+
`<= ${Math.round(textureBytesMax / 1024)} KB total, no dimension > ${maxDimension}`,
|
|
584
|
+
textureOk,
|
|
585
|
+
`${Math.round(measured.geometry.textureBytes / 1024)} KB across ${measured.geometry.textures} texture(s), max dimension ${measured.maxTextureDimension}`,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
// --- G8 paste-and-run --------------------------------------------------
|
|
589
|
+
if (args.pasteRun) {
|
|
590
|
+
const g8 = await pasteAndRun({ manifest, browser, projectDir: PROJECT_DIR });
|
|
591
|
+
add(
|
|
592
|
+
"G8",
|
|
593
|
+
"paste-and-run",
|
|
594
|
+
"scratch project with only three: tsc --noEmit --strict passes and the render lights >= 1 pixel",
|
|
595
|
+
g8.passed,
|
|
596
|
+
g8.measured,
|
|
597
|
+
g8.detail,
|
|
598
|
+
);
|
|
599
|
+
} else {
|
|
600
|
+
add("G8", "paste-and-run", "skipped via --skip-paste-run", false, "skipped");
|
|
601
|
+
}
|
|
602
|
+
} finally {
|
|
603
|
+
await site.close();
|
|
604
|
+
await browser.close();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// --- G14: validate the manifest that will actually ship -------------------
|
|
608
|
+
const runAt = new Date().toISOString();
|
|
609
|
+
const projected = {
|
|
610
|
+
...manifest,
|
|
611
|
+
geometry: measured.geometry,
|
|
612
|
+
space: {
|
|
613
|
+
...manifest.space,
|
|
614
|
+
boundingBox: measured.space.boundingBox,
|
|
615
|
+
sizeMeters: measured.space.sizeMeters,
|
|
616
|
+
sizeDeltaPct: measured.space.sizeMeters.map((v, i) => round(pct(v, config.statedSizeMeters[i]), 3)),
|
|
617
|
+
},
|
|
618
|
+
gates: {
|
|
619
|
+
runAt,
|
|
620
|
+
runner: RUNNER_ID,
|
|
621
|
+
allPassed: results.every((r) => r.passed),
|
|
622
|
+
results: results.map(({ id, name, threshold, measured: m, passed }) => ({ id, name, threshold, measured: m, passed })),
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
const projectedBytes = Buffer.byteLength(JSON.stringify(projected), "utf8");
|
|
626
|
+
const shape = validateManifest(projected, { bytes: projectedBytes });
|
|
627
|
+
add(
|
|
628
|
+
"G14",
|
|
629
|
+
"manifest-validity",
|
|
630
|
+
`parses against the manifest schema, <= ${MANIFEST_MAX_BYTES / 1024} KB, source.code non-empty`,
|
|
631
|
+
shape.ok && projectedBytes <= MANIFEST_MAX_BYTES && (manifest.source?.code?.length ?? 0) > 0,
|
|
632
|
+
`${(projectedBytes / 1024).toFixed(1)} KB${shape.ok ? "" : `; ${shape.errors.length} schema error(s)`}`,
|
|
633
|
+
{ bytes: projectedBytes, errors: shape.errors },
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const allPassed = results.every((r) => r.passed);
|
|
637
|
+
const report = {
|
|
638
|
+
runner: RUNNER_ID,
|
|
639
|
+
runAt,
|
|
640
|
+
slug: config.slug,
|
|
641
|
+
name: config.name,
|
|
642
|
+
threeRevision: measured.threeRevision,
|
|
643
|
+
sourceSha256: manifest.source.sha256,
|
|
644
|
+
allPassed,
|
|
645
|
+
geometry: measured.geometry,
|
|
646
|
+
space: {
|
|
647
|
+
boundingBox: measured.space.boundingBox,
|
|
648
|
+
sizeMeters: measured.space.sizeMeters,
|
|
649
|
+
centre: measured.space.centre,
|
|
650
|
+
statedSizeMeters: config.statedSizeMeters,
|
|
651
|
+
sizeDeltaPct: projected.space.sizeDeltaPct,
|
|
652
|
+
},
|
|
653
|
+
userData: measured.userData,
|
|
654
|
+
results,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const outPath = path.resolve(PROJECT_DIR, args.out);
|
|
658
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
659
|
+
await fsp.writeFile(outPath, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
660
|
+
|
|
661
|
+
const width = Math.max(...results.map((r) => r.name.length));
|
|
662
|
+
console.log(`\n${config.name} - ${RUNNER_ID}\n`);
|
|
663
|
+
for (const r of results.slice().sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)))) {
|
|
664
|
+
console.log(
|
|
665
|
+
` ${r.passed ? "pass" : "FAIL"} ${r.id.padEnd(3)} ${r.name.padEnd(width)} ${String(r.measured)}${r.passed ? "" : ` (want ${r.threshold})`}`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
console.log(
|
|
669
|
+
`\n ${allPassed ? `all ${results.length} gates passed` : `${results.filter((r) => !r.passed).length} of ${results.length} gates FAILED`}`,
|
|
670
|
+
);
|
|
671
|
+
console.log(` report: ${path.relative(PROJECT_DIR, outPath)}\n`);
|
|
672
|
+
|
|
673
|
+
return allPassed ? 0 : 1;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
main().then(
|
|
677
|
+
(code) => process.exit(code),
|
|
678
|
+
(error) => {
|
|
679
|
+
console.error(`\n[gates] runner error: ${error?.stack ?? error}\n`);
|
|
680
|
+
process.exit(2);
|
|
681
|
+
},
|
|
682
|
+
);
|