feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"name": "simplecad-scene-contract-parity",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simplecad-scene-contract-parity",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ajv": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.2",
|
||||
"json-schema-to-typescript": "15.0.4",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
"version": "11.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
|
||||
"integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/philsturgeon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-to-typescript": {
|
||||
"version": "15.0.4",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
|
||||
"integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^11.5.5",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"is-glob": "^4.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimist": "^1.2.8",
|
||||
"prettier": "^3.2.5",
|
||||
"tinyglobby": "^0.2.9"
|
||||
},
|
||||
"bin": {
|
||||
"json2ts": "dist/src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
|
||||
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "simplecad-scene-contract-parity",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"generate:types": "uv run python ../tools/generate_scene_contract_types.py --language typescript",
|
||||
"check:types": "uv run python ../tools/generate_scene_contract_types.py --check --language typescript",
|
||||
"test": "npm run check:types && npm run build && node --test dist/test/*.test.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.2",
|
||||
"json-schema-to-typescript": "15.0.4",
|
||||
"typescript": "5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export class DuplicateKeyError extends SyntaxError {}
|
||||
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const maxSafeInteger = 9_007_199_254_740_991;
|
||||
const parserDepthCapacity = 256;
|
||||
|
||||
function assertUnicodeScalarString(value: string): void {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) {
|
||||
throw new TypeError("string contains an unpaired surrogate");
|
||||
}
|
||||
index += 1;
|
||||
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||
throw new TypeError("string contains an unpaired surrogate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StrictJsonParser {
|
||||
private offset = 0;
|
||||
|
||||
public constructor(private readonly text: string) {}
|
||||
|
||||
public parse(): unknown {
|
||||
this.space();
|
||||
const parsed = this.value(0);
|
||||
this.space();
|
||||
if (this.offset !== this.text.length) {
|
||||
throw new SyntaxError("trailing data after JSON value");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private value(depth: number): unknown {
|
||||
if (depth > parserDepthCapacity) {
|
||||
throw new SyntaxError("JSON nesting exceeds parser capacity");
|
||||
}
|
||||
this.space();
|
||||
const token = this.text[this.offset];
|
||||
if (
|
||||
this.text.startsWith("NaN", this.offset) ||
|
||||
this.text.startsWith("Infinity", this.offset) ||
|
||||
this.text.startsWith("-Infinity", this.offset)
|
||||
) {
|
||||
throw new SyntaxError("non-finite JSON number is forbidden");
|
||||
}
|
||||
if (token === "{") return this.object(depth + 1);
|
||||
if (token === "[") return this.array(depth + 1);
|
||||
if (token === '"') return this.string();
|
||||
if (token === "t" && this.consume("true")) return true;
|
||||
if (token === "f" && this.consume("false")) return false;
|
||||
if (token === "n" && this.consume("null")) return null;
|
||||
if (token === "-" || (token !== undefined && token >= "0" && token <= "9")) {
|
||||
return this.number();
|
||||
}
|
||||
throw new SyntaxError(`invalid JSON token at offset ${this.offset}`);
|
||||
}
|
||||
|
||||
private object(depth: number): Record<string, unknown> {
|
||||
this.offset += 1;
|
||||
this.space();
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
const keys = new Set<string>();
|
||||
if (this.text[this.offset] === "}") {
|
||||
this.offset += 1;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
if (this.text[this.offset] !== '"') {
|
||||
throw new SyntaxError(`JSON object key expected at offset ${this.offset}`);
|
||||
}
|
||||
const key = this.string();
|
||||
if (keys.has(key)) {
|
||||
throw new DuplicateKeyError(`duplicate JSON object member: ${JSON.stringify(key)}`);
|
||||
}
|
||||
keys.add(key);
|
||||
this.space();
|
||||
if (this.text[this.offset] !== ":") {
|
||||
throw new SyntaxError(`JSON object colon expected at offset ${this.offset}`);
|
||||
}
|
||||
this.offset += 1;
|
||||
const child = this.value(depth);
|
||||
result[key] = child;
|
||||
this.space();
|
||||
const separator = this.text[this.offset];
|
||||
this.offset += 1;
|
||||
if (separator === "}") return result;
|
||||
if (separator !== ",") {
|
||||
throw new SyntaxError(`JSON object separator expected at offset ${this.offset - 1}`);
|
||||
}
|
||||
this.space();
|
||||
}
|
||||
}
|
||||
|
||||
private array(depth: number): unknown[] {
|
||||
this.offset += 1;
|
||||
this.space();
|
||||
const result: unknown[] = [];
|
||||
if (this.text[this.offset] === "]") {
|
||||
this.offset += 1;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
const child = this.value(depth);
|
||||
result.push(child);
|
||||
this.space();
|
||||
const separator = this.text[this.offset];
|
||||
this.offset += 1;
|
||||
if (separator === "]") return result;
|
||||
if (separator !== ",") {
|
||||
throw new SyntaxError(`JSON array separator expected at offset ${this.offset - 1}`);
|
||||
}
|
||||
this.space();
|
||||
}
|
||||
}
|
||||
|
||||
private string(): string {
|
||||
const start = this.offset;
|
||||
this.offset += 1;
|
||||
while (this.offset < this.text.length) {
|
||||
const unit = this.text.charCodeAt(this.offset);
|
||||
if (unit < 0x20) {
|
||||
throw new SyntaxError(`unescaped control character at offset ${this.offset}`);
|
||||
}
|
||||
if (unit === 0x22) {
|
||||
this.offset += 1;
|
||||
const result = JSON.parse(this.text.slice(start, this.offset)) as string;
|
||||
assertUnicodeScalarString(result);
|
||||
return result;
|
||||
}
|
||||
if (unit === 0x5c) {
|
||||
this.offset += 1;
|
||||
const escape = this.text[this.offset];
|
||||
if (escape === "u") {
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(this.text.slice(this.offset + 1, this.offset + 5))) {
|
||||
throw new SyntaxError(`invalid JSON Unicode escape at offset ${this.offset}`);
|
||||
}
|
||||
this.offset += 5;
|
||||
continue;
|
||||
}
|
||||
if (escape === undefined || !'"\\/bfnrt'.includes(escape)) {
|
||||
throw new SyntaxError(`invalid JSON escape at offset ${this.offset}`);
|
||||
}
|
||||
}
|
||||
this.offset += 1;
|
||||
}
|
||||
throw new SyntaxError("unterminated JSON string");
|
||||
}
|
||||
|
||||
private number(): number {
|
||||
const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(
|
||||
this.text.slice(this.offset),
|
||||
);
|
||||
if (match === null) throw new SyntaxError(`invalid JSON number at offset ${this.offset}`);
|
||||
const token = match[0];
|
||||
this.offset += token.length;
|
||||
const result = Number(token);
|
||||
if (!/[.eE]/.test(token) && Math.abs(result) > maxSafeInteger) {
|
||||
throw new SyntaxError("JSON integer exceeds the JCS safe domain");
|
||||
}
|
||||
if (!Number.isFinite(result)) throw new SyntaxError("non-finite JSON number is forbidden");
|
||||
return result;
|
||||
}
|
||||
|
||||
private consume(token: string): boolean {
|
||||
if (!this.text.startsWith(token, this.offset)) return false;
|
||||
this.offset += token.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
private space(): void {
|
||||
while (
|
||||
this.text[this.offset] === " " ||
|
||||
this.text[this.offset] === "\n" ||
|
||||
this.text[this.offset] === "\r" ||
|
||||
this.text[this.offset] === "\t"
|
||||
) {
|
||||
this.offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStrictJson(data: Uint8Array | string): unknown {
|
||||
let text: string;
|
||||
if (typeof data === "string") {
|
||||
text = data;
|
||||
} else {
|
||||
if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
|
||||
throw new SyntaxError("UTF-8 BOM is forbidden");
|
||||
}
|
||||
text = decoder.decode(data);
|
||||
}
|
||||
if (text.startsWith("\ufeff")) throw new SyntaxError("UTF-8 BOM is forbidden");
|
||||
return new StrictJsonParser(text).parse();
|
||||
}
|
||||
|
||||
function serialize(value: unknown, stack: Set<object>): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("JSON number must be finite");
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
assertUnicodeScalarString(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value !== "object") throw new TypeError(`unsupported JSON value: ${typeof value}`);
|
||||
if (stack.has(value)) throw new TypeError("cyclic value is not JSON");
|
||||
stack.add(value);
|
||||
let result: string;
|
||||
if (Array.isArray(value)) {
|
||||
const items: string[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError("sparse arrays are outside the JSON domain");
|
||||
items.push(serialize(value[index], stack));
|
||||
}
|
||||
result = `[${items.join(",")}]`;
|
||||
} else {
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
for (const key of keys) assertUnicodeScalarString(key);
|
||||
result = `{${keys
|
||||
.map((key) => `${JSON.stringify(key)}:${serialize(record[key], stack)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
stack.delete(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalJsonBytes(value: unknown): Buffer {
|
||||
return Buffer.from(serialize(value, new Set()), "utf8");
|
||||
}
|
||||
|
||||
export function parseCanonicalJson(data: Uint8Array | string): unknown {
|
||||
const raw = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
||||
const parsed = parseStrictJson(raw);
|
||||
if (!raw.equals(canonicalJsonBytes(parsed))) {
|
||||
throw new SyntaxError("JSON bytes are not RFC 8785 canonical");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function sha256(data: Uint8Array): string {
|
||||
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function canonicalJsonHash(value: unknown): string {
|
||||
return sha256(canonicalJsonBytes(value));
|
||||
}
|
||||
|
||||
export function computeSceneRevision(scene: Record<string, unknown>): string {
|
||||
const { revision: _revision, ...draft } = scene;
|
||||
return canonicalJsonHash(draft);
|
||||
}
|
||||
|
||||
export function withSceneRevision(scene: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = structuredClone(scene);
|
||||
result.revision = computeSceneRevision(result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
ConnectorBindingDocument as ConnectorBindingValue,
|
||||
EntityDocument as EntityValue,
|
||||
NormalizedProductDocument as NormalizedProductValue,
|
||||
PresentationDocument as PresentationValue,
|
||||
SceneDocument as SceneValue,
|
||||
} from "./generated/index.js";
|
||||
import { canonicalJsonBytes, parseCanonicalJson, sha256 } from "./canonical.js";
|
||||
import type { ValidationReport } from "./report.js";
|
||||
import {
|
||||
validateConnectorBinding,
|
||||
validateEntityAsset,
|
||||
validateNormalizedProduct,
|
||||
validatePresentation,
|
||||
validateSceneManifest,
|
||||
} from "./validation.js";
|
||||
|
||||
export type SceneDocument = SceneValue;
|
||||
export type EntityDocument = EntityValue;
|
||||
export type PresentationDocument = PresentationValue;
|
||||
export type ConnectorBindingDocument = ConnectorBindingValue;
|
||||
export type NormalizedProductDocument = NormalizedProductValue;
|
||||
|
||||
export type DeepReadonly<T> =
|
||||
T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
||||
export interface ImmutableContractDocument<Value> {
|
||||
readonly value: DeepReadonly<Value>;
|
||||
readonly canonicalBytes: Buffer;
|
||||
readonly canonicalHash: string;
|
||||
toMutable(): Value;
|
||||
}
|
||||
|
||||
export interface ImmutableSceneDocument
|
||||
extends ImmutableContractDocument<SceneValue> {
|
||||
readonly revision: string;
|
||||
}
|
||||
|
||||
export type ImmutableEntityDocument = ImmutableContractDocument<EntityValue>;
|
||||
export type ImmutablePresentationDocument =
|
||||
ImmutableContractDocument<PresentationValue>;
|
||||
export type ImmutableConnectorBindingDocument =
|
||||
ImmutableContractDocument<ConnectorBindingValue>;
|
||||
export type ImmutableNormalizedProductDocument =
|
||||
ImmutableContractDocument<NormalizedProductValue>;
|
||||
|
||||
export interface ContractDocumentFactory<Value, Document> {
|
||||
fromValue(value: Value | DeepReadonly<Value>): Document;
|
||||
parse(data: Uint8Array | string): Document;
|
||||
}
|
||||
|
||||
export class ContractDocumentValidationError extends TypeError {
|
||||
public constructor(public readonly report: ValidationReport) {
|
||||
const first = report.firstError;
|
||||
super(
|
||||
first === null
|
||||
? "Scene contract validation failed"
|
||||
: `Scene contract validation failed: ${first.code} at ${first.path}: ${first.message}`,
|
||||
);
|
||||
this.name = "ContractDocumentValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
type Validator = (value: unknown) => ValidationReport;
|
||||
|
||||
function deepFreeze(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) deepFreeze(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class FrozenContractDocument<Value>
|
||||
implements ImmutableContractDocument<Value>
|
||||
{
|
||||
readonly #value: DeepReadonly<Value>;
|
||||
readonly #canonicalBytes: Buffer;
|
||||
readonly #canonicalHash: string;
|
||||
|
||||
public constructor(value: Value | DeepReadonly<Value>, validate: Validator) {
|
||||
const canonical = canonicalJsonBytes(value);
|
||||
const detached = parseCanonicalJson(canonical);
|
||||
const validation = validate(detached);
|
||||
if (!validation.valid) throw new ContractDocumentValidationError(validation);
|
||||
this.#value = deepFreeze(detached) as DeepReadonly<Value>;
|
||||
this.#canonicalBytes = Buffer.from(canonical);
|
||||
this.#canonicalHash = sha256(canonical);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
public get value(): DeepReadonly<Value> {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
public get canonicalBytes(): Buffer {
|
||||
return Buffer.from(this.#canonicalBytes);
|
||||
}
|
||||
|
||||
public get canonicalHash(): string {
|
||||
return this.#canonicalHash;
|
||||
}
|
||||
|
||||
public toMutable(): Value {
|
||||
return parseCanonicalJson(this.#canonicalBytes) as Value;
|
||||
}
|
||||
}
|
||||
|
||||
class FrozenSceneDocument
|
||||
extends FrozenContractDocument<SceneValue>
|
||||
implements ImmutableSceneDocument
|
||||
{
|
||||
public get revision(): string {
|
||||
return this.value.revision;
|
||||
}
|
||||
}
|
||||
|
||||
function documentFactory<Value, Document>(
|
||||
create: (value: Value | DeepReadonly<Value>) => Document,
|
||||
): ContractDocumentFactory<Value, Document> {
|
||||
return Object.freeze({
|
||||
fromValue: create,
|
||||
parse: (data: Uint8Array | string) => create(parseCanonicalJson(data) as Value),
|
||||
});
|
||||
}
|
||||
|
||||
export const SceneDocument = documentFactory<SceneValue, ImmutableSceneDocument>(
|
||||
(value) => new FrozenSceneDocument(value, validateSceneManifest),
|
||||
);
|
||||
|
||||
export const EntityDocument = documentFactory<EntityValue, ImmutableEntityDocument>(
|
||||
(value) => new FrozenContractDocument<EntityValue>(value, validateEntityAsset),
|
||||
);
|
||||
|
||||
export const PresentationDocument = documentFactory<
|
||||
PresentationValue,
|
||||
ImmutablePresentationDocument
|
||||
>((value) => new FrozenContractDocument<PresentationValue>(value, validatePresentation));
|
||||
|
||||
export const ConnectorBindingDocument = documentFactory<
|
||||
ConnectorBindingValue,
|
||||
ImmutableConnectorBindingDocument
|
||||
>((value) => new FrozenContractDocument<ConnectorBindingValue>(value, validateConnectorBinding));
|
||||
|
||||
export const NormalizedProductDocument = documentFactory<
|
||||
NormalizedProductValue,
|
||||
ImmutableNormalizedProductDocument
|
||||
>((value) => new FrozenContractDocument<NormalizedProductValue>(value, validateNormalizedProduct));
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
|
||||
export interface ConnectorBindingDocument {
|
||||
schema_version: "1.0";
|
||||
binding_id: LogicalId;
|
||||
source_model: SourceModel;
|
||||
source_scene: SourceScene;
|
||||
owner_definition_id: StructuralId;
|
||||
selected_occurrence_node_id: StructuralId;
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
target: Target;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceModel".
|
||||
*/
|
||||
export interface SourceModel {
|
||||
graph_id: ProductId;
|
||||
model_schema_version: "2.0";
|
||||
artifact_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceScene".
|
||||
*/
|
||||
export interface SourceScene {
|
||||
scene_id: LogicalId;
|
||||
revision: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "target".
|
||||
*/
|
||||
export interface Target {
|
||||
kind: "topology_entity";
|
||||
entity_asset_id: Hash;
|
||||
entity_id: string;
|
||||
expected_source: ModelOutput | ModelTopology;
|
||||
flip: boolean;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutput".
|
||||
*/
|
||||
export interface ModelOutput {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "modelTopology".
|
||||
*/
|
||||
export interface ModelTopology {
|
||||
kind: "model_topology";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
topology_kind: "FACE" | "EDGE" | "VERTEX";
|
||||
topo_id: string;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "entityId".
|
||||
*/
|
||||
export type EntityId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "source".
|
||||
*/
|
||||
export type Source = ModelOutput | ModelTopology | ImportedPrimitive | Unbound;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "geometry".
|
||||
*/
|
||||
export type Geometry =
|
||||
| BrepSolid
|
||||
| Point
|
||||
| Line
|
||||
| Circle
|
||||
| Ellipse
|
||||
| BsplineCurve
|
||||
| OtherCurve
|
||||
| Plane
|
||||
| Cylinder
|
||||
| Cone
|
||||
| Sphere
|
||||
| Torus
|
||||
| BsplineSurface
|
||||
| OtherSurface;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "positiveInteger".
|
||||
*/
|
||||
export type PositiveInteger = number;
|
||||
|
||||
export interface EntityDocument {
|
||||
schema_version: "1.0";
|
||||
definition_id: StructuralId;
|
||||
geometry_asset_id: Hash;
|
||||
edge_asset_id: Hash;
|
||||
geometry_engine: GeometryEngine;
|
||||
/**
|
||||
* @minItems 4
|
||||
*/
|
||||
entities: [Entity, Entity, Entity, Entity, ...Entity[]];
|
||||
/**
|
||||
* @minItems 1
|
||||
*/
|
||||
face_groups: [Group, ...Group[]];
|
||||
edge_groups: Group[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "geometryEngine".
|
||||
*/
|
||||
export interface GeometryEngine {
|
||||
name: "OpenCascade";
|
||||
version: string;
|
||||
profile: "ocp-evaluated-properties-1";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "entity".
|
||||
*/
|
||||
export interface Entity {
|
||||
entity_id: EntityId;
|
||||
kind: "solid" | "face" | "edge" | "vertex";
|
||||
parent_entity_ids: EntityId[];
|
||||
child_entity_ids: EntityId[];
|
||||
source: Source;
|
||||
geometry: Geometry;
|
||||
properties: SolidProperties | FaceProperties | EdgeProperties | VertexProperties;
|
||||
sdk_connector_frame: null | Transform;
|
||||
render_status: "rendered" | "degenerate";
|
||||
connector_binding_status:
|
||||
| "not_applicable"
|
||||
| "owner_not_part"
|
||||
| "source_not_model"
|
||||
| "frame_undefined"
|
||||
| "selector_ambiguous"
|
||||
| "selector_unstable"
|
||||
| "supported";
|
||||
semantic_binding_ids: string[];
|
||||
evaluated_tags: string[];
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutput".
|
||||
*/
|
||||
export interface ModelOutput {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "modelTopology".
|
||||
*/
|
||||
export interface ModelTopology {
|
||||
kind: "model_topology";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
topology_kind: "SOLID" | "FACE" | "EDGE" | "VERTEX";
|
||||
topo_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "importedPrimitive".
|
||||
*/
|
||||
export interface ImportedPrimitive {
|
||||
kind: "imported_primitive";
|
||||
source_element_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "unbound".
|
||||
*/
|
||||
export interface Unbound {
|
||||
kind: "unbound";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "brepSolid".
|
||||
*/
|
||||
export interface BrepSolid {
|
||||
type: "brep_solid";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "point".
|
||||
*/
|
||||
export interface Point {
|
||||
type: "point";
|
||||
position: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "line".
|
||||
*/
|
||||
export interface Line {
|
||||
type: "line";
|
||||
origin: Vec3;
|
||||
direction: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "circle".
|
||||
*/
|
||||
export interface Circle {
|
||||
type: "circle";
|
||||
center: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "ellipse".
|
||||
*/
|
||||
export interface Ellipse {
|
||||
type: "ellipse";
|
||||
center: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bsplineCurve".
|
||||
*/
|
||||
export interface BsplineCurve {
|
||||
type: "bspline_curve";
|
||||
degree: SafeInteger;
|
||||
rational: boolean;
|
||||
periodic: boolean;
|
||||
poles_count: SafeInteger;
|
||||
knots_count: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "otherCurve".
|
||||
*/
|
||||
export interface OtherCurve {
|
||||
type: "other_curve";
|
||||
engine_type: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "plane".
|
||||
*/
|
||||
export interface Plane {
|
||||
type: "plane";
|
||||
origin: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "cylinder".
|
||||
*/
|
||||
export interface Cylinder {
|
||||
type: "cylinder";
|
||||
origin: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "cone".
|
||||
*/
|
||||
export interface Cone {
|
||||
type: "cone";
|
||||
origin: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
reference_radius: number;
|
||||
semi_angle_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "sphere".
|
||||
*/
|
||||
export interface Sphere {
|
||||
type: "sphere";
|
||||
center: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "torus".
|
||||
*/
|
||||
export interface Torus {
|
||||
type: "torus";
|
||||
center: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bsplineSurface".
|
||||
*/
|
||||
export interface BsplineSurface {
|
||||
type: "bspline_surface";
|
||||
u_degree: SafeInteger;
|
||||
v_degree: SafeInteger;
|
||||
u_rational: boolean;
|
||||
v_rational: boolean;
|
||||
u_periodic: boolean;
|
||||
v_periodic: boolean;
|
||||
u_poles_count: SafeInteger;
|
||||
v_poles_count: SafeInteger;
|
||||
u_knots_count: SafeInteger;
|
||||
v_knots_count: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "otherSurface".
|
||||
*/
|
||||
export interface OtherSurface {
|
||||
type: "other_surface";
|
||||
engine_type: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "solidProperties".
|
||||
*/
|
||||
export interface SolidProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
volume: number;
|
||||
surface_area: number;
|
||||
centroid: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bounds".
|
||||
*/
|
||||
export interface Bounds {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "faceProperties".
|
||||
*/
|
||||
export interface FaceProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
area: number;
|
||||
centroid: Vec3;
|
||||
orientation: "forward" | "reversed" | "internal" | "external";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "edgeProperties".
|
||||
*/
|
||||
export interface EdgeProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
length: number;
|
||||
centroid: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "vertexProperties".
|
||||
*/
|
||||
export interface VertexProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
position: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "group".
|
||||
*/
|
||||
export interface Group {
|
||||
group_id: SafeInteger;
|
||||
entity_id: EntityId;
|
||||
mesh_index: 0;
|
||||
primitive_index: 0;
|
||||
first_index: SafeInteger;
|
||||
index_count: PositiveInteger;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Generated by tools/generate_scene_contract_types.py. Do not edit.
|
||||
|
||||
export type { SceneDocument } from "./scene.js";
|
||||
export type { EntityDocument } from "./entities.js";
|
||||
export type { PresentationDocument } from "./presentation.js";
|
||||
export type { ConnectorBindingDocument } from "./connector_binding.js";
|
||||
export type { NormalizedProductDocument } from "./normalized_product.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export type NormalizedProductDocument = Part | Assembly;
|
||||
export type ProductId = string;
|
||||
export type BodySource = ModelBodySource | ManualBodySource;
|
||||
export type SafeInteger = number;
|
||||
export type Connector = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
anchor: GeometryAnchor | PlacementAnchor | ForwardedAnchor;
|
||||
geometry_ref?: null | GeometryRef;
|
||||
};
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
export type StructuralId = string;
|
||||
|
||||
export interface Part {
|
||||
kind: "part";
|
||||
part_id: ProductId;
|
||||
body_source: BodySource;
|
||||
name: string | null;
|
||||
material: null | Material;
|
||||
connectors: Connector[];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface ModelBodySource {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
export interface ManualBodySource {
|
||||
kind: "manual";
|
||||
source_id: ProductId;
|
||||
}
|
||||
export interface Material {
|
||||
material_id: ProductId;
|
||||
name: string | null;
|
||||
density: number | null;
|
||||
density_unit: string | null;
|
||||
color: null | [number, number, number];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface GeometryAnchor {
|
||||
anchor_kind: "geometry";
|
||||
geometry_ref: GeometryRef;
|
||||
}
|
||||
export interface GeometryRef {
|
||||
kind: "face" | "edge" | "vertex";
|
||||
source_node_id: string | null;
|
||||
geo_selector: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
flip: boolean;
|
||||
}
|
||||
export interface PlacementAnchor {
|
||||
anchor_kind: "placement";
|
||||
placement: Transform;
|
||||
}
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
export interface ForwardedAnchor {
|
||||
anchor_kind: "forwarded";
|
||||
source_component_id: ProductId;
|
||||
source_connector_id: ProductId;
|
||||
offset: null | Transform;
|
||||
}
|
||||
export interface Assembly {
|
||||
kind: "assembly";
|
||||
assembly_id: ProductId;
|
||||
name: string | null;
|
||||
components: Component[];
|
||||
connectors: Connector[];
|
||||
constraints: Constraint[];
|
||||
grounded_component_ids: ProductId[];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface Component {
|
||||
component_id: ProductId;
|
||||
name: string | null;
|
||||
definition_ref: StructuralId;
|
||||
local_placement: Transform;
|
||||
}
|
||||
export interface Constraint {
|
||||
constraint_id: ProductId;
|
||||
constraint_kind: "fixed" | "revolute" | "prismatic" | "gear" | "belt" | "rack_pinion";
|
||||
connector_a: ConnectorRef;
|
||||
connector_b: ConnectorRef;
|
||||
drive_distance: number | null;
|
||||
distance_limit: null | Limit;
|
||||
drive_angle_degrees: number | null;
|
||||
angle_limit: null | Limit;
|
||||
pitch_radius_a: number | null;
|
||||
pitch_radius_b: number | null;
|
||||
pulley_radius_a: number | null;
|
||||
pulley_radius_b: number | null;
|
||||
pitch_radius: number | null;
|
||||
phase_offset: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
export interface ConnectorRef {
|
||||
component_id: ProductId;
|
||||
connector_id: ProductId;
|
||||
}
|
||||
export interface Limit {
|
||||
lower_value: number;
|
||||
upper_value: number;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* @minItems 4
|
||||
* @maxItems 4
|
||||
*
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "color".
|
||||
*/
|
||||
export type Color = [number, number, number, 1];
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "nodeOverride".
|
||||
*/
|
||||
export type NodeOverride = NodeOverride1 & {
|
||||
node_id: StructuralId;
|
||||
visible?: boolean;
|
||||
appearance_name?: string;
|
||||
};
|
||||
export type NodeOverride1 = {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "camera".
|
||||
*/
|
||||
export type Camera = Perspective | Orthographic;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "positive".
|
||||
*/
|
||||
export type Positive = number;
|
||||
|
||||
export interface PresentationDocument {
|
||||
schema_version: "1.0";
|
||||
presentation_id: LogicalId;
|
||||
source_scene_id: LogicalId;
|
||||
appearances: Appearance[];
|
||||
node_overrides: NodeOverride[];
|
||||
cameras: Camera[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "appearance".
|
||||
*/
|
||||
export interface Appearance {
|
||||
name: string;
|
||||
base_color: Color;
|
||||
metallic: number;
|
||||
roughness: number;
|
||||
alpha_mode: "opaque";
|
||||
double_sided: boolean;
|
||||
edge_color: Color;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "perspective".
|
||||
*/
|
||||
export interface Perspective {
|
||||
name: string;
|
||||
projection: "perspective";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: Positive;
|
||||
far: Positive;
|
||||
vertical_fov_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "orthographic".
|
||||
*/
|
||||
export interface Orthographic {
|
||||
name: string;
|
||||
projection: "orthographic";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: Positive;
|
||||
far: Positive;
|
||||
vertical_span: Positive;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "cameraBaseProperties".
|
||||
*/
|
||||
export interface CameraBaseProperties {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "token".
|
||||
*/
|
||||
export type Token = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "sceneSource".
|
||||
*/
|
||||
export type SceneSource = ModelSource | ImportedSource | ManualSource;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "uri".
|
||||
*/
|
||||
export type Uri = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "definition".
|
||||
*/
|
||||
export type Definition = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
definition_id: StructuralId;
|
||||
kind: "part" | "assembly" | "shape";
|
||||
name: string | null;
|
||||
source: DefinitionSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
geometry_asset_id?: Hash;
|
||||
edge_asset_id?: Hash;
|
||||
entity_asset_id?: Hash;
|
||||
appearance_id?: StructuralId;
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "definitionSource".
|
||||
*/
|
||||
export type DefinitionSource =
|
||||
ProductModelSource | ProductManualSource | ModelOutputSource | ImportedDefinitionSource | ManualDefinitionSource;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [Number, Number, Number];
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "number".
|
||||
*/
|
||||
export type Number = number;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "positiveNumber".
|
||||
*/
|
||||
export type PositiveNumber = number;
|
||||
/**
|
||||
* @minItems 4
|
||||
* @maxItems 4
|
||||
*
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "color".
|
||||
*/
|
||||
export type Color = [number, number, number, 1];
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "connector".
|
||||
*/
|
||||
export type Connector = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
connector_snapshot_id: StructuralId;
|
||||
owner_definition_id: StructuralId;
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
anchor_kind: "geometry" | "placement" | "forwarded";
|
||||
local_transform: Transform;
|
||||
target?: ConnectorTarget;
|
||||
forwarded_from?: ForwardedFrom;
|
||||
source: null | ModelOperationSource | ManualConnectorSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "camera".
|
||||
*/
|
||||
export type Camera = PerspectiveCamera | OrthographicCamera;
|
||||
|
||||
export interface SceneDocument {
|
||||
schema_version: "1.0";
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
extensions_used: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
extensions_required: unknown[];
|
||||
extensions: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
scene_id: LogicalId;
|
||||
revision: Hash;
|
||||
generator: Generator;
|
||||
source: SceneSource;
|
||||
presentation_source?: PresentationSource;
|
||||
coordinate_system: CoordinateSystem;
|
||||
compile_options: CompileOptions;
|
||||
definitions: Definition[];
|
||||
nodes: Node[];
|
||||
geometry_assets: GeometryAsset[];
|
||||
edge_assets: EdgeAsset[];
|
||||
appearances: Appearance[];
|
||||
entity_assets: EntityAsset[];
|
||||
connectors: Connector[];
|
||||
cameras: Camera[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
lights: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
annotations: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
diagnostics: unknown[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "generator".
|
||||
*/
|
||||
export interface Generator {
|
||||
name: "simplecadapi";
|
||||
simplecadapi_version: Token;
|
||||
ocp_version: Token;
|
||||
ocp_bindings_version: Token;
|
||||
python_abi: Token;
|
||||
platform_tag: Token;
|
||||
toolchain_hash: Hash;
|
||||
profile: "scene-1.0-ocp-glb-1" | "scene-1.0-ocp-glb-2";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelSource".
|
||||
*/
|
||||
export interface ModelSource {
|
||||
kind: "model";
|
||||
graph_id: ProductId;
|
||||
model_schema_version: "2.0";
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "model/model.json";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
/**
|
||||
* @maxItems 25000
|
||||
*/
|
||||
source_files?: SourceFile[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceFile".
|
||||
*/
|
||||
export interface SourceFile {
|
||||
path: string;
|
||||
uri: Uri;
|
||||
media_type: "text/x-python; charset=utf-8";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "importedSource".
|
||||
*/
|
||||
export interface ImportedSource {
|
||||
kind: "imported";
|
||||
format: Token;
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "source/source.bin";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualSource".
|
||||
*/
|
||||
export interface ManualSource {
|
||||
kind: "manual";
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "presentationSource".
|
||||
*/
|
||||
export interface PresentationSource {
|
||||
presentation_id: LogicalId;
|
||||
schema_version: "1.0";
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "presentation/presentation.json";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "coordinateSystem".
|
||||
*/
|
||||
export interface CoordinateSystem {
|
||||
length_unit: "mm";
|
||||
handedness: "right";
|
||||
up_axis: "+Z";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "compileOptions".
|
||||
*/
|
||||
export interface CompileOptions {
|
||||
linear_tolerance: number;
|
||||
angular_tolerance: number;
|
||||
embed_source: boolean;
|
||||
embed_presentation: boolean;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productModelSource".
|
||||
*/
|
||||
export interface ProductModelSource {
|
||||
kind: "product_model";
|
||||
root_id: LogicalId;
|
||||
semantic_type: "Part" | "Assembly";
|
||||
semantic_id: ProductId;
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productManualSource".
|
||||
*/
|
||||
export interface ProductManualSource {
|
||||
kind: "product_manual";
|
||||
root_id: LogicalId;
|
||||
semantic_type: "Part" | "Assembly";
|
||||
semantic_id: ProductId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutputSource".
|
||||
*/
|
||||
export interface ModelOutputSource {
|
||||
kind: "model_output";
|
||||
root_id: LogicalId;
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "importedDefinitionSource".
|
||||
*/
|
||||
export interface ImportedDefinitionSource {
|
||||
kind: "imported";
|
||||
root_id: LogicalId;
|
||||
source_element_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualDefinitionSource".
|
||||
*/
|
||||
export interface ManualDefinitionSource {
|
||||
kind: "manual";
|
||||
root_id: LogicalId;
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "node".
|
||||
*/
|
||||
export interface Node {
|
||||
node_id: StructuralId;
|
||||
parent_node_id: StructuralId | null;
|
||||
order: SafeInteger;
|
||||
definition_id: StructuralId;
|
||||
name: string | null;
|
||||
transform: Transform;
|
||||
visible: boolean;
|
||||
selectable: boolean;
|
||||
appearance_override_id: StructuralId | null;
|
||||
source: ProductOccurrenceSource | ShapeRootSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productOccurrenceSource".
|
||||
*/
|
||||
export interface ProductOccurrenceSource {
|
||||
kind: "product_occurrence";
|
||||
root_id: LogicalId;
|
||||
component_path: ProductId[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "shapeRootSource".
|
||||
*/
|
||||
export interface ShapeRootSource {
|
||||
kind: "shape_root";
|
||||
root_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "geometryAsset".
|
||||
*/
|
||||
export interface GeometryAsset {
|
||||
asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "model/gltf-binary";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
scene_local_bounds: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
tessellation: {
|
||||
linear_tolerance: PositiveNumber;
|
||||
angular_tolerance: PositiveNumber;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "bounds".
|
||||
*/
|
||||
export interface Bounds {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "edgeAsset".
|
||||
*/
|
||||
export interface EdgeAsset {
|
||||
asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "model/gltf-binary";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
scene_local_bounds: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
tessellation: {
|
||||
linear_tolerance: PositiveNumber;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "appearance".
|
||||
*/
|
||||
export interface Appearance {
|
||||
appearance_id: string;
|
||||
name: string | null;
|
||||
source: null | ProductMaterialSource | PresentationAppearanceSource;
|
||||
base_color: Color;
|
||||
metallic: number;
|
||||
roughness: number;
|
||||
alpha_mode: "opaque";
|
||||
double_sided: boolean;
|
||||
edge_color: Color;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productMaterialSource".
|
||||
*/
|
||||
export interface ProductMaterialSource {
|
||||
kind: "product_material";
|
||||
root_id: LogicalId;
|
||||
material_id: ProductId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "presentationAppearanceSource".
|
||||
*/
|
||||
export interface PresentationAppearanceSource {
|
||||
kind: "presentation";
|
||||
presentation_id: LogicalId;
|
||||
appearance_name: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "entityAsset".
|
||||
*/
|
||||
export interface EntityAsset {
|
||||
entity_asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "application/vnd.simplecad.entities+json";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "connectorTarget".
|
||||
*/
|
||||
export interface ConnectorTarget {
|
||||
entity_asset_id: Hash;
|
||||
entity_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "forwardedFrom".
|
||||
*/
|
||||
export interface ForwardedFrom {
|
||||
source_component_id: ProductId;
|
||||
source_definition_id: StructuralId;
|
||||
source_connector_id: ProductId;
|
||||
source_connector_snapshot_id: StructuralId;
|
||||
offset: null | Transform;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOperationSource".
|
||||
*/
|
||||
export interface ModelOperationSource {
|
||||
kind: "model_operation";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualConnectorSource".
|
||||
*/
|
||||
export interface ManualConnectorSource {
|
||||
kind: "manual";
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "perspectiveCamera".
|
||||
*/
|
||||
export interface PerspectiveCamera {
|
||||
camera_id: StructuralId;
|
||||
name: string;
|
||||
projection: "perspective";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: PositiveNumber;
|
||||
far: PositiveNumber;
|
||||
vertical_fov_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "orthographicCamera".
|
||||
*/
|
||||
export interface OrthographicCamera {
|
||||
camera_id: StructuralId;
|
||||
name: string;
|
||||
projection: "orthographic";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: PositiveNumber;
|
||||
far: PositiveNumber;
|
||||
vertical_span: PositiveNumber;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "assetBase".
|
||||
*/
|
||||
export interface AssetBase {
|
||||
asset_id?: Hash;
|
||||
uri?: Uri;
|
||||
media_type?: "model/gltf-binary";
|
||||
byte_length?: SafeInteger;
|
||||
content_hash?: Hash;
|
||||
scene_local_bounds?: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene?: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "cameraBase".
|
||||
*/
|
||||
export interface CameraBase {
|
||||
camera_id?: StructuralId;
|
||||
name?: string;
|
||||
parent_node_id?: null | StructuralId;
|
||||
transform?: Transform;
|
||||
near?: PositiveNumber;
|
||||
far?: PositiveNumber;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { canonicalJsonBytes, parseStrictJson } from "./canonical.js";
|
||||
import { BASE_LIMITS, type SceneResourceLimits } from "./resources.js";
|
||||
|
||||
export type GlbKind = "triangle" | "line";
|
||||
|
||||
export interface GlbInfo {
|
||||
kind: GlbKind;
|
||||
vertexCount: number;
|
||||
indexCount: number;
|
||||
primitiveCount: number;
|
||||
decodedBufferBytes: number;
|
||||
positionBounds: readonly [readonly [number, number, number], readonly [number, number, number]];
|
||||
}
|
||||
|
||||
const maxSafeInteger = 9_007_199_254_740_991;
|
||||
|
||||
function record(value: unknown, context: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${context} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireKeys(value: unknown, expected: readonly string[], context: string): Record<string, unknown> {
|
||||
const result = record(value, context);
|
||||
const keys = Object.keys(result).sort();
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== [...expected].sort()[index])) {
|
||||
throw new Error(`${context} fields do not match closed GLB profile`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function integer(value: unknown, context: string): number {
|
||||
if (!Number.isSafeInteger(value) || typeof value !== "number" || value < 0 || value > maxSafeInteger) {
|
||||
throw new Error(`${context} must be a non-negative safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function vec3(value: unknown, context: string): [number, number, number] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 3 ||
|
||||
value.some((component) => typeof component !== "number")
|
||||
) {
|
||||
throw new Error(`${context} must be a three-number array`);
|
||||
}
|
||||
if (!value.every(Number.isFinite)) throw new Error(`${context} must contain finite values`);
|
||||
return value as [number, number, number];
|
||||
}
|
||||
|
||||
export function profileF32Bits(value: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("f32 input must be a finite number");
|
||||
const converted = Math.fround(value);
|
||||
if (!Number.isFinite(converted)) throw new Error("f32 conversion overflowed");
|
||||
const bytes = Buffer.allocUnsafe(4);
|
||||
bytes.writeFloatLE(converted, 0);
|
||||
const bits = bytes.readUInt32LE();
|
||||
return bits === 0x80000000 ? 0 : bits;
|
||||
}
|
||||
|
||||
export function profileF32(value: number): number {
|
||||
const bytes = Buffer.allocUnsafe(4);
|
||||
bytes.writeUInt32LE(profileF32Bits(value));
|
||||
return bytes.readFloatLE();
|
||||
}
|
||||
|
||||
export function profileCross(
|
||||
left: readonly [number, number, number],
|
||||
right: readonly [number, number, number],
|
||||
): [number, number, number] {
|
||||
const a = vec3(left, "cross left");
|
||||
const b = vec3(right, "cross right");
|
||||
const p1x = a[1] * b[2];
|
||||
const p2x = a[2] * b[1];
|
||||
const p1y = a[2] * b[0];
|
||||
const p2y = a[0] * b[2];
|
||||
const p1z = a[0] * b[1];
|
||||
const p2z = a[1] * b[0];
|
||||
const result: [number, number, number] = [p1x - p2x, p1y - p2y, p1z - p2z];
|
||||
if (!result.every(Number.isFinite)) throw new Error("cross product produced a non-finite value");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function profileNormalize(value: readonly [number, number, number]): [number, number, number] {
|
||||
const vector = vec3(value, "normalize input");
|
||||
let squared = vector[0] * vector[0] + vector[1] * vector[1];
|
||||
squared += vector[2] * vector[2];
|
||||
if (!Number.isFinite(squared) || squared <= 0) throw new Error("normalize input has zero or non-finite length");
|
||||
const length = Math.sqrt(squared);
|
||||
const result: [number, number, number] = vector.map((component) => profileF32(component / length)) as [number, number, number];
|
||||
let normSquared = result[0] * result[0] + result[1] * result[1];
|
||||
normSquared += result[2] * result[2];
|
||||
const norm = Math.sqrt(normSquared);
|
||||
if (norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("normalized binary32 vector is outside the profile tolerance");
|
||||
return result;
|
||||
}
|
||||
|
||||
function array(value: unknown, context: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${context} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function equalArray(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function tupleCompare(left: readonly number[], right: readonly number[]): number {
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function preflightGlbCounts(
|
||||
kind: GlbKind,
|
||||
vertexCountInput: number,
|
||||
indexCountInput: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
const vertexCount = integer(vertexCountInput, "POSITION count");
|
||||
const vertexLimit = kind === "triangle"
|
||||
? limits.triangle_vertices_per_asset
|
||||
: limits.line_vertices_per_asset;
|
||||
if (vertexCount === 0 || vertexCount > vertexLimit) {
|
||||
throw new Error("GLB vertex count is empty or exceeds resource limit");
|
||||
}
|
||||
const indexCount = integer(indexCountInput, "index count");
|
||||
const divisor = kind === "triangle" ? 3 : 2;
|
||||
const primitiveCount = indexCount / divisor;
|
||||
const primitiveLimit = kind === "triangle"
|
||||
? limits.triangles_per_asset
|
||||
: limits.line_segments_per_asset;
|
||||
if (indexCount === 0 || indexCount % divisor !== 0 || primitiveCount > primitiveLimit) {
|
||||
throw new Error("GLB index count is invalid or exceeds resource limit");
|
||||
}
|
||||
return primitiveCount;
|
||||
}
|
||||
|
||||
export function preflightGlb(
|
||||
data: Uint8Array,
|
||||
expectedKind?: GlbKind,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): GlbInfo {
|
||||
if (data.byteLength > limits.one_member_bytes) throw new Error("GLB exceeds resource limit");
|
||||
const raw = Buffer.from(data);
|
||||
if (raw.length < 28) throw new Error("truncated GLB");
|
||||
if (raw.readUInt32LE(0) !== 0x46546c67 || raw.readUInt32LE(4) !== 2 || raw.readUInt32LE(8) !== raw.length) {
|
||||
throw new Error("invalid GLB header");
|
||||
}
|
||||
const jsonLength = raw.readUInt32LE(12);
|
||||
if (raw.readUInt32LE(16) !== 0x4e4f534a || jsonLength % 4 !== 0) {
|
||||
throw new Error("invalid GLB JSON chunk header");
|
||||
}
|
||||
const jsonEnd = 20 + jsonLength;
|
||||
if (jsonEnd + 8 > raw.length) throw new Error("truncated GLB JSON chunk");
|
||||
const paddedJson = raw.subarray(20, jsonEnd);
|
||||
let unpaddedLength = paddedJson.length;
|
||||
while (unpaddedLength > 0 && paddedJson[unpaddedLength - 1] === 0x20) unpaddedLength -= 1;
|
||||
const jsonBytes = paddedJson.subarray(0, unpaddedLength);
|
||||
const expectedPadding = (4 - (jsonBytes.length % 4)) % 4;
|
||||
if (
|
||||
paddedJson.length !== jsonBytes.length + expectedPadding ||
|
||||
paddedJson.subarray(jsonBytes.length).some((byte) => byte !== 0x20)
|
||||
) {
|
||||
throw new Error("GLB JSON padding must use the minimal ASCII-space suffix");
|
||||
}
|
||||
const document = parseStrictJson(jsonBytes);
|
||||
if (document === null || typeof document !== "object" || Array.isArray(document) || !Buffer.from(jsonBytes).equals(canonicalJsonBytes(document))) {
|
||||
throw new Error("GLB JSON chunk must be RFC 8785 canonical");
|
||||
}
|
||||
const binLength = raw.readUInt32LE(jsonEnd);
|
||||
if (raw.readUInt32LE(jsonEnd + 4) !== 0x004e4942 || binLength % 4 !== 0 || jsonEnd + 8 + binLength !== raw.length) {
|
||||
throw new Error("invalid GLB BIN chunk header");
|
||||
}
|
||||
const binData = raw.subarray(jsonEnd + 8);
|
||||
const root = requireKeys(
|
||||
document,
|
||||
["accessors", "asset", "bufferViews", "buffers", "meshes", "nodes", "scene", "scenes"],
|
||||
"GLB root",
|
||||
);
|
||||
const asset = root.asset;
|
||||
if (JSON.stringify(asset) !== JSON.stringify({ generator: "SimpleCAD Scene GLB Profile 1", version: "2.0" })) {
|
||||
throw new Error("GLB asset record does not match Scene profile");
|
||||
}
|
||||
const nodes = array(root.nodes, "GLB nodes");
|
||||
const scenes = array(root.scenes, "GLB scenes");
|
||||
let skeletonValid = integer(root.scene, "GLB default scene") === 0 && nodes.length === 1 && scenes.length === 1;
|
||||
if (skeletonValid) {
|
||||
const node = requireKeys(nodes[0], ["mesh"], "GLB node");
|
||||
const scene = requireKeys(scenes[0], ["nodes"], "GLB scene");
|
||||
skeletonValid = integer(node.mesh, "GLB node mesh") === 0 && Array.isArray(scene.nodes) && equalArray(scene.nodes, [0]);
|
||||
if (Array.isArray(scene.nodes) && scene.nodes.some((item) => !Number.isSafeInteger(item) || typeof item !== "number")) {
|
||||
skeletonValid = false;
|
||||
}
|
||||
}
|
||||
if (!skeletonValid) throw new Error("GLB scene/node skeleton does not match Scene profile");
|
||||
|
||||
const meshes = array(root.meshes, "GLB meshes");
|
||||
if (meshes.length !== 1) throw new Error("GLB must contain exactly one mesh");
|
||||
const mesh = requireKeys(meshes[0], ["primitives"], "GLB mesh");
|
||||
const primitives = array(mesh.primitives, "GLB primitives");
|
||||
if (primitives.length !== 1) throw new Error("GLB must contain exactly one primitive");
|
||||
const primitive = requireKeys(primitives[0], ["attributes", "indices", "mode"], "GLB primitive");
|
||||
const accessors = array(root.accessors, "GLB accessors");
|
||||
const views = array(root.bufferViews, "GLB bufferViews");
|
||||
const buffers = array(root.buffers, "GLB buffers");
|
||||
if (buffers.length !== 1) throw new Error("GLB accessors/bufferViews/buffer skeleton is invalid");
|
||||
const buffer = requireKeys(buffers[0], ["byteLength"], "GLB buffer");
|
||||
const bufferByteLength = integer(buffer.byteLength, "GLB buffer byteLength");
|
||||
const attributesRecord = record(primitive.attributes, "GLB primitive attributes");
|
||||
const attributes = Object.fromEntries(
|
||||
Object.entries(attributesRecord).map(([name, value]) => [name, integer(value, `GLB ${name} attribute accessor`)]),
|
||||
);
|
||||
const primitiveIndices = integer(primitive.indices, "GLB primitive index accessor");
|
||||
const primitiveMode = integer(primitive.mode, "GLB primitive mode");
|
||||
let kind: GlbKind;
|
||||
let accessorCount: number;
|
||||
let indexView: number;
|
||||
if (JSON.stringify(attributes) === JSON.stringify({ NORMAL: 1, POSITION: 0 }) && primitiveIndices === 2 && primitiveMode === 4) {
|
||||
kind = "triangle";
|
||||
accessorCount = 3;
|
||||
indexView = 2;
|
||||
} else if (JSON.stringify(attributes) === JSON.stringify({ POSITION: 0 }) && primitiveIndices === 1 && primitiveMode === 1) {
|
||||
kind = "line";
|
||||
accessorCount = 2;
|
||||
indexView = 1;
|
||||
} else {
|
||||
throw new Error("GLB primitive does not match triangle or line profile");
|
||||
}
|
||||
if (expectedKind !== undefined && kind !== expectedKind) throw new Error(`expected ${expectedKind} GLB, got ${kind}`);
|
||||
if (accessors.length !== accessorCount || views.length !== accessorCount) {
|
||||
throw new Error("GLB accessor/bufferView count mismatch");
|
||||
}
|
||||
|
||||
const position = requireKeys(accessors[0], ["bufferView", "componentType", "count", "max", "min", "type"], "POSITION accessor");
|
||||
const vertexCount = integer(position.count, "POSITION count");
|
||||
if (integer(position.bufferView, "POSITION bufferView") !== 0 || integer(position.componentType, "POSITION componentType") !== 5126 || position.type !== "VEC3") {
|
||||
throw new Error("invalid POSITION accessor");
|
||||
}
|
||||
const minimum = vec3(position.min, "POSITION min");
|
||||
const maximum = vec3(position.max, "POSITION max");
|
||||
if (minimum.some((value, index) => value > maximum[index])) throw new Error("POSITION min exceeds max");
|
||||
|
||||
if (kind === "triangle") {
|
||||
const normal = requireKeys(accessors[1], ["bufferView", "componentType", "count", "type"], "NORMAL accessor");
|
||||
if (integer(normal.bufferView, "NORMAL bufferView") !== 1 || integer(normal.componentType, "NORMAL componentType") !== 5126 || integer(normal.count, "NORMAL count") !== vertexCount || normal.type !== "VEC3") {
|
||||
throw new Error("invalid NORMAL accessor");
|
||||
}
|
||||
}
|
||||
const indexAccessor = requireKeys(accessors[indexView], ["bufferView", "componentType", "count", "type"], "index accessor");
|
||||
const indexCount = integer(indexAccessor.count, "index count");
|
||||
const primitiveCount = preflightGlbCounts(kind, vertexCount, indexCount, limits);
|
||||
const componentType = vertexCount <= 65536 ? 5123 : 5125;
|
||||
if (integer(indexAccessor.bufferView, "index bufferView") !== indexView || integer(indexAccessor.componentType, "index componentType") !== componentType || indexAccessor.type !== "SCALAR") {
|
||||
throw new Error("invalid GLB index accessor");
|
||||
}
|
||||
|
||||
let expectedOffset = 0;
|
||||
const parsedViews: Record<string, unknown>[] = [];
|
||||
for (let viewIndex = 0; viewIndex < views.length; viewIndex += 1) {
|
||||
const view = requireKeys(views[viewIndex], ["buffer", "byteLength", "byteOffset", "target"], `bufferView[${viewIndex}]`);
|
||||
parsedViews.push(view);
|
||||
const offset = integer(view.byteOffset, `bufferView[${viewIndex}].byteOffset`);
|
||||
const length = integer(view.byteLength, `bufferView[${viewIndex}].byteLength`);
|
||||
if (integer(view.buffer, `bufferView[${viewIndex}].buffer`) !== 0 || offset !== expectedOffset) throw new Error("GLB bufferView offset mismatch");
|
||||
if (integer(view.target, `bufferView[${viewIndex}].target`) !== (viewIndex < indexView ? 34962 : 34963)) throw new Error("GLB bufferView target mismatch");
|
||||
const expectedLength = viewIndex < indexView ? 12 * vertexCount : indexCount * (componentType === 5123 ? 2 : 4);
|
||||
if (length !== expectedLength) throw new Error("GLB bufferView byteLength mismatch");
|
||||
expectedOffset = (expectedOffset + expectedLength + 3) & ~3;
|
||||
}
|
||||
const lastView = parsedViews.at(-1)!;
|
||||
const unpaddedBinLength = integer(lastView.byteOffset, "last bufferView byteOffset") + integer(lastView.byteLength, "last bufferView byteLength");
|
||||
if (bufferByteLength !== unpaddedBinLength) throw new Error("GLB buffer byteLength mismatch");
|
||||
if (binLength !== ((unpaddedBinLength + 3) & ~3)) throw new Error("GLB BIN chunk length mismatch");
|
||||
if (binData.subarray(unpaddedBinLength).some((byte) => byte !== 0)) throw new Error("GLB BIN padding must be zero");
|
||||
|
||||
const positions: [number, number, number][] = [];
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const offset = index * 12;
|
||||
const point: [number, number, number] = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)];
|
||||
if (!point.every(Number.isFinite)) throw new Error("GLB POSITION contains non-finite values");
|
||||
if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign");
|
||||
positions.push(point);
|
||||
}
|
||||
const actualMin: [number, number, number] = [...positions[0]];
|
||||
const actualMax: [number, number, number] = [...positions[0]];
|
||||
for (const point of positions) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
actualMin[axis] = Math.min(actualMin[axis], point[axis]);
|
||||
actualMax[axis] = Math.max(actualMax[axis], point[axis]);
|
||||
}
|
||||
}
|
||||
if (!equalArray(minimum, actualMin) || !equalArray(maximum, actualMax)) throw new Error("POSITION accessor bounds do not match buffer");
|
||||
|
||||
if (kind === "triangle") {
|
||||
const normalOffset = integer(parsedViews[1].byteOffset, "NORMAL byteOffset");
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const offset = normalOffset + index * 12;
|
||||
const normal = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)];
|
||||
if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign");
|
||||
const norm = Math.sqrt(normal.reduce((sum, component) => sum + component * component, 0));
|
||||
if (!normal.every(Number.isFinite) || norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("GLB NORMAL must be finite and normalized");
|
||||
}
|
||||
}
|
||||
const indices: number[] = [];
|
||||
const indexOffset = integer(parsedViews[indexView].byteOffset, "index byteOffset");
|
||||
for (let index = 0; index < indexCount; index += 1) {
|
||||
indices.push(componentType === 5123 ? binData.readUInt16LE(indexOffset + index * 2) : binData.readUInt32LE(indexOffset + index * 4));
|
||||
}
|
||||
let maximumIndex = -1;
|
||||
for (const index of indices) maximumIndex = Math.max(maximumIndex, index);
|
||||
if (maximumIndex >= vertexCount || new Set(indices).size !== vertexCount) throw new Error("GLB indices must be in range and reference every vertex");
|
||||
if (kind === "triangle") {
|
||||
const triangles: number[][] = [];
|
||||
for (let index = 0; index < indexCount; index += 3) {
|
||||
const triple = indices.slice(index, index + 3);
|
||||
const rotations = [triple, [triple[1], triple[2], triple[0]], [triple[2], triple[0], triple[1]]].sort(tupleCompare);
|
||||
if (!equalArray(triple, rotations[0])) throw new Error("GLB triangle indices are not in canonical cyclic rotation");
|
||||
const [a, b, c] = triple.map((item) => positions[item]);
|
||||
const ab = b.map((value, axis) => value - a[axis]);
|
||||
const ac = c.map((value, axis) => value - a[axis]);
|
||||
const cross = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
|
||||
if (equalArray(a, b) || equalArray(b, c) || equalArray(a, c) || cross.every((value) => value === 0)) throw new Error("GLB contains a collapsed triangle");
|
||||
triangles.push(triple);
|
||||
}
|
||||
const sorted = [...triangles].sort(tupleCompare);
|
||||
if (triangles.some((triple, index) => !equalArray(triple, sorted[index]))) throw new Error("GLB triangle records are not canonically sorted");
|
||||
} else {
|
||||
const segments: number[][] = [];
|
||||
for (let index = 0; index < indexCount; index += 2) {
|
||||
const pair = indices.slice(index, index + 2);
|
||||
if (pair[0] >= pair[1]) throw new Error("GLB line indices are not in canonical endpoint order");
|
||||
if (equalArray(positions[pair[0]], positions[pair[1]])) throw new Error("GLB contains a collapsed line segment");
|
||||
segments.push(pair);
|
||||
}
|
||||
const sortedUnique = [...new Map(segments.map((pair) => [pair.join(","), pair])).values()].sort(tupleCompare);
|
||||
if (segments.length !== sortedUnique.length || segments.some((pair, index) => !equalArray(pair, sortedUnique[index]))) throw new Error("GLB line records are not sorted and unique");
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
vertexCount,
|
||||
indexCount,
|
||||
primitiveCount,
|
||||
decodedBufferBytes: 2 * parsedViews.reduce((sum, view) => sum + integer(view.byteLength, "bufferView byteLength"), 0),
|
||||
positionBounds: [minimum, maximum],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./canonical.js";
|
||||
export * from "./documents.js";
|
||||
export * from "./glb.js";
|
||||
export * from "./report.js";
|
||||
export * from "./resources.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./zip.js";
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { parseStrictJson } from "./canonical.js";
|
||||
|
||||
export type ValidationPhase = "parse" | "structure" | "semantic" | "package" | "budget";
|
||||
export type ValidationArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product" | "package" | "glb" | "zip";
|
||||
|
||||
export interface ValidationRule {
|
||||
readonly id: string;
|
||||
readonly phase: ValidationPhase;
|
||||
readonly precedence: number;
|
||||
readonly artifacts: readonly ValidationArtifact[];
|
||||
readonly pointer_policy: "exact" | "nearest_record" | "root";
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
interface ValidationRuleRegistry {
|
||||
readonly schema_version: "1.0";
|
||||
readonly registry_id: "scene-1.0-rules";
|
||||
readonly phases: readonly ValidationPhase[];
|
||||
readonly rules: readonly ValidationRule[];
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: "error";
|
||||
code: string;
|
||||
path: string;
|
||||
message: string;
|
||||
phase: ValidationPhase;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
valid: boolean;
|
||||
issues: readonly ValidationIssue[];
|
||||
firstError: ValidationIssue | null;
|
||||
}
|
||||
|
||||
const phaseOrder: Record<ValidationPhase, number> = {
|
||||
parse: 0,
|
||||
structure: 1,
|
||||
semantic: 2,
|
||||
package: 3,
|
||||
budget: 4,
|
||||
};
|
||||
|
||||
function utf8Compare(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
||||
}
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts",
|
||||
);
|
||||
const ruleSchema = parseStrictJson(readFileSync(resolve(contractRoot, "schemas/rules-1.schema.json")));
|
||||
const validateRuleSchema = new Ajv2020({ strict: true, allErrors: true }).compile(ruleSchema as object);
|
||||
|
||||
export function validateRuleRegistry(value: unknown): ReadonlyMap<string, ValidationRule> {
|
||||
if (!validateRuleSchema(value)) {
|
||||
const errors = [...(validateRuleSchema.errors ?? [])].sort(
|
||||
(left, right) => utf8Compare(left.instancePath, right.instancePath) ||
|
||||
utf8Compare(left.message ?? "", right.message ?? ""),
|
||||
);
|
||||
const first = errors[0];
|
||||
throw new Error(`invalid scene rule registry at ${first?.instancePath ?? ""}: ${first?.message ?? "schema validation failed"}`);
|
||||
}
|
||||
|
||||
const registry = value as ValidationRuleRegistry;
|
||||
const registryPhaseOrder = new Map(registry.phases.map((phase, index) => [phase, index]));
|
||||
const byId = new Map<string, ValidationRule>();
|
||||
const phasePrecedence = new Set<string>();
|
||||
for (const rule of registry.rules) {
|
||||
if (byId.has(rule.id)) throw new Error(`invalid scene rule registry: duplicate rule ID ${JSON.stringify(rule.id)}`);
|
||||
const precedenceKey = `${rule.phase}\0${rule.precedence}`;
|
||||
if (phasePrecedence.has(precedenceKey)) {
|
||||
throw new Error(`invalid scene rule registry: duplicate precedence ${rule.precedence} in phase ${JSON.stringify(rule.phase)}`);
|
||||
}
|
||||
byId.set(rule.id, rule);
|
||||
phasePrecedence.add(precedenceKey);
|
||||
}
|
||||
const expected = [...registry.rules].sort(
|
||||
(left, right) =>
|
||||
registryPhaseOrder.get(left.phase)! - registryPhaseOrder.get(right.phase)! ||
|
||||
left.precedence - right.precedence,
|
||||
);
|
||||
if (registry.rules.some((rule, index) => rule !== expected[index])) {
|
||||
throw new Error("invalid scene rule registry: rules must be ordered by phase and precedence");
|
||||
}
|
||||
for (const phase of registry.phases) {
|
||||
const phaseRules = registry.rules.filter((rule) => rule.phase === phase);
|
||||
const ids = phaseRules.map((rule) => rule.id);
|
||||
const expectedIds = [...ids].sort(utf8Compare);
|
||||
if (ids.some((id, index) => id !== expectedIds[index])) {
|
||||
throw new Error(`invalid scene rule registry: precedence in phase ${JSON.stringify(phase)} must match unsigned UTF-8 rule-ID order`);
|
||||
}
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
const rules = validateRuleRegistry(
|
||||
parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))),
|
||||
);
|
||||
|
||||
export function hasRootPointerPolicy(code: string): boolean {
|
||||
return rules.get(code)?.pointer_policy === "root";
|
||||
}
|
||||
|
||||
export function issue(
|
||||
code: string,
|
||||
path: string,
|
||||
message: string,
|
||||
phase: ValidationPhase = "semantic",
|
||||
): ValidationIssue {
|
||||
return { severity: "error", code, path, message, phase };
|
||||
}
|
||||
|
||||
export function report(input: Iterable<ValidationIssue>, artifact?: ValidationArtifact): ValidationReport {
|
||||
const unique = new Map<string, ValidationIssue>();
|
||||
for (const item of input) {
|
||||
if (artifact !== undefined) {
|
||||
const rule = rules.get(item.code);
|
||||
if (rule === undefined) throw new Error(`unregistered scene validation issue code: ${JSON.stringify(item.code)}`);
|
||||
if (item.phase !== rule.phase) {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} has phase ${JSON.stringify(item.phase)}; registered phase is ${JSON.stringify(rule.phase)}`);
|
||||
}
|
||||
if (!rule.artifacts.includes(artifact)) {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} does not apply to artifact ${JSON.stringify(artifact)}`);
|
||||
}
|
||||
if (rule.pointer_policy === "root" && item.path !== "") {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} requires the root pointer`);
|
||||
}
|
||||
}
|
||||
const key = artifact === undefined
|
||||
? `${item.phase}\0${item.code}\0${item.path}`
|
||||
: `${item.phase}\0${item.code}\0${item.path}\0${item.message}`;
|
||||
if (!unique.has(key)) unique.set(key, item);
|
||||
}
|
||||
const issues = [...unique.values()].sort(
|
||||
(left, right) =>
|
||||
phaseOrder[left.phase] - phaseOrder[right.phase] ||
|
||||
(artifact === undefined
|
||||
? utf8Compare(left.code, right.code)
|
||||
: rules.get(left.code)!.precedence - rules.get(right.code)!.precedence) ||
|
||||
utf8Compare(left.path, right.path) ||
|
||||
utf8Compare(left.message, right.message),
|
||||
);
|
||||
return { valid: issues.length === 0, issues, firstError: issues[0] ?? null };
|
||||
}
|
||||
|
||||
export function pointer(parts: readonly (string | number)[]): string {
|
||||
if (parts.length === 0) return "";
|
||||
return `/${parts.map((part) => String(part).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const BASE_LIMITS = {
|
||||
zip_members: 50_000,
|
||||
input_archive_bytes: 256 * 1024 * 1024,
|
||||
canonical_archive_bytes: 256 * 1024 * 1024,
|
||||
total_uncompressed_bytes: 1024 * 1024 * 1024,
|
||||
one_member_bytes: 256 * 1024 * 1024,
|
||||
scene_json_bytes: 32 * 1024 * 1024,
|
||||
entity_json_bytes: 64 * 1024 * 1024,
|
||||
model_json_bytes: 64 * 1024 * 1024,
|
||||
presentation_json_bytes: 8 * 1024 * 1024,
|
||||
compression_ratio: 100,
|
||||
json_depth: 64,
|
||||
json_string_bytes: 1024 * 1024,
|
||||
uri_bytes: 1024,
|
||||
structural_id_bytes: 4096,
|
||||
definitions: 25_000,
|
||||
nodes: 100_000,
|
||||
assets_per_kind: 25_000,
|
||||
appearances: 25_000,
|
||||
connectors: 100_000,
|
||||
cameras: 1000,
|
||||
hierarchy_depth: 256,
|
||||
forwarded_connector_depth: 64,
|
||||
entities_per_sidecar: 500_000,
|
||||
entities_total: 2_000_000,
|
||||
triangle_vertices_per_asset: 2_000_000,
|
||||
triangle_vertices_total: 10_000_000,
|
||||
triangles_per_asset: 2_000_000,
|
||||
triangles_total: 10_000_000,
|
||||
line_vertices_per_asset: 2_000_000,
|
||||
line_vertices_total: 10_000_000,
|
||||
line_segments_per_asset: 2_000_000,
|
||||
line_segments_total: 10_000_000,
|
||||
static_decoded_buffer_bytes: 512 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export type SceneResourceLimits = { readonly [Name in keyof typeof BASE_LIMITS]: number };
|
||||
export type SceneResourceLimitName = keyof typeof BASE_LIMITS;
|
||||
|
||||
export function preflightInputArchiveSize(size: number, limits: SceneResourceLimits = BASE_LIMITS): number {
|
||||
if (!Number.isSafeInteger(size) || size < 0) throw new Error("invalid input archive size");
|
||||
if (size > limits.input_archive_bytes) throw new Error("input archive exceeds resource limit");
|
||||
return size;
|
||||
}
|
||||
|
||||
export function preflightResourceCount(
|
||||
count: number,
|
||||
limitName: SceneResourceLimitName,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new Error("invalid resource count");
|
||||
if (count > limits[limitName]) throw new Error(`${limitName} exceeds resource limit`);
|
||||
return count;
|
||||
}
|
||||
|
||||
export function preflightAggregateCompressionRatio(
|
||||
uncompressedSize: number,
|
||||
archiveSize: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(archiveSize) || archiveSize <= 0) {
|
||||
throw new Error("invalid compression ratio sizes");
|
||||
}
|
||||
if (uncompressedSize > limits.compression_ratio * archiveSize) throw new Error("aggregate compression ratio exceeds limit");
|
||||
}
|
||||
|
||||
export function preflightMemberCompressionRatio(
|
||||
uncompressedSize: number,
|
||||
compressedSize: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(compressedSize) || compressedSize < 0) {
|
||||
throw new Error("invalid compression ratio sizes");
|
||||
}
|
||||
if (uncompressedSize > limits.compression_ratio * Math.max(1, compressedSize)) throw new Error("member compression ratio exceeds limit");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
|
||||
import { parseStrictJson } from "./canonical.js";
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts/schemas",
|
||||
);
|
||||
|
||||
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
||||
|
||||
function declareRequiredProperties(value: unknown): void {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
const schema = value as Record<string, unknown>;
|
||||
if (
|
||||
schema.type === undefined &&
|
||||
(schema.properties !== undefined || schema.required !== undefined)
|
||||
) {
|
||||
schema.type = "object";
|
||||
}
|
||||
if (Array.isArray(schema.required) && schema.required.every((item) => typeof item === "string")) {
|
||||
const properties =
|
||||
schema.properties !== null && typeof schema.properties === "object" && !Array.isArray(schema.properties)
|
||||
? (schema.properties as Record<string, unknown>)
|
||||
: {};
|
||||
for (const name of schema.required) properties[name] ??= {};
|
||||
schema.properties = properties;
|
||||
}
|
||||
for (const key of ["properties", "$defs", "patternProperties", "dependentSchemas"]) {
|
||||
const schemas = schema[key];
|
||||
if (schemas !== null && typeof schemas === "object" && !Array.isArray(schemas)) {
|
||||
Object.values(schemas).forEach(declareRequiredProperties);
|
||||
}
|
||||
}
|
||||
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
||||
const schemas = schema[key];
|
||||
if (Array.isArray(schemas)) schemas.forEach(declareRequiredProperties);
|
||||
}
|
||||
for (const key of [
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"items",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
]) {
|
||||
declareRequiredProperties(schema[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function load(name: string): ValidateFunction {
|
||||
const schema = parseStrictJson(readFileSync(resolve(contractRoot, name)));
|
||||
// Ajv strictRequired expects declarations in the same nested subschema. The
|
||||
// contract uses valid required-only guards, so add behavior-neutral schemas.
|
||||
declareRequiredProperties(schema);
|
||||
return ajv.compile(schema as object);
|
||||
}
|
||||
|
||||
export type SchemaArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product";
|
||||
|
||||
const schemaFiles: Record<SchemaArtifact, string> = {
|
||||
scene: "scene-1.0.schema.json",
|
||||
entities: "entities-1.0.schema.json",
|
||||
presentation: "presentation-1.0.schema.json",
|
||||
connector_binding: "connector-binding-1.0.schema.json",
|
||||
normalized_product: "normalized-product-1.schema.json",
|
||||
};
|
||||
const schemaDocuments = Object.fromEntries(
|
||||
Object.entries(schemaFiles).map(([artifact, name]) => [artifact, parseStrictJson(readFileSync(resolve(contractRoot, name)))]),
|
||||
) as Record<SchemaArtifact, Record<string, unknown>>;
|
||||
const pointerValidators = new Map<string, ValidateFunction>();
|
||||
|
||||
export function validateSchemaPointer(artifact: SchemaArtifact, schemaPointer: string, value: unknown): boolean {
|
||||
const key = `${artifact}:${schemaPointer}`;
|
||||
let validate = pointerValidators.get(key);
|
||||
if (validate === undefined) {
|
||||
const root = structuredClone(schemaDocuments[artifact]);
|
||||
delete root.$id;
|
||||
const wrapper = schemaPointer === "#"
|
||||
? root
|
||||
: { $schema: root.$schema, $ref: schemaPointer, $defs: root.$defs };
|
||||
declareRequiredProperties(wrapper);
|
||||
validate = ajv.compile(wrapper);
|
||||
pointerValidators.set(key, validate);
|
||||
}
|
||||
return Boolean(validate(value));
|
||||
}
|
||||
|
||||
export const validateSceneSchema = load("scene-1.0.schema.json");
|
||||
export const validateEntitiesSchema = load("entities-1.0.schema.json");
|
||||
export const validatePresentationSchema = load("presentation-1.0.schema.json");
|
||||
export const validateConnectorBindingSchema = load("connector-binding-1.0.schema.json");
|
||||
export const validateNormalizedProductSchema = load("normalized-product-1.schema.json");
|
||||
|
||||
export function schemaErrors(validate: ValidateFunction, value: unknown): ErrorObject[] {
|
||||
validate(value);
|
||||
return validate.errors === null || validate.errors === undefined ? [] : [...validate.errors];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Deterministic rigid-transform primitives used by Scene validation. */
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function composeRigidTransforms(left: JsonRecord, right: JsonRecord): JsonRecord {
|
||||
const origin = left.origin as number[];
|
||||
const xAxis = left.x_axis as number[];
|
||||
const yAxis = left.y_axis as number[];
|
||||
const zAxis = left.z_axis as number[];
|
||||
const rotate = (vector: number[]): number[] => [0, 1, 2].map((index) =>
|
||||
(xAxis[index] * vector[0] + yAxis[index] * vector[1]) + zAxis[index] * vector[2]
|
||||
);
|
||||
const rotatedOrigin = rotate(right.origin as number[]);
|
||||
return {
|
||||
origin: origin.map((component, index) => component + rotatedOrigin[index]),
|
||||
x_axis: rotate(right.x_axis as number[]),
|
||||
y_axis: rotate(right.y_axis as number[]),
|
||||
z_axis: rotate(right.z_axis as number[]),
|
||||
};
|
||||
}
|
||||
|
||||
export function rigidTransformsEqual(actual: JsonRecord, expected: JsonRecord): boolean {
|
||||
const actualOrigin = actual.origin as number[];
|
||||
const expectedOrigin = expected.origin as number[];
|
||||
const originEpsilon = Math.max(
|
||||
1e-9,
|
||||
1e-12 * Math.max(1, ...actualOrigin.map(Math.abs), ...expectedOrigin.map(Math.abs)),
|
||||
);
|
||||
return actualOrigin.every((component, index) => Math.abs(component - expectedOrigin[index]) <= originEpsilon) &&
|
||||
["x_axis", "y_axis", "z_axis"].every((key) =>
|
||||
(actual[key] as number[]).every((component, index) =>
|
||||
Math.abs(component - (expected[key] as number[])[index]) <= 1e-12
|
||||
)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import {
|
||||
BASE_LIMITS,
|
||||
preflightAggregateCompressionRatio,
|
||||
preflightInputArchiveSize,
|
||||
preflightMemberCompressionRatio,
|
||||
type SceneResourceLimits,
|
||||
} from "./resources.js";
|
||||
|
||||
export interface ArchiveInfo {
|
||||
members: ReadonlyMap<string, Buffer>;
|
||||
inputSize: number;
|
||||
canonicalSize: number;
|
||||
usedDeflate: boolean;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
method: number;
|
||||
crc32: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localOffset: number;
|
||||
}
|
||||
|
||||
const memberPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$/;
|
||||
function crc32Table(): Uint32Array {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const crcTable = crc32Table();
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function ascii(raw: Uint8Array, context: string): string {
|
||||
if (raw.some((byte) => byte > 0x7f)) throw new Error("archive member names must be ASCII");
|
||||
return Buffer.from(raw).toString("ascii");
|
||||
}
|
||||
|
||||
function validateName(name: string, seen: Set<string>): Buffer {
|
||||
const encoded = Buffer.from(name, "ascii");
|
||||
if (encoded.toString("ascii") !== name) throw new Error("archive member names must be ASCII");
|
||||
if (!memberPattern.test(name)) throw new Error(`invalid archive member name: ${JSON.stringify(name)}`);
|
||||
if (name.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new Error(`invalid archive member path segment: ${JSON.stringify(name)}`);
|
||||
}
|
||||
const folded = name.toLowerCase();
|
||||
if (seen.has(folded)) throw new Error(`duplicate or case-colliding archive member: ${JSON.stringify(name)}`);
|
||||
seen.add(folded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function canonicalArchiveSize(sizes: ReadonlyMap<string, number>): number {
|
||||
let total = 22;
|
||||
for (const [name, size] of sizes) total += 76 + 2 * Buffer.byteLength(name, "ascii") + size;
|
||||
return total;
|
||||
}
|
||||
|
||||
export function preflightArchiveMemberSizes(
|
||||
sizes: ReadonlyMap<string, number>,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
if (sizes.size === 0 || !sizes.has("scene.json")) throw new Error("scene package must contain scene.json");
|
||||
if (sizes.size > limits.zip_members) throw new Error("archive member count exceeds resource limit");
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (const [name, size] of sizes) {
|
||||
validateName(name, seen);
|
||||
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`invalid member size for ${JSON.stringify(name)}`);
|
||||
if (size > limits.one_member_bytes) throw new Error(`archive member exceeds resource limit: ${JSON.stringify(name)}`);
|
||||
if (name === "scene.json" && size > limits.scene_json_bytes) throw new Error("scene.json exceeds resource limit");
|
||||
if (name.startsWith("entities/") && size > limits.entity_json_bytes) throw new Error(`entity sidecar exceeds resource limit: ${JSON.stringify(name)}`);
|
||||
if (name === "model/model.json" && size > limits.model_json_bytes) throw new Error("embedded model.json exceeds resource limit");
|
||||
if (name === "presentation/presentation.json" && size > limits.presentation_json_bytes) throw new Error("presentation JSON exceeds resource limit");
|
||||
total += size;
|
||||
if (total > limits.total_uncompressed_bytes) throw new Error("total uncompressed bytes exceed resource limit");
|
||||
}
|
||||
const result = canonicalArchiveSize(sizes);
|
||||
if (result > limits.canonical_archive_bytes) throw new Error("canonical stored archive exceeds resource limit");
|
||||
if (sizes.size > 0xffff || result > 0xffffffff) throw new Error("archive would require ZIP64");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalZipBytes(
|
||||
input: ReadonlyMap<string, Uint8Array> | Record<string, Uint8Array>,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): Buffer {
|
||||
const members = input instanceof Map ? new Map(input) : new Map(Object.entries(input));
|
||||
const payloads = new Map([...members].map(([name, value]) => [name, Buffer.from(value)]));
|
||||
const canonicalSize = preflightArchiveMemberSizes(new Map([...payloads].map(([name, value]) => [name, value.length])), limits);
|
||||
const localParts: Buffer[] = [];
|
||||
const centralParts: Buffer[] = [];
|
||||
let localOffset = 0;
|
||||
for (const name of [...payloads.keys()].sort((left, right) => Buffer.compare(Buffer.from(left, "ascii"), Buffer.from(right, "ascii")))) {
|
||||
const nameBytes = Buffer.from(name, "ascii");
|
||||
const payload = payloads.get(name)!;
|
||||
const crc = crc32(payload);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(0, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(payload.length, 18);
|
||||
local.writeUInt32LE(payload.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
localParts.push(local, nameBytes, payload);
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0x0021, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(payload.length, 20);
|
||||
central.writeUInt32LE(payload.length, 24);
|
||||
central.writeUInt16LE(nameBytes.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0x81a40000, 38);
|
||||
central.writeUInt32LE(localOffset, 42);
|
||||
centralParts.push(central, nameBytes);
|
||||
localOffset += local.length + nameBytes.length + payload.length;
|
||||
}
|
||||
const centralBytes = Buffer.concat(centralParts);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(payloads.size, 8);
|
||||
eocd.writeUInt16LE(payloads.size, 10);
|
||||
eocd.writeUInt32LE(centralBytes.length, 12);
|
||||
eocd.writeUInt32LE(localOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
const result = Buffer.concat([...localParts, centralBytes, eocd]);
|
||||
if (result.length !== canonicalSize) throw new Error("canonical ZIP size formula disagrees with encoder");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function preflightZipBytes(data: Uint8Array, limits: SceneResourceLimits = BASE_LIMITS): ArchiveInfo {
|
||||
preflightInputArchiveSize(data.byteLength, limits);
|
||||
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (raw.length < 22) throw new Error("truncated ZIP archive");
|
||||
const eocdOffset = raw.length - 22;
|
||||
if (raw.readUInt32LE(eocdOffset) !== 0x06054b50 || raw.readUInt16LE(eocdOffset + 20) !== 0) {
|
||||
throw new Error("ZIP EOCD must be final and have no comment");
|
||||
}
|
||||
const disk = raw.readUInt16LE(eocdOffset + 4);
|
||||
const centralDisk = raw.readUInt16LE(eocdOffset + 6);
|
||||
const diskCount = raw.readUInt16LE(eocdOffset + 8);
|
||||
const totalCount = raw.readUInt16LE(eocdOffset + 10);
|
||||
const centralSize = raw.readUInt32LE(eocdOffset + 12);
|
||||
const centralOffset = raw.readUInt32LE(eocdOffset + 16);
|
||||
if (disk !== 0 || centralDisk !== 0 || diskCount !== totalCount) throw new Error("multi-disk ZIP archives are forbidden");
|
||||
if (totalCount > limits.zip_members) throw new Error("archive member count exceeds resource limit");
|
||||
if (centralOffset + centralSize !== eocdOffset) throw new Error("ZIP central directory offset/size mismatch");
|
||||
const entries: Entry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor = centralOffset;
|
||||
for (let index = 0; index < totalCount; index += 1) {
|
||||
if (cursor + 46 > eocdOffset) throw new Error("truncated ZIP central directory");
|
||||
if (raw.readUInt32LE(cursor) !== 0x02014b50) throw new Error("invalid ZIP central header signature");
|
||||
const madeBy = raw.readUInt16LE(cursor + 4);
|
||||
const needed = raw.readUInt16LE(cursor + 6);
|
||||
const flags = raw.readUInt16LE(cursor + 8);
|
||||
const method = raw.readUInt16LE(cursor + 10);
|
||||
const dosTime = raw.readUInt16LE(cursor + 12);
|
||||
const dosDate = raw.readUInt16LE(cursor + 14);
|
||||
const crc = raw.readUInt32LE(cursor + 16);
|
||||
const compressedSize = raw.readUInt32LE(cursor + 20);
|
||||
const uncompressedSize = raw.readUInt32LE(cursor + 24);
|
||||
const nameLength = raw.readUInt16LE(cursor + 28);
|
||||
const extraLength = raw.readUInt16LE(cursor + 30);
|
||||
const commentLength = raw.readUInt16LE(cursor + 32);
|
||||
const diskStart = raw.readUInt16LE(cursor + 34);
|
||||
const internalAttributes = raw.readUInt16LE(cursor + 36);
|
||||
const externalAttributes = raw.readUInt32LE(cursor + 38);
|
||||
const localOffset = raw.readUInt32LE(cursor + 42);
|
||||
if (madeBy !== 0x0314 || needed !== 20) throw new Error("ZIP creator/version profile mismatch");
|
||||
if (flags !== 0x0800 || (method !== 0 && method !== 8)) throw new Error("unsupported ZIP flags or compression method");
|
||||
if (dosTime !== 0 || dosDate !== 0x0021) throw new Error("ZIP timestamp profile mismatch");
|
||||
if (extraLength !== 0 || commentLength !== 0 || diskStart !== 0 || internalAttributes !== 0) throw new Error("ZIP extra/comment/disk/attributes are forbidden");
|
||||
if (externalAttributes !== 0x81a40000) throw new Error("ZIP member must be Unix regular mode 0100644");
|
||||
const nameStart = cursor + 46;
|
||||
const recordEnd = nameStart + nameLength + extraLength + commentLength;
|
||||
if (recordEnd > eocdOffset) throw new Error("truncated ZIP central member record");
|
||||
const name = ascii(raw.subarray(nameStart, nameStart + nameLength), "central name");
|
||||
validateName(name, seen);
|
||||
entries.push({ name, method, crc32: crc, compressedSize, uncompressedSize, localOffset });
|
||||
cursor = recordEnd;
|
||||
}
|
||||
if (cursor !== eocdOffset) throw new Error("unexpected bytes in ZIP central directory");
|
||||
const canonicalSize = preflightArchiveMemberSizes(new Map(entries.map((entry) => [entry.name, entry.uncompressedSize])), limits);
|
||||
const occupied: [number, number][] = [];
|
||||
const payloadRanges: { entry: Entry; start: number; end: number }[] = [];
|
||||
let totalUncompressed = 0;
|
||||
for (const entry of entries) {
|
||||
const offset = entry.localOffset;
|
||||
if (offset + 30 > centralOffset) throw new Error("truncated ZIP local header");
|
||||
if (raw.readUInt32LE(offset) !== 0x04034b50) throw new Error("invalid ZIP local header signature");
|
||||
const needed = raw.readUInt16LE(offset + 4);
|
||||
const flags = raw.readUInt16LE(offset + 6);
|
||||
const method = raw.readUInt16LE(offset + 8);
|
||||
const dosTime = raw.readUInt16LE(offset + 10);
|
||||
const dosDate = raw.readUInt16LE(offset + 12);
|
||||
const crc = raw.readUInt32LE(offset + 14);
|
||||
const compressedSize = raw.readUInt32LE(offset + 18);
|
||||
const uncompressedSize = raw.readUInt32LE(offset + 22);
|
||||
const nameLength = raw.readUInt16LE(offset + 26);
|
||||
const extraLength = raw.readUInt16LE(offset + 28);
|
||||
if (needed !== 20 || flags !== 0x0800 || method !== entry.method || dosTime !== 0 || dosDate !== 0x0021 || crc !== entry.crc32 || compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize || extraLength !== 0) {
|
||||
throw new Error("ZIP central/local header mismatch");
|
||||
}
|
||||
const nameStart = offset + 30;
|
||||
const nameEnd = nameStart + nameLength;
|
||||
const payloadEnd = nameEnd + compressedSize;
|
||||
if (payloadEnd > centralOffset) throw new Error("ZIP member payload crosses central directory");
|
||||
if (ascii(raw.subarray(nameStart, nameEnd), "local name") !== entry.name) throw new Error("ZIP central/local member name mismatch");
|
||||
occupied.push([offset, payloadEnd]);
|
||||
payloadRanges.push({ entry, start: nameEnd, end: payloadEnd });
|
||||
totalUncompressed += uncompressedSize;
|
||||
}
|
||||
occupied.sort((left, right) => left[0] - right[0]);
|
||||
if (occupied.length > 0) {
|
||||
if (occupied[0][0] !== 0) throw new Error("ZIP archive has a leading prefix");
|
||||
for (let index = 1; index < occupied.length; index += 1) {
|
||||
if (occupied[index - 1][1] !== occupied[index][0]) throw new Error("ZIP local records overlap or contain gaps");
|
||||
}
|
||||
if (occupied.at(-1)![1] !== centralOffset) throw new Error("ZIP bytes exist between local records and central directory");
|
||||
}
|
||||
preflightAggregateCompressionRatio(totalUncompressed, raw.length, limits);
|
||||
|
||||
const members = new Map<string, Buffer>();
|
||||
for (const { entry, start, end } of payloadRanges) {
|
||||
const compressed = raw.subarray(start, end);
|
||||
try {
|
||||
preflightMemberCompressionRatio(entry.uncompressedSize, entry.compressedSize, limits);
|
||||
} catch {
|
||||
throw new Error(`member compression ratio exceeds limit: ${JSON.stringify(entry.name)}`);
|
||||
}
|
||||
let payload: Buffer;
|
||||
if (entry.method === 0) {
|
||||
payload = Buffer.from(compressed);
|
||||
} else {
|
||||
try {
|
||||
const inflated = inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1, info: true }) as unknown as {
|
||||
buffer: Buffer;
|
||||
engine: { bytesWritten: number };
|
||||
};
|
||||
if (inflated.engine.bytesWritten !== compressed.length) throw new Error("trailing deflate data");
|
||||
payload = Buffer.from(inflated.buffer);
|
||||
} catch {
|
||||
throw new Error(`invalid deflate stream: ${JSON.stringify(entry.name)}`);
|
||||
}
|
||||
}
|
||||
if (payload.length !== entry.uncompressedSize) throw new Error(`ZIP member decoded size mismatch: ${JSON.stringify(entry.name)}`);
|
||||
if (crc32(payload) !== entry.crc32) throw new Error(`ZIP member CRC mismatch: ${JSON.stringify(entry.name)}`);
|
||||
members.set(entry.name, payload);
|
||||
}
|
||||
return { members, inputSize: raw.length, canonicalSize, usedDeflate: entries.some((entry) => entry.method === 8) };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
canonicalJsonBytes,
|
||||
parseCanonicalJson,
|
||||
parseStrictJson,
|
||||
validateNormalizedProduct,
|
||||
withSceneRevision,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("canonical JSON renders finite Numbers from their value", () => {
|
||||
assert.equal(canonicalJsonBytes(1e30).toString("utf8"), "1e+30");
|
||||
assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30");
|
||||
|
||||
const nested = parseStrictJson('{"large":1e30,"small":[1E-7]}');
|
||||
assert.equal(
|
||||
canonicalJsonBytes(nested).toString("utf8"),
|
||||
'{"large":1e+30,"small":[1e-7]}',
|
||||
);
|
||||
assert.deepEqual(parseCanonicalJson("1e+30"), 1e30);
|
||||
assert.throws(() => parseCanonicalJson("1e30"), /canonical/);
|
||||
});
|
||||
|
||||
test("canonical JSON normalizes negative zero at root and nested positions", () => {
|
||||
const root = parseStrictJson("-0");
|
||||
assert.equal(Object.is(root, -0), true);
|
||||
assert.equal(canonicalJsonBytes(root).toString("utf8"), "0");
|
||||
|
||||
const nested = parseStrictJson('{"values":[-0,0e0]}');
|
||||
assert.equal(canonicalJsonBytes(nested).toString("utf8"), '{"values":[0,0]}');
|
||||
assert.throws(() => parseCanonicalJson("-0"), /canonical/);
|
||||
});
|
||||
|
||||
test("cloning parsed values does not change canonical bytes or scene revision", () => {
|
||||
const scene = parseStrictJson('{"measurement":1e30,"nested":{"epsilon":1e-7}}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const clone = structuredClone(scene);
|
||||
|
||||
assert.deepEqual(canonicalJsonBytes(clone), canonicalJsonBytes(scene));
|
||||
assert.deepEqual(withSceneRevision(clone), withSceneRevision(scene));
|
||||
});
|
||||
|
||||
test("unsafe integer JSON is rejected lexically without affecting exponent Numbers", () => {
|
||||
assert.throws(() => parseStrictJson("9007199254740992"), /safe domain/);
|
||||
assert.throws(() => parseStrictJson('{"value":-9007199254740992}'), /safe domain/);
|
||||
assert.equal(
|
||||
canonicalJsonBytes(Number.MAX_SAFE_INTEGER + 1).toString("utf8"),
|
||||
"9007199254740992",
|
||||
);
|
||||
assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30");
|
||||
});
|
||||
|
||||
test("strict JSON rejects unpaired surrogates in values and object keys", () => {
|
||||
assert.throws(() => parseStrictJson('{"value":"\\ud800"}'), /surrogate/);
|
||||
assert.throws(() => parseStrictJson('{"\\ud800":0}'), /surrogate/);
|
||||
});
|
||||
|
||||
test("serialized JSON over the depth limit reports a budget issue", () => {
|
||||
let serialized = "0";
|
||||
for (let depth = 0; depth < 65; depth += 1) serialized = `{"x":${serialized}}`;
|
||||
|
||||
const result = validateNormalizedProduct(serialized);
|
||||
assert.equal(result.firstError?.code, "resource_limit_exceeded");
|
||||
assert.equal(result.firstError?.phase, "budget");
|
||||
assert.equal(result.firstError?.path, "/x".repeat(65));
|
||||
});
|
||||
|
||||
test("serialized non-finite tokens and overflow report nonfinite_json_number", () => {
|
||||
for (const serialized of [
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"1e999",
|
||||
'[0,NaN]',
|
||||
'{"value":1e999}',
|
||||
]) {
|
||||
const result = validateNormalizedProduct(serialized);
|
||||
assert.equal(result.firstError?.code, "nonfinite_json_number", serialized);
|
||||
assert.equal(result.firstError?.phase, "parse", serialized);
|
||||
}
|
||||
|
||||
const programmatic = validateNormalizedProduct({ metadata: { value: Number.NaN } });
|
||||
assert.equal(programmatic.firstError?.code, "nonfinite_json_number");
|
||||
assert.equal(programmatic.firstError?.path, "/metadata/value");
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { withSceneRevision } from "../src/index.js";
|
||||
|
||||
export interface ExpectedIssue { code: string; path: string }
|
||||
export interface Mutation {
|
||||
operation: "add" | "delete" | "set";
|
||||
path: (string | number)[];
|
||||
value?: unknown;
|
||||
}
|
||||
export interface SceneFieldCase {
|
||||
name: string;
|
||||
valid: boolean;
|
||||
expected: ExpectedIssue | null;
|
||||
mutations: Mutation[];
|
||||
recompute_revision: boolean;
|
||||
}
|
||||
export interface PackageCase {
|
||||
name: string;
|
||||
valid: boolean;
|
||||
expected: ExpectedIssue | null;
|
||||
manifest_base64: string;
|
||||
blob_uris: string[];
|
||||
blob_mutations?: Record<string, string>;
|
||||
}
|
||||
export interface SceneShapeCase extends PackageCase {
|
||||
expected_shape: SceneShapeFacts;
|
||||
}
|
||||
export interface SceneShapeFacts {
|
||||
definition_count: number;
|
||||
definition_occurrence_counts: Record<string, number>;
|
||||
edge_asset_count: number;
|
||||
entity_asset_count: number;
|
||||
geometry_asset_count: number;
|
||||
maximum_depth: number;
|
||||
node_count: number;
|
||||
root_node_ids: string[];
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asRecords(value: unknown): JsonRecord[] {
|
||||
assert.ok(Array.isArray(value));
|
||||
return value.map(asRecord);
|
||||
}
|
||||
|
||||
export function corpusBlobs(
|
||||
pool: Record<string, string>,
|
||||
item: PackageCase,
|
||||
): Map<string, Uint8Array> {
|
||||
const blobs = new Map<string, Uint8Array>(
|
||||
item.blob_uris.map((uri) => [uri, Buffer.from(pool[uri], "base64")]),
|
||||
);
|
||||
for (const [uri, payload] of Object.entries(item.blob_mutations ?? {})) {
|
||||
blobs.set(uri, Buffer.from(payload, "base64"));
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
|
||||
export function applySceneFieldCase(
|
||||
scene: JsonRecord,
|
||||
item: SceneFieldCase,
|
||||
): JsonRecord {
|
||||
const result = structuredClone(scene);
|
||||
for (const mutation of item.mutations) {
|
||||
let current: JsonRecord | unknown[] = result;
|
||||
for (const part of mutation.path.slice(0, -1)) {
|
||||
current = Array.isArray(current)
|
||||
? current[part as number] as JsonRecord | unknown[]
|
||||
: current[part as string] as JsonRecord | unknown[];
|
||||
}
|
||||
const final = mutation.path.at(-1)!;
|
||||
if (mutation.operation === "delete") {
|
||||
if (Array.isArray(current)) delete current[final as number];
|
||||
else delete current[final as string];
|
||||
} else if (Array.isArray(current)) {
|
||||
current[final as number] = structuredClone(mutation.value);
|
||||
} else {
|
||||
current[final as string] = structuredClone(mutation.value);
|
||||
}
|
||||
}
|
||||
return item.recompute_revision ? withSceneRevision(result) : result;
|
||||
}
|
||||
|
||||
export function sceneShapeFacts(scene: JsonRecord): SceneShapeFacts {
|
||||
const nodes = asRecords(scene.nodes);
|
||||
const definitionOccurrenceCounts: Record<string, number> = {};
|
||||
for (const node of nodes) {
|
||||
const definitionId = String(node.definition_id);
|
||||
definitionOccurrenceCounts[definitionId] =
|
||||
(definitionOccurrenceCounts[definitionId] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
definition_count: asRecords(scene.definitions).length,
|
||||
definition_occurrence_counts: Object.fromEntries(
|
||||
Object.entries(definitionOccurrenceCounts).sort(([left], [right]) =>
|
||||
Buffer.compare(Buffer.from(left), Buffer.from(right))
|
||||
),
|
||||
),
|
||||
edge_asset_count: asRecords(scene.edge_assets).length,
|
||||
entity_asset_count: asRecords(scene.entity_assets).length,
|
||||
geometry_asset_count: asRecords(scene.geometry_assets).length,
|
||||
maximum_depth: Math.max(
|
||||
...nodes.map((node) => {
|
||||
const source = asRecord(node.source);
|
||||
return Array.isArray(source.component_path) ? source.component_path.length : 0;
|
||||
}),
|
||||
),
|
||||
node_count: nodes.length,
|
||||
root_node_ids: nodes
|
||||
.filter((node) => node.parent_node_id === null)
|
||||
.map((node) => String(node.node_id)),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ConnectorBindingDocument,
|
||||
ContractDocumentValidationError,
|
||||
EntityDocument,
|
||||
type ImmutableContractDocument,
|
||||
NormalizedProductDocument,
|
||||
parseCanonicalJson,
|
||||
parseStrictJson,
|
||||
PresentationDocument,
|
||||
SceneDocument,
|
||||
sha256,
|
||||
} from "../src/index.js";
|
||||
|
||||
interface ValidationCase {
|
||||
valid: boolean;
|
||||
payload_base64: string;
|
||||
}
|
||||
|
||||
interface Corpus {
|
||||
artifacts: Record<string, { base64: string }>;
|
||||
presentation_cases: ValidationCase[];
|
||||
connector_binding_cases: ValidationCase[];
|
||||
normalized_product_cases: ValidationCase[];
|
||||
}
|
||||
|
||||
interface TestFactory {
|
||||
fromValue(value: unknown): ImmutableContractDocument<unknown>;
|
||||
parse(data: Uint8Array | string): ImmutableContractDocument<unknown>;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpus = parseStrictJson(
|
||||
readFileSync(resolve(here, "../../../test/fixtures/scene-contract/corpus.json")),
|
||||
) as Corpus;
|
||||
|
||||
const validCase = (cases: ValidationCase[]): Buffer => {
|
||||
const item = cases.find((candidate) => candidate.valid);
|
||||
assert.ok(item !== undefined);
|
||||
return Buffer.from(item.payload_base64, "base64");
|
||||
};
|
||||
|
||||
const documents: readonly [string, TestFactory, Buffer][] = [
|
||||
[
|
||||
"scene",
|
||||
SceneDocument as unknown as TestFactory,
|
||||
Buffer.from(corpus.artifacts.scene.base64, "base64"),
|
||||
],
|
||||
[
|
||||
"entities",
|
||||
EntityDocument as unknown as TestFactory,
|
||||
Buffer.from(corpus.artifacts.entities.base64, "base64"),
|
||||
],
|
||||
[
|
||||
"presentation",
|
||||
PresentationDocument as unknown as TestFactory,
|
||||
validCase(corpus.presentation_cases),
|
||||
],
|
||||
[
|
||||
"connector binding",
|
||||
ConnectorBindingDocument as unknown as TestFactory,
|
||||
validCase(corpus.connector_binding_cases),
|
||||
],
|
||||
[
|
||||
"normalized product",
|
||||
NormalizedProductDocument as unknown as TestFactory,
|
||||
validCase(corpus.normalized_product_cases),
|
||||
],
|
||||
];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertDeepFrozen(value: unknown): void {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
for (const child of Object.values(value)) assertDeepFrozen(child);
|
||||
}
|
||||
|
||||
function nestedContainer(value: Record<string, unknown>): Record<string, unknown> | unknown[] {
|
||||
const result = Object.values(value).find(
|
||||
(child) => child !== null && typeof child === "object",
|
||||
);
|
||||
assert.ok(result !== undefined);
|
||||
return result as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
test("all contract document factories construct, parse, validate, and round trip", () => {
|
||||
for (const [name, factory, payload] of documents) {
|
||||
const input = record(parseCanonicalJson(payload));
|
||||
const constructed = factory.fromValue(input);
|
||||
const parsed = factory.parse(payload);
|
||||
|
||||
assert.deepEqual(constructed.canonicalBytes, payload, name);
|
||||
assert.deepEqual(parsed.canonicalBytes, payload, name);
|
||||
assert.equal(constructed.canonicalHash, sha256(payload), name);
|
||||
assert.deepEqual(constructed.toMutable(), parsed.toMutable(), name);
|
||||
assert.deepEqual(factory.fromValue(constructed.value).canonicalBytes, payload, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("contract documents resist nested mutation and isolate mutable copies", () => {
|
||||
for (const [name, factory, payload] of documents) {
|
||||
const input = record(parseCanonicalJson(payload));
|
||||
const document = factory.fromValue(input);
|
||||
const expectedHash = document.canonicalHash;
|
||||
const expectedBytes = document.canonicalBytes;
|
||||
|
||||
assert.equal(Object.isFrozen(document), true, name);
|
||||
assertDeepFrozen(document.value);
|
||||
assert.throws(() => {
|
||||
record(document.value).mutation = true;
|
||||
}, TypeError, name);
|
||||
const frozenNested = nestedContainer(record(document.value));
|
||||
assert.throws(() => {
|
||||
if (Array.isArray(frozenNested)) frozenNested.push(null);
|
||||
else frozenNested.mutation = true;
|
||||
}, TypeError, name);
|
||||
|
||||
input.mutation = true;
|
||||
const firstCopy = record(document.toMutable());
|
||||
const secondCopy = record(document.toMutable());
|
||||
const mutableNested = nestedContainer(firstCopy);
|
||||
if (Array.isArray(mutableNested)) mutableNested.push(null);
|
||||
else mutableNested.mutation = true;
|
||||
firstCopy.mutation = true;
|
||||
|
||||
assert.equal(Object.hasOwn(record(document.value), "mutation"), false, name);
|
||||
assert.equal(Object.hasOwn(secondCopy, "mutation"), false, name);
|
||||
assert.notDeepEqual(firstCopy, secondCopy, name);
|
||||
|
||||
const exposedBytes = document.canonicalBytes;
|
||||
exposedBytes[0] ^= 0xff;
|
||||
assert.deepEqual(document.canonicalBytes, expectedBytes, name);
|
||||
assert.equal(document.canonicalHash, expectedHash, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("document constructors reject invalid values, noncanonical input, and stale revisions", () => {
|
||||
for (const [name, factory] of documents) {
|
||||
assert.throws(
|
||||
() => factory.fromValue({}),
|
||||
ContractDocumentValidationError,
|
||||
name,
|
||||
);
|
||||
}
|
||||
assert.throws(() => SceneDocument.parse('{ "schema_version": "1.0" }'), /canonical/);
|
||||
|
||||
const scene = record(
|
||||
parseCanonicalJson(Buffer.from(corpus.artifacts.scene.base64, "base64")),
|
||||
);
|
||||
scene.revision = `sha256:${"0".repeat(64)}`;
|
||||
assert.throws(
|
||||
() => SceneDocument.fromValue(scene as never),
|
||||
(error: unknown) =>
|
||||
error instanceof ContractDocumentValidationError &&
|
||||
error.report.firstError?.code === "revision_mismatch",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
canonicalJsonBytes,
|
||||
computeSceneRevision,
|
||||
parseStrictJson,
|
||||
sha256,
|
||||
} from "../src/canonical.js";
|
||||
import { report } from "../src/report.js";
|
||||
import {
|
||||
computePackageBudgetTotals,
|
||||
packageBudgetIssues,
|
||||
validateSceneManifest,
|
||||
validateScenePackage,
|
||||
} from "../src/validation.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface Artifact {
|
||||
base64: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
interface Corpus {
|
||||
artifacts: Record<string, Artifact>;
|
||||
presentation_cases: { valid: boolean; payload_base64: string }[];
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpusPath = resolve(here, "../../../test/fixtures/scene-contract/corpus.json");
|
||||
const corpus = parseStrictJson(readFileSync(corpusPath)) as Corpus;
|
||||
const decode = (value: string): Buffer => Buffer.from(value, "base64");
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asRecords(value: unknown): JsonRecord[] {
|
||||
assert.ok(Array.isArray(value));
|
||||
return value.map(asRecord);
|
||||
}
|
||||
|
||||
function validScene(): JsonRecord {
|
||||
return asRecord(parseStrictJson(decode(corpus.artifacts.scene.base64)));
|
||||
}
|
||||
|
||||
function artifactBlobs(): Map<string, Buffer> {
|
||||
const result = new Map<string, Buffer>();
|
||||
for (const artifact of Object.values(corpus.artifacts)) {
|
||||
if (artifact.uri !== undefined) result.set(artifact.uri, decode(artifact.base64));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validPresentation(): JsonRecord {
|
||||
const value = corpus.presentation_cases.find((item) => item.valid);
|
||||
assert.ok(value !== undefined);
|
||||
return asRecord(parseStrictJson(decode(value.payload_base64)));
|
||||
}
|
||||
|
||||
function utf8Compare(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
||||
}
|
||||
|
||||
function attachPresentation(scene: JsonRecord, blobs: Map<string, Buffer>): { presentation: JsonRecord; blobs: Map<string, Buffer> } {
|
||||
const presentation = validPresentation();
|
||||
const presentationId = presentation.presentation_id as string;
|
||||
const expectedAppearanceIds = new Map<unknown, string>();
|
||||
const sceneAppearances = asRecords(scene.appearances);
|
||||
for (const authored of asRecords(presentation.appearances)) {
|
||||
const evaluated: JsonRecord = {
|
||||
alpha_mode: authored.alpha_mode,
|
||||
base_color: authored.base_color,
|
||||
double_sided: authored.double_sided,
|
||||
edge_color: authored.edge_color,
|
||||
metallic: authored.metallic,
|
||||
name: authored.name,
|
||||
roughness: authored.roughness,
|
||||
sdk_metadata: {},
|
||||
source: {
|
||||
appearance_name: authored.name,
|
||||
kind: "presentation",
|
||||
presentation_id: presentationId,
|
||||
},
|
||||
};
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(evaluated)).slice("sha256:".length)}`;
|
||||
expectedAppearanceIds.set(authored.name, appearanceId);
|
||||
sceneAppearances.push({ appearance_id: appearanceId, ...evaluated });
|
||||
}
|
||||
sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string));
|
||||
scene.appearances = sceneAppearances;
|
||||
|
||||
const nodeMap = new Map(asRecords(scene.nodes).map((node) => [node.node_id, node]));
|
||||
for (const override of asRecords(presentation.node_overrides)) {
|
||||
const node = nodeMap.get(override.node_id);
|
||||
assert.ok(node !== undefined);
|
||||
if (override.visible !== undefined) node.visible = override.visible;
|
||||
if (override.appearance_name !== undefined) node.appearance_override_id = expectedAppearanceIds.get(override.appearance_name);
|
||||
}
|
||||
scene.cameras = asRecords(presentation.cameras).map((camera) => ({
|
||||
camera_id: `camera/${presentationId}/${String(camera.name)}`,
|
||||
...camera,
|
||||
}));
|
||||
|
||||
const presentationBytes = canonicalJsonBytes(presentation);
|
||||
asRecord(scene.compile_options).embed_presentation = true;
|
||||
scene.presentation_source = {
|
||||
artifact_hash: sha256(presentationBytes),
|
||||
embedded_artifact_byte_length: presentationBytes.length,
|
||||
embedded_artifact_uri: "presentation/presentation.json",
|
||||
presentation_id: presentationId,
|
||||
schema_version: "1.0",
|
||||
};
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const resultBlobs = new Map(blobs);
|
||||
resultBlobs.set("presentation/presentation.json", presentationBytes);
|
||||
return { presentation, blobs: resultBlobs };
|
||||
}
|
||||
|
||||
function replacePresentation(scene: JsonRecord, blobs: Map<string, Buffer>, payload: Buffer): Map<string, Buffer> {
|
||||
const source = asRecord(scene.presentation_source);
|
||||
source.artifact_hash = sha256(payload);
|
||||
source.embedded_artifact_byte_length = payload.length;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const result = new Map(blobs);
|
||||
result.set("presentation/presentation.json", payload);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("package budget formula and first error match Python with declarative limits", () => {
|
||||
const totals = computePackageBudgetTotals({
|
||||
sceneJsonBytes: 7,
|
||||
glbDecodedBufferBytes: 11,
|
||||
entityJsonBytes: 13,
|
||||
otherImmutableJsonBytes: 17,
|
||||
entityCount: 23,
|
||||
entityVertexCount: 19,
|
||||
triangleVertexCount: 29,
|
||||
triangleCount: 31,
|
||||
lineVertexCount: 37,
|
||||
lineSegmentCount: 41,
|
||||
});
|
||||
assert.deepEqual(totals, {
|
||||
staticDecodedBufferBytes: 656,
|
||||
entityCount: 23,
|
||||
triangleVertexTotal: 29,
|
||||
triangleTotal: 31,
|
||||
lineVertexTotal: 37,
|
||||
lineSegmentTotal: 41,
|
||||
});
|
||||
assert.deepEqual(packageBudgetIssues(totals, {
|
||||
static_decoded_buffer_bytes: 656,
|
||||
entities_total: 23,
|
||||
triangle_vertices_total: 29,
|
||||
triangles_total: 31,
|
||||
line_vertices_total: 37,
|
||||
line_segments_total: 41,
|
||||
}), []);
|
||||
|
||||
const result = report(packageBudgetIssues(totals, {
|
||||
static_decoded_buffer_bytes: 655,
|
||||
entities_total: 22,
|
||||
triangle_vertices_total: 28,
|
||||
triangles_total: 30,
|
||||
line_vertices_total: 36,
|
||||
line_segments_total: 40,
|
||||
}), "package");
|
||||
assert.deepEqual(
|
||||
result.firstError === null
|
||||
? null
|
||||
: [result.firstError.code, result.firstError.path, result.firstError.message],
|
||||
["resource_limit_exceeded", "", "total line GLB vertex count exceeds resource limit"],
|
||||
);
|
||||
});
|
||||
|
||||
test("embedded presentation must be canonical and resolve exactly against the scene", () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
const validResult = validateScenePackage(canonicalJsonBytes(scene), attached.blobs);
|
||||
assert.equal(validResult.valid, true, JSON.stringify(validResult.issues));
|
||||
|
||||
const noncanonical = Buffer.from(JSON.stringify(attached.presentation, null, 2));
|
||||
const blobs = replacePresentation(scene, attached.blobs, noncanonical);
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.deepEqual(
|
||||
result.firstError === null ? null : [result.firstError.code, result.firstError.path],
|
||||
["noncanonical_json", ""],
|
||||
);
|
||||
});
|
||||
|
||||
test("embedded presentation IDs and scene references are contextual", async (context) => {
|
||||
const cases: [string, (presentation: JsonRecord) => void, string, string][] = [
|
||||
["presentation ID", (presentation) => { presentation.presentation_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/presentation_id"],
|
||||
["source scene ID", (presentation) => { presentation.source_scene_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/source_scene_id"],
|
||||
["node override", (presentation) => { asRecords(presentation.node_overrides)[0].node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/node_overrides/0/node_id"],
|
||||
["camera parent", (presentation) => { asRecords(presentation.cameras)[0].parent_node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/cameras/0/parent_node_id"],
|
||||
];
|
||||
for (const [name, mutate, code, path] of cases) await context.test(name, () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
mutate(attached.presentation);
|
||||
const blobs = replacePresentation(scene, attached.blobs, canonicalJsonBytes(attached.presentation));
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.ok(result.issues.some((item) => item.code === code && item.path === path));
|
||||
});
|
||||
});
|
||||
|
||||
test("presentation appearance provenance and camera resolution are exact", () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
const sceneAppearances = asRecords(scene.appearances);
|
||||
const appearance = sceneAppearances.find((item) => item.source !== null && asRecord(item.source).kind === "presentation");
|
||||
assert.ok(appearance !== undefined);
|
||||
asRecord(appearance.source).appearance_name = "Other";
|
||||
const draft = { ...appearance };
|
||||
delete draft.appearance_id;
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`;
|
||||
appearance.appearance_id = appearanceId;
|
||||
asRecords(scene.nodes)[0].appearance_override_id = appearanceId;
|
||||
sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string));
|
||||
scene.appearances = sceneAppearances;
|
||||
scene.cameras = [];
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), attached.blobs);
|
||||
const appearanceIndex = asRecords(scene.appearances).indexOf(appearance);
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === `/appearances/${appearanceIndex}/source/appearance_name`));
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/presentation/presentation.json/cameras/0"));
|
||||
});
|
||||
|
||||
test("product material provenance must match a Part definition root", () => {
|
||||
const scene = validScene();
|
||||
const appearance = asRecords(scene.appearances)[0];
|
||||
appearance.source = { kind: "product_material", material_id: "steel", root_id: "other" };
|
||||
const draft = { ...appearance };
|
||||
delete draft.appearance_id;
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`;
|
||||
appearance.appearance_id = appearanceId;
|
||||
asRecords(scene.definitions)[0].appearance_id = appearanceId;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
|
||||
const result = validateSceneManifest(scene);
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/appearances/0/source/root_id"));
|
||||
});
|
||||
|
||||
test("one URI cannot declare contradictory hash, length, or media roles", () => {
|
||||
const scene = validScene();
|
||||
const edge = asRecords(scene.edge_assets)[0];
|
||||
const oldUri = edge.uri as string;
|
||||
edge.uri = asRecords(scene.geometry_assets)[0].uri;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const blobs = artifactBlobs();
|
||||
blobs.delete(oldUri);
|
||||
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.ok(result.issues.some((item) => item.code === "package_member_set_invalid"));
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/edge_assets/0/uri"));
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { issue, parseStrictJson, report, validateRuleRegistry } from "../src/index.js";
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts",
|
||||
);
|
||||
|
||||
function registry(): Record<string, unknown> {
|
||||
return structuredClone(
|
||||
parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function rules(value: Record<string, unknown>): Record<string, unknown>[] {
|
||||
assert.ok(Array.isArray(value.rules));
|
||||
return value.rules as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
test("rule registry rejects malformed shape, duplicates, and wrong ordering", () => {
|
||||
const valid = registry();
|
||||
assert.equal(validateRuleRegistry(valid).size, rules(valid).length);
|
||||
|
||||
const missingPrecedence = registry();
|
||||
delete rules(missingPrecedence)[0].precedence;
|
||||
assert.throws(() => validateRuleRegistry(missingPrecedence), /precedence/);
|
||||
|
||||
const duplicateId = registry();
|
||||
rules(duplicateId)[1].id = rules(duplicateId)[0].id;
|
||||
assert.throws(() => validateRuleRegistry(duplicateId), /duplicate rule ID/);
|
||||
|
||||
const duplicatePrecedence = registry();
|
||||
rules(duplicatePrecedence)[1].precedence = 0;
|
||||
assert.throws(() => validateRuleRegistry(duplicatePrecedence), /duplicate precedence/);
|
||||
|
||||
const wrongOrder = registry();
|
||||
[rules(wrongOrder)[0], rules(wrongOrder)[1]] = [rules(wrongOrder)[1], rules(wrongOrder)[0]];
|
||||
assert.throws(() => validateRuleRegistry(wrongOrder), /ordered by phase and precedence/);
|
||||
|
||||
const wrongIdOrder = registry();
|
||||
[rules(wrongIdOrder)[0].id, rules(wrongIdOrder)[1].id] = [rules(wrongIdOrder)[1].id, rules(wrongIdOrder)[0].id];
|
||||
assert.throws(() => validateRuleRegistry(wrongIdOrder), /unsigned UTF-8 rule-ID order/);
|
||||
});
|
||||
|
||||
test("registered precedence orders artifact reports", () => {
|
||||
const result = report([
|
||||
issue("transform_invalid", "/z", "later registered rule"),
|
||||
issue("bounds_invalid", "/z", "z message"),
|
||||
issue("bounds_invalid", "/z", "a message"),
|
||||
issue("bounds_invalid", "/a", "earlier pointer"),
|
||||
], "scene");
|
||||
assert.deepEqual(result.issues.map(({ code, path, message }) => [code, path, message]), [
|
||||
["bounds_invalid", "/a", "earlier pointer"],
|
||||
["bounds_invalid", "/z", "a message"],
|
||||
["bounds_invalid", "/z", "z message"],
|
||||
["transform_invalid", "/z", "later registered rule"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("artifact reports reject unregistered, wrong-phase, and wrong-artifact issues", () => {
|
||||
assert.throws(
|
||||
() => report([issue("not_registered", "", "no authority")], "scene"),
|
||||
/unregistered/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("bounds_invalid", "", "wrong phase", "package")], "scene"),
|
||||
/registered phase/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("presentation_reference_invalid", "", "wrong artifact")], "scene"),
|
||||
/does not apply/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("duplicate_json_key", "/value", "wrong pointer", "parse")], "scene"),
|
||||
/root pointer/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { BASE_LIMITS, preflightZipBytes } from "../src/index.js";
|
||||
|
||||
function declaredDeflateZip(
|
||||
compressed: Uint8Array,
|
||||
uncompressedSize: number,
|
||||
localUncompressedSize = uncompressedSize,
|
||||
): Buffer {
|
||||
const name = Buffer.from("scene.json", "ascii");
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(0, 14);
|
||||
local.writeUInt32LE(compressed.byteLength, 18);
|
||||
local.writeUInt32LE(localUncompressedSize, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
|
||||
const centralOffset = local.length + name.length + compressed.byteLength;
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0x0021, 14);
|
||||
central.writeUInt32LE(0, 16);
|
||||
central.writeUInt32LE(compressed.byteLength, 20);
|
||||
central.writeUInt32LE(uncompressedSize, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0x81a40000, 38);
|
||||
central.writeUInt32LE(0, 42);
|
||||
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(1, 8);
|
||||
eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(central.length + name.length, 12);
|
||||
eocd.writeUInt32LE(centralOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([local, name, compressed, central, name, eocd]);
|
||||
}
|
||||
|
||||
function assertError(archive: Uint8Array, expected: string): void {
|
||||
assert.throws(() => preflightZipBytes(archive), (error) => {
|
||||
assert.equal((error as Error).message, expected);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("aggregate ratio uses cross-checked declarations before inflate", () => {
|
||||
const compressed = Buffer.from([0]);
|
||||
const template = declaredDeflateZip(compressed, 0);
|
||||
const exactSize = template.length * BASE_LIMITS.compression_ratio;
|
||||
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize),
|
||||
'member compression ratio exceeds limit: "scene.json"',
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize + 1),
|
||||
"aggregate compression ratio exceeds limit",
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize + 1, exactSize),
|
||||
"ZIP central/local header mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
test("zero compressed size uses one as the member ratio denominator", () => {
|
||||
assertError(
|
||||
declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio),
|
||||
'invalid deflate stream: "scene.json"',
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio + 1),
|
||||
'member compression ratio exceeds limit: "scene.json"',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user