feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
+2
View File
@@ -0,0 +1,2 @@
dist/
node_modules/
+304
View File
@@ -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"
}
}
}
+24
View File
@@ -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;
}
+348
View File
@@ -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],
};
}
+7
View File
@@ -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";
+154
View File
@@ -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");
}
+103
View File
@@ -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
+278
View File
@@ -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"',
);
});
+16
View File
@@ -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"]
}