feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export class DuplicateKeyError extends SyntaxError {}
|
||||
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const maxSafeInteger = 9_007_199_254_740_991;
|
||||
const parserDepthCapacity = 256;
|
||||
|
||||
function assertUnicodeScalarString(value: string): void {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const unit = value.charCodeAt(index);
|
||||
if (unit >= 0xd800 && unit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) {
|
||||
throw new TypeError("string contains an unpaired surrogate");
|
||||
}
|
||||
index += 1;
|
||||
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
||||
throw new TypeError("string contains an unpaired surrogate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StrictJsonParser {
|
||||
private offset = 0;
|
||||
|
||||
public constructor(private readonly text: string) {}
|
||||
|
||||
public parse(): unknown {
|
||||
this.space();
|
||||
const parsed = this.value(0);
|
||||
this.space();
|
||||
if (this.offset !== this.text.length) {
|
||||
throw new SyntaxError("trailing data after JSON value");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private value(depth: number): unknown {
|
||||
if (depth > parserDepthCapacity) {
|
||||
throw new SyntaxError("JSON nesting exceeds parser capacity");
|
||||
}
|
||||
this.space();
|
||||
const token = this.text[this.offset];
|
||||
if (
|
||||
this.text.startsWith("NaN", this.offset) ||
|
||||
this.text.startsWith("Infinity", this.offset) ||
|
||||
this.text.startsWith("-Infinity", this.offset)
|
||||
) {
|
||||
throw new SyntaxError("non-finite JSON number is forbidden");
|
||||
}
|
||||
if (token === "{") return this.object(depth + 1);
|
||||
if (token === "[") return this.array(depth + 1);
|
||||
if (token === '"') return this.string();
|
||||
if (token === "t" && this.consume("true")) return true;
|
||||
if (token === "f" && this.consume("false")) return false;
|
||||
if (token === "n" && this.consume("null")) return null;
|
||||
if (token === "-" || (token !== undefined && token >= "0" && token <= "9")) {
|
||||
return this.number();
|
||||
}
|
||||
throw new SyntaxError(`invalid JSON token at offset ${this.offset}`);
|
||||
}
|
||||
|
||||
private object(depth: number): Record<string, unknown> {
|
||||
this.offset += 1;
|
||||
this.space();
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
||||
const keys = new Set<string>();
|
||||
if (this.text[this.offset] === "}") {
|
||||
this.offset += 1;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
if (this.text[this.offset] !== '"') {
|
||||
throw new SyntaxError(`JSON object key expected at offset ${this.offset}`);
|
||||
}
|
||||
const key = this.string();
|
||||
if (keys.has(key)) {
|
||||
throw new DuplicateKeyError(`duplicate JSON object member: ${JSON.stringify(key)}`);
|
||||
}
|
||||
keys.add(key);
|
||||
this.space();
|
||||
if (this.text[this.offset] !== ":") {
|
||||
throw new SyntaxError(`JSON object colon expected at offset ${this.offset}`);
|
||||
}
|
||||
this.offset += 1;
|
||||
const child = this.value(depth);
|
||||
result[key] = child;
|
||||
this.space();
|
||||
const separator = this.text[this.offset];
|
||||
this.offset += 1;
|
||||
if (separator === "}") return result;
|
||||
if (separator !== ",") {
|
||||
throw new SyntaxError(`JSON object separator expected at offset ${this.offset - 1}`);
|
||||
}
|
||||
this.space();
|
||||
}
|
||||
}
|
||||
|
||||
private array(depth: number): unknown[] {
|
||||
this.offset += 1;
|
||||
this.space();
|
||||
const result: unknown[] = [];
|
||||
if (this.text[this.offset] === "]") {
|
||||
this.offset += 1;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
const child = this.value(depth);
|
||||
result.push(child);
|
||||
this.space();
|
||||
const separator = this.text[this.offset];
|
||||
this.offset += 1;
|
||||
if (separator === "]") return result;
|
||||
if (separator !== ",") {
|
||||
throw new SyntaxError(`JSON array separator expected at offset ${this.offset - 1}`);
|
||||
}
|
||||
this.space();
|
||||
}
|
||||
}
|
||||
|
||||
private string(): string {
|
||||
const start = this.offset;
|
||||
this.offset += 1;
|
||||
while (this.offset < this.text.length) {
|
||||
const unit = this.text.charCodeAt(this.offset);
|
||||
if (unit < 0x20) {
|
||||
throw new SyntaxError(`unescaped control character at offset ${this.offset}`);
|
||||
}
|
||||
if (unit === 0x22) {
|
||||
this.offset += 1;
|
||||
const result = JSON.parse(this.text.slice(start, this.offset)) as string;
|
||||
assertUnicodeScalarString(result);
|
||||
return result;
|
||||
}
|
||||
if (unit === 0x5c) {
|
||||
this.offset += 1;
|
||||
const escape = this.text[this.offset];
|
||||
if (escape === "u") {
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(this.text.slice(this.offset + 1, this.offset + 5))) {
|
||||
throw new SyntaxError(`invalid JSON Unicode escape at offset ${this.offset}`);
|
||||
}
|
||||
this.offset += 5;
|
||||
continue;
|
||||
}
|
||||
if (escape === undefined || !'"\\/bfnrt'.includes(escape)) {
|
||||
throw new SyntaxError(`invalid JSON escape at offset ${this.offset}`);
|
||||
}
|
||||
}
|
||||
this.offset += 1;
|
||||
}
|
||||
throw new SyntaxError("unterminated JSON string");
|
||||
}
|
||||
|
||||
private number(): number {
|
||||
const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(
|
||||
this.text.slice(this.offset),
|
||||
);
|
||||
if (match === null) throw new SyntaxError(`invalid JSON number at offset ${this.offset}`);
|
||||
const token = match[0];
|
||||
this.offset += token.length;
|
||||
const result = Number(token);
|
||||
if (!/[.eE]/.test(token) && Math.abs(result) > maxSafeInteger) {
|
||||
throw new SyntaxError("JSON integer exceeds the JCS safe domain");
|
||||
}
|
||||
if (!Number.isFinite(result)) throw new SyntaxError("non-finite JSON number is forbidden");
|
||||
return result;
|
||||
}
|
||||
|
||||
private consume(token: string): boolean {
|
||||
if (!this.text.startsWith(token, this.offset)) return false;
|
||||
this.offset += token.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
private space(): void {
|
||||
while (
|
||||
this.text[this.offset] === " " ||
|
||||
this.text[this.offset] === "\n" ||
|
||||
this.text[this.offset] === "\r" ||
|
||||
this.text[this.offset] === "\t"
|
||||
) {
|
||||
this.offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStrictJson(data: Uint8Array | string): unknown {
|
||||
let text: string;
|
||||
if (typeof data === "string") {
|
||||
text = data;
|
||||
} else {
|
||||
if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
|
||||
throw new SyntaxError("UTF-8 BOM is forbidden");
|
||||
}
|
||||
text = decoder.decode(data);
|
||||
}
|
||||
if (text.startsWith("\ufeff")) throw new SyntaxError("UTF-8 BOM is forbidden");
|
||||
return new StrictJsonParser(text).parse();
|
||||
}
|
||||
|
||||
function serialize(value: unknown, stack: Set<object>): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("JSON number must be finite");
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
assertUnicodeScalarString(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value !== "object") throw new TypeError(`unsupported JSON value: ${typeof value}`);
|
||||
if (stack.has(value)) throw new TypeError("cyclic value is not JSON");
|
||||
stack.add(value);
|
||||
let result: string;
|
||||
if (Array.isArray(value)) {
|
||||
const items: string[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.hasOwn(value, index)) throw new TypeError("sparse arrays are outside the JSON domain");
|
||||
items.push(serialize(value[index], stack));
|
||||
}
|
||||
result = `[${items.join(",")}]`;
|
||||
} else {
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
for (const key of keys) assertUnicodeScalarString(key);
|
||||
result = `{${keys
|
||||
.map((key) => `${JSON.stringify(key)}:${serialize(record[key], stack)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
stack.delete(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalJsonBytes(value: unknown): Buffer {
|
||||
return Buffer.from(serialize(value, new Set()), "utf8");
|
||||
}
|
||||
|
||||
export function parseCanonicalJson(data: Uint8Array | string): unknown {
|
||||
const raw = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
||||
const parsed = parseStrictJson(raw);
|
||||
if (!raw.equals(canonicalJsonBytes(parsed))) {
|
||||
throw new SyntaxError("JSON bytes are not RFC 8785 canonical");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function sha256(data: Uint8Array): string {
|
||||
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function canonicalJsonHash(value: unknown): string {
|
||||
return sha256(canonicalJsonBytes(value));
|
||||
}
|
||||
|
||||
export function computeSceneRevision(scene: Record<string, unknown>): string {
|
||||
const { revision: _revision, ...draft } = scene;
|
||||
return canonicalJsonHash(draft);
|
||||
}
|
||||
|
||||
export function withSceneRevision(scene: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = structuredClone(scene);
|
||||
result.revision = computeSceneRevision(result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
ConnectorBindingDocument as ConnectorBindingValue,
|
||||
EntityDocument as EntityValue,
|
||||
NormalizedProductDocument as NormalizedProductValue,
|
||||
PresentationDocument as PresentationValue,
|
||||
SceneDocument as SceneValue,
|
||||
} from "./generated/index.js";
|
||||
import { canonicalJsonBytes, parseCanonicalJson, sha256 } from "./canonical.js";
|
||||
import type { ValidationReport } from "./report.js";
|
||||
import {
|
||||
validateConnectorBinding,
|
||||
validateEntityAsset,
|
||||
validateNormalizedProduct,
|
||||
validatePresentation,
|
||||
validateSceneManifest,
|
||||
} from "./validation.js";
|
||||
|
||||
export type SceneDocument = SceneValue;
|
||||
export type EntityDocument = EntityValue;
|
||||
export type PresentationDocument = PresentationValue;
|
||||
export type ConnectorBindingDocument = ConnectorBindingValue;
|
||||
export type NormalizedProductDocument = NormalizedProductValue;
|
||||
|
||||
export type DeepReadonly<T> =
|
||||
T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
||||
export interface ImmutableContractDocument<Value> {
|
||||
readonly value: DeepReadonly<Value>;
|
||||
readonly canonicalBytes: Buffer;
|
||||
readonly canonicalHash: string;
|
||||
toMutable(): Value;
|
||||
}
|
||||
|
||||
export interface ImmutableSceneDocument
|
||||
extends ImmutableContractDocument<SceneValue> {
|
||||
readonly revision: string;
|
||||
}
|
||||
|
||||
export type ImmutableEntityDocument = ImmutableContractDocument<EntityValue>;
|
||||
export type ImmutablePresentationDocument =
|
||||
ImmutableContractDocument<PresentationValue>;
|
||||
export type ImmutableConnectorBindingDocument =
|
||||
ImmutableContractDocument<ConnectorBindingValue>;
|
||||
export type ImmutableNormalizedProductDocument =
|
||||
ImmutableContractDocument<NormalizedProductValue>;
|
||||
|
||||
export interface ContractDocumentFactory<Value, Document> {
|
||||
fromValue(value: Value | DeepReadonly<Value>): Document;
|
||||
parse(data: Uint8Array | string): Document;
|
||||
}
|
||||
|
||||
export class ContractDocumentValidationError extends TypeError {
|
||||
public constructor(public readonly report: ValidationReport) {
|
||||
const first = report.firstError;
|
||||
super(
|
||||
first === null
|
||||
? "Scene contract validation failed"
|
||||
: `Scene contract validation failed: ${first.code} at ${first.path}: ${first.message}`,
|
||||
);
|
||||
this.name = "ContractDocumentValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
type Validator = (value: unknown) => ValidationReport;
|
||||
|
||||
function deepFreeze(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) deepFreeze(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
class FrozenContractDocument<Value>
|
||||
implements ImmutableContractDocument<Value>
|
||||
{
|
||||
readonly #value: DeepReadonly<Value>;
|
||||
readonly #canonicalBytes: Buffer;
|
||||
readonly #canonicalHash: string;
|
||||
|
||||
public constructor(value: Value | DeepReadonly<Value>, validate: Validator) {
|
||||
const canonical = canonicalJsonBytes(value);
|
||||
const detached = parseCanonicalJson(canonical);
|
||||
const validation = validate(detached);
|
||||
if (!validation.valid) throw new ContractDocumentValidationError(validation);
|
||||
this.#value = deepFreeze(detached) as DeepReadonly<Value>;
|
||||
this.#canonicalBytes = Buffer.from(canonical);
|
||||
this.#canonicalHash = sha256(canonical);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
public get value(): DeepReadonly<Value> {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
public get canonicalBytes(): Buffer {
|
||||
return Buffer.from(this.#canonicalBytes);
|
||||
}
|
||||
|
||||
public get canonicalHash(): string {
|
||||
return this.#canonicalHash;
|
||||
}
|
||||
|
||||
public toMutable(): Value {
|
||||
return parseCanonicalJson(this.#canonicalBytes) as Value;
|
||||
}
|
||||
}
|
||||
|
||||
class FrozenSceneDocument
|
||||
extends FrozenContractDocument<SceneValue>
|
||||
implements ImmutableSceneDocument
|
||||
{
|
||||
public get revision(): string {
|
||||
return this.value.revision;
|
||||
}
|
||||
}
|
||||
|
||||
function documentFactory<Value, Document>(
|
||||
create: (value: Value | DeepReadonly<Value>) => Document,
|
||||
): ContractDocumentFactory<Value, Document> {
|
||||
return Object.freeze({
|
||||
fromValue: create,
|
||||
parse: (data: Uint8Array | string) => create(parseCanonicalJson(data) as Value),
|
||||
});
|
||||
}
|
||||
|
||||
export const SceneDocument = documentFactory<SceneValue, ImmutableSceneDocument>(
|
||||
(value) => new FrozenSceneDocument(value, validateSceneManifest),
|
||||
);
|
||||
|
||||
export const EntityDocument = documentFactory<EntityValue, ImmutableEntityDocument>(
|
||||
(value) => new FrozenContractDocument<EntityValue>(value, validateEntityAsset),
|
||||
);
|
||||
|
||||
export const PresentationDocument = documentFactory<
|
||||
PresentationValue,
|
||||
ImmutablePresentationDocument
|
||||
>((value) => new FrozenContractDocument<PresentationValue>(value, validatePresentation));
|
||||
|
||||
export const ConnectorBindingDocument = documentFactory<
|
||||
ConnectorBindingValue,
|
||||
ImmutableConnectorBindingDocument
|
||||
>((value) => new FrozenContractDocument<ConnectorBindingValue>(value, validateConnectorBinding));
|
||||
|
||||
export const NormalizedProductDocument = documentFactory<
|
||||
NormalizedProductValue,
|
||||
ImmutableNormalizedProductDocument
|
||||
>((value) => new FrozenContractDocument<NormalizedProductValue>(value, validateNormalizedProduct));
|
||||
@@ -0,0 +1,94 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
|
||||
export interface ConnectorBindingDocument {
|
||||
schema_version: "1.0";
|
||||
binding_id: LogicalId;
|
||||
source_model: SourceModel;
|
||||
source_scene: SourceScene;
|
||||
owner_definition_id: StructuralId;
|
||||
selected_occurrence_node_id: StructuralId;
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
target: Target;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceModel".
|
||||
*/
|
||||
export interface SourceModel {
|
||||
graph_id: ProductId;
|
||||
model_schema_version: "2.0";
|
||||
artifact_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceScene".
|
||||
*/
|
||||
export interface SourceScene {
|
||||
scene_id: LogicalId;
|
||||
revision: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "target".
|
||||
*/
|
||||
export interface Target {
|
||||
kind: "topology_entity";
|
||||
entity_asset_id: Hash;
|
||||
entity_id: string;
|
||||
expected_source: ModelOutput | ModelTopology;
|
||||
flip: boolean;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutput".
|
||||
*/
|
||||
export interface ModelOutput {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema
|
||||
* via the `definition` "modelTopology".
|
||||
*/
|
||||
export interface ModelTopology {
|
||||
kind: "model_topology";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
topology_kind: "FACE" | "EDGE" | "VERTEX";
|
||||
topo_id: string;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "entityId".
|
||||
*/
|
||||
export type EntityId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "source".
|
||||
*/
|
||||
export type Source = ModelOutput | ModelTopology | ImportedPrimitive | Unbound;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "geometry".
|
||||
*/
|
||||
export type Geometry =
|
||||
| BrepSolid
|
||||
| Point
|
||||
| Line
|
||||
| Circle
|
||||
| Ellipse
|
||||
| BsplineCurve
|
||||
| OtherCurve
|
||||
| Plane
|
||||
| Cylinder
|
||||
| Cone
|
||||
| Sphere
|
||||
| Torus
|
||||
| BsplineSurface
|
||||
| OtherSurface;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "positiveInteger".
|
||||
*/
|
||||
export type PositiveInteger = number;
|
||||
|
||||
export interface EntityDocument {
|
||||
schema_version: "1.0";
|
||||
definition_id: StructuralId;
|
||||
geometry_asset_id: Hash;
|
||||
edge_asset_id: Hash;
|
||||
geometry_engine: GeometryEngine;
|
||||
/**
|
||||
* @minItems 4
|
||||
*/
|
||||
entities: [Entity, Entity, Entity, Entity, ...Entity[]];
|
||||
/**
|
||||
* @minItems 1
|
||||
*/
|
||||
face_groups: [Group, ...Group[]];
|
||||
edge_groups: Group[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "geometryEngine".
|
||||
*/
|
||||
export interface GeometryEngine {
|
||||
name: "OpenCascade";
|
||||
version: string;
|
||||
profile: "ocp-evaluated-properties-1";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "entity".
|
||||
*/
|
||||
export interface Entity {
|
||||
entity_id: EntityId;
|
||||
kind: "solid" | "face" | "edge" | "vertex";
|
||||
parent_entity_ids: EntityId[];
|
||||
child_entity_ids: EntityId[];
|
||||
source: Source;
|
||||
geometry: Geometry;
|
||||
properties: SolidProperties | FaceProperties | EdgeProperties | VertexProperties;
|
||||
sdk_connector_frame: null | Transform;
|
||||
render_status: "rendered" | "degenerate";
|
||||
connector_binding_status:
|
||||
| "not_applicable"
|
||||
| "owner_not_part"
|
||||
| "source_not_model"
|
||||
| "frame_undefined"
|
||||
| "selector_ambiguous"
|
||||
| "selector_unstable"
|
||||
| "supported";
|
||||
semantic_binding_ids: string[];
|
||||
evaluated_tags: string[];
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutput".
|
||||
*/
|
||||
export interface ModelOutput {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "modelTopology".
|
||||
*/
|
||||
export interface ModelTopology {
|
||||
kind: "model_topology";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
topology_kind: "SOLID" | "FACE" | "EDGE" | "VERTEX";
|
||||
topo_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "importedPrimitive".
|
||||
*/
|
||||
export interface ImportedPrimitive {
|
||||
kind: "imported_primitive";
|
||||
source_element_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "unbound".
|
||||
*/
|
||||
export interface Unbound {
|
||||
kind: "unbound";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "brepSolid".
|
||||
*/
|
||||
export interface BrepSolid {
|
||||
type: "brep_solid";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "point".
|
||||
*/
|
||||
export interface Point {
|
||||
type: "point";
|
||||
position: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "line".
|
||||
*/
|
||||
export interface Line {
|
||||
type: "line";
|
||||
origin: Vec3;
|
||||
direction: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "circle".
|
||||
*/
|
||||
export interface Circle {
|
||||
type: "circle";
|
||||
center: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "ellipse".
|
||||
*/
|
||||
export interface Ellipse {
|
||||
type: "ellipse";
|
||||
center: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bsplineCurve".
|
||||
*/
|
||||
export interface BsplineCurve {
|
||||
type: "bspline_curve";
|
||||
degree: SafeInteger;
|
||||
rational: boolean;
|
||||
periodic: boolean;
|
||||
poles_count: SafeInteger;
|
||||
knots_count: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "otherCurve".
|
||||
*/
|
||||
export interface OtherCurve {
|
||||
type: "other_curve";
|
||||
engine_type: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "plane".
|
||||
*/
|
||||
export interface Plane {
|
||||
type: "plane";
|
||||
origin: Vec3;
|
||||
normal: Vec3;
|
||||
x_direction: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "cylinder".
|
||||
*/
|
||||
export interface Cylinder {
|
||||
type: "cylinder";
|
||||
origin: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "cone".
|
||||
*/
|
||||
export interface Cone {
|
||||
type: "cone";
|
||||
origin: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
reference_radius: number;
|
||||
semi_angle_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "sphere".
|
||||
*/
|
||||
export interface Sphere {
|
||||
type: "sphere";
|
||||
center: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "torus".
|
||||
*/
|
||||
export interface Torus {
|
||||
type: "torus";
|
||||
center: Vec3;
|
||||
axis: Vec3;
|
||||
x_direction: Vec3;
|
||||
major_radius: number;
|
||||
minor_radius: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bsplineSurface".
|
||||
*/
|
||||
export interface BsplineSurface {
|
||||
type: "bspline_surface";
|
||||
u_degree: SafeInteger;
|
||||
v_degree: SafeInteger;
|
||||
u_rational: boolean;
|
||||
v_rational: boolean;
|
||||
u_periodic: boolean;
|
||||
v_periodic: boolean;
|
||||
u_poles_count: SafeInteger;
|
||||
v_poles_count: SafeInteger;
|
||||
u_knots_count: SafeInteger;
|
||||
v_knots_count: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "otherSurface".
|
||||
*/
|
||||
export interface OtherSurface {
|
||||
type: "other_surface";
|
||||
engine_type: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "solidProperties".
|
||||
*/
|
||||
export interface SolidProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
volume: number;
|
||||
surface_area: number;
|
||||
centroid: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "bounds".
|
||||
*/
|
||||
export interface Bounds {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "faceProperties".
|
||||
*/
|
||||
export interface FaceProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
area: number;
|
||||
centroid: Vec3;
|
||||
orientation: "forward" | "reversed" | "internal" | "external";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "edgeProperties".
|
||||
*/
|
||||
export interface EdgeProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
length: number;
|
||||
centroid: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "vertexProperties".
|
||||
*/
|
||||
export interface VertexProperties {
|
||||
quality: "kernel_evaluated";
|
||||
bounds: Bounds;
|
||||
position: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `EntityDocument`'s JSON-Schema
|
||||
* via the `definition` "group".
|
||||
*/
|
||||
export interface Group {
|
||||
group_id: SafeInteger;
|
||||
entity_id: EntityId;
|
||||
mesh_index: 0;
|
||||
primitive_index: 0;
|
||||
first_index: SafeInteger;
|
||||
index_count: PositiveInteger;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Generated by tools/generate_scene_contract_types.py. Do not edit.
|
||||
|
||||
export type { SceneDocument } from "./scene.js";
|
||||
export type { EntityDocument } from "./entities.js";
|
||||
export type { PresentationDocument } from "./presentation.js";
|
||||
export type { ConnectorBindingDocument } from "./connector_binding.js";
|
||||
export type { NormalizedProductDocument } from "./normalized_product.js";
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export type NormalizedProductDocument = Part | Assembly;
|
||||
export type ProductId = string;
|
||||
export type BodySource = ModelBodySource | ManualBodySource;
|
||||
export type SafeInteger = number;
|
||||
export type Connector = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
anchor: GeometryAnchor | PlacementAnchor | ForwardedAnchor;
|
||||
geometry_ref?: null | GeometryRef;
|
||||
};
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
export type StructuralId = string;
|
||||
|
||||
export interface Part {
|
||||
kind: "part";
|
||||
part_id: ProductId;
|
||||
body_source: BodySource;
|
||||
name: string | null;
|
||||
material: null | Material;
|
||||
connectors: Connector[];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface ModelBodySource {
|
||||
kind: "model_output";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
export interface ManualBodySource {
|
||||
kind: "manual";
|
||||
source_id: ProductId;
|
||||
}
|
||||
export interface Material {
|
||||
material_id: ProductId;
|
||||
name: string | null;
|
||||
density: number | null;
|
||||
density_unit: string | null;
|
||||
color: null | [number, number, number];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface GeometryAnchor {
|
||||
anchor_kind: "geometry";
|
||||
geometry_ref: GeometryRef;
|
||||
}
|
||||
export interface GeometryRef {
|
||||
kind: "face" | "edge" | "vertex";
|
||||
source_node_id: string | null;
|
||||
geo_selector: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
flip: boolean;
|
||||
}
|
||||
export interface PlacementAnchor {
|
||||
anchor_kind: "placement";
|
||||
placement: Transform;
|
||||
}
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
export interface ForwardedAnchor {
|
||||
anchor_kind: "forwarded";
|
||||
source_component_id: ProductId;
|
||||
source_connector_id: ProductId;
|
||||
offset: null | Transform;
|
||||
}
|
||||
export interface Assembly {
|
||||
kind: "assembly";
|
||||
assembly_id: ProductId;
|
||||
name: string | null;
|
||||
components: Component[];
|
||||
connectors: Connector[];
|
||||
constraints: Constraint[];
|
||||
grounded_component_ids: ProductId[];
|
||||
metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
export interface Component {
|
||||
component_id: ProductId;
|
||||
name: string | null;
|
||||
definition_ref: StructuralId;
|
||||
local_placement: Transform;
|
||||
}
|
||||
export interface Constraint {
|
||||
constraint_id: ProductId;
|
||||
constraint_kind: "fixed" | "revolute" | "prismatic" | "gear" | "belt" | "rack_pinion";
|
||||
connector_a: ConnectorRef;
|
||||
connector_b: ConnectorRef;
|
||||
drive_distance: number | null;
|
||||
distance_limit: null | Limit;
|
||||
drive_angle_degrees: number | null;
|
||||
angle_limit: null | Limit;
|
||||
pitch_radius_a: number | null;
|
||||
pitch_radius_b: number | null;
|
||||
pulley_radius_a: number | null;
|
||||
pulley_radius_b: number | null;
|
||||
pitch_radius: number | null;
|
||||
phase_offset: number | null;
|
||||
name: string | null;
|
||||
}
|
||||
export interface ConnectorRef {
|
||||
component_id: ProductId;
|
||||
connector_id: ProductId;
|
||||
}
|
||||
export interface Limit {
|
||||
lower_value: number;
|
||||
upper_value: number;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* @minItems 4
|
||||
* @maxItems 4
|
||||
*
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "color".
|
||||
*/
|
||||
export type Color = [number, number, number, 1];
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "nodeOverride".
|
||||
*/
|
||||
export type NodeOverride = NodeOverride1 & {
|
||||
node_id: StructuralId;
|
||||
visible?: boolean;
|
||||
appearance_name?: string;
|
||||
};
|
||||
export type NodeOverride1 = {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "camera".
|
||||
*/
|
||||
export type Camera = Perspective | Orthographic;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [number, number, number];
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "positive".
|
||||
*/
|
||||
export type Positive = number;
|
||||
|
||||
export interface PresentationDocument {
|
||||
schema_version: "1.0";
|
||||
presentation_id: LogicalId;
|
||||
source_scene_id: LogicalId;
|
||||
appearances: Appearance[];
|
||||
node_overrides: NodeOverride[];
|
||||
cameras: Camera[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "appearance".
|
||||
*/
|
||||
export interface Appearance {
|
||||
name: string;
|
||||
base_color: Color;
|
||||
metallic: number;
|
||||
roughness: number;
|
||||
alpha_mode: "opaque";
|
||||
double_sided: boolean;
|
||||
edge_color: Color;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "perspective".
|
||||
*/
|
||||
export interface Perspective {
|
||||
name: string;
|
||||
projection: "perspective";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: Positive;
|
||||
far: Positive;
|
||||
vertical_fov_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "orthographic".
|
||||
*/
|
||||
export interface Orthographic {
|
||||
name: string;
|
||||
projection: "orthographic";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: Positive;
|
||||
far: Positive;
|
||||
vertical_span: Positive;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `PresentationDocument`'s JSON-Schema
|
||||
* via the `definition` "cameraBaseProperties".
|
||||
*/
|
||||
export interface CameraBaseProperties {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "logicalId".
|
||||
*/
|
||||
export type LogicalId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "hash".
|
||||
*/
|
||||
export type Hash = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "token".
|
||||
*/
|
||||
export type Token = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "sceneSource".
|
||||
*/
|
||||
export type SceneSource = ModelSource | ImportedSource | ManualSource;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productId".
|
||||
*/
|
||||
export type ProductId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "safeInteger".
|
||||
*/
|
||||
export type SafeInteger = number;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "uri".
|
||||
*/
|
||||
export type Uri = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "definition".
|
||||
*/
|
||||
export type Definition = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
definition_id: StructuralId;
|
||||
kind: "part" | "assembly" | "shape";
|
||||
name: string | null;
|
||||
source: DefinitionSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
geometry_asset_id?: Hash;
|
||||
edge_asset_id?: Hash;
|
||||
entity_asset_id?: Hash;
|
||||
appearance_id?: StructuralId;
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "structuralId".
|
||||
*/
|
||||
export type StructuralId = string;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "definitionSource".
|
||||
*/
|
||||
export type DefinitionSource =
|
||||
ProductModelSource | ProductManualSource | ModelOutputSource | ImportedDefinitionSource | ManualDefinitionSource;
|
||||
/**
|
||||
* @minItems 3
|
||||
* @maxItems 3
|
||||
*
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "vec3".
|
||||
*/
|
||||
export type Vec3 = [Number, Number, Number];
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "number".
|
||||
*/
|
||||
export type Number = number;
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "positiveNumber".
|
||||
*/
|
||||
export type PositiveNumber = number;
|
||||
/**
|
||||
* @minItems 4
|
||||
* @maxItems 4
|
||||
*
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "color".
|
||||
*/
|
||||
export type Color = [number, number, number, 1];
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "connector".
|
||||
*/
|
||||
export type Connector = {
|
||||
[k: string]: unknown;
|
||||
} & {
|
||||
connector_snapshot_id: StructuralId;
|
||||
owner_definition_id: StructuralId;
|
||||
connector_id: ProductId;
|
||||
name: string | null;
|
||||
anchor_kind: "geometry" | "placement" | "forwarded";
|
||||
local_transform: Transform;
|
||||
target?: ConnectorTarget;
|
||||
forwarded_from?: ForwardedFrom;
|
||||
source: null | ModelOperationSource | ManualConnectorSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "camera".
|
||||
*/
|
||||
export type Camera = PerspectiveCamera | OrthographicCamera;
|
||||
|
||||
export interface SceneDocument {
|
||||
schema_version: "1.0";
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
extensions_used: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
extensions_required: unknown[];
|
||||
extensions: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
scene_id: LogicalId;
|
||||
revision: Hash;
|
||||
generator: Generator;
|
||||
source: SceneSource;
|
||||
presentation_source?: PresentationSource;
|
||||
coordinate_system: CoordinateSystem;
|
||||
compile_options: CompileOptions;
|
||||
definitions: Definition[];
|
||||
nodes: Node[];
|
||||
geometry_assets: GeometryAsset[];
|
||||
edge_assets: EdgeAsset[];
|
||||
appearances: Appearance[];
|
||||
entity_assets: EntityAsset[];
|
||||
connectors: Connector[];
|
||||
cameras: Camera[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
lights: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
annotations: unknown[];
|
||||
/**
|
||||
* @maxItems 0
|
||||
*/
|
||||
diagnostics: unknown[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "generator".
|
||||
*/
|
||||
export interface Generator {
|
||||
name: "simplecadapi";
|
||||
simplecadapi_version: Token;
|
||||
ocp_version: Token;
|
||||
ocp_bindings_version: Token;
|
||||
python_abi: Token;
|
||||
platform_tag: Token;
|
||||
toolchain_hash: Hash;
|
||||
profile: "scene-1.0-ocp-glb-1" | "scene-1.0-ocp-glb-2";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelSource".
|
||||
*/
|
||||
export interface ModelSource {
|
||||
kind: "model";
|
||||
graph_id: ProductId;
|
||||
model_schema_version: "2.0";
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "model/model.json";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
/**
|
||||
* @maxItems 25000
|
||||
*/
|
||||
source_files?: SourceFile[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "sourceFile".
|
||||
*/
|
||||
export interface SourceFile {
|
||||
path: string;
|
||||
uri: Uri;
|
||||
media_type: "text/x-python; charset=utf-8";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "importedSource".
|
||||
*/
|
||||
export interface ImportedSource {
|
||||
kind: "imported";
|
||||
format: Token;
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "source/source.bin";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualSource".
|
||||
*/
|
||||
export interface ManualSource {
|
||||
kind: "manual";
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "presentationSource".
|
||||
*/
|
||||
export interface PresentationSource {
|
||||
presentation_id: LogicalId;
|
||||
schema_version: "1.0";
|
||||
artifact_hash: Hash;
|
||||
embedded_artifact_uri?: "presentation/presentation.json";
|
||||
embedded_artifact_byte_length?: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "coordinateSystem".
|
||||
*/
|
||||
export interface CoordinateSystem {
|
||||
length_unit: "mm";
|
||||
handedness: "right";
|
||||
up_axis: "+Z";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "compileOptions".
|
||||
*/
|
||||
export interface CompileOptions {
|
||||
linear_tolerance: number;
|
||||
angular_tolerance: number;
|
||||
embed_source: boolean;
|
||||
embed_presentation: boolean;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productModelSource".
|
||||
*/
|
||||
export interface ProductModelSource {
|
||||
kind: "product_model";
|
||||
root_id: LogicalId;
|
||||
semantic_type: "Part" | "Assembly";
|
||||
semantic_id: ProductId;
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productManualSource".
|
||||
*/
|
||||
export interface ProductManualSource {
|
||||
kind: "product_manual";
|
||||
root_id: LogicalId;
|
||||
semantic_type: "Part" | "Assembly";
|
||||
semantic_id: ProductId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOutputSource".
|
||||
*/
|
||||
export interface ModelOutputSource {
|
||||
kind: "model_output";
|
||||
root_id: LogicalId;
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "importedDefinitionSource".
|
||||
*/
|
||||
export interface ImportedDefinitionSource {
|
||||
kind: "imported";
|
||||
root_id: LogicalId;
|
||||
source_element_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualDefinitionSource".
|
||||
*/
|
||||
export interface ManualDefinitionSource {
|
||||
kind: "manual";
|
||||
root_id: LogicalId;
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "node".
|
||||
*/
|
||||
export interface Node {
|
||||
node_id: StructuralId;
|
||||
parent_node_id: StructuralId | null;
|
||||
order: SafeInteger;
|
||||
definition_id: StructuralId;
|
||||
name: string | null;
|
||||
transform: Transform;
|
||||
visible: boolean;
|
||||
selectable: boolean;
|
||||
appearance_override_id: StructuralId | null;
|
||||
source: ProductOccurrenceSource | ShapeRootSource;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "transform".
|
||||
*/
|
||||
export interface Transform {
|
||||
origin: Vec3;
|
||||
x_axis: Vec3;
|
||||
y_axis: Vec3;
|
||||
z_axis: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productOccurrenceSource".
|
||||
*/
|
||||
export interface ProductOccurrenceSource {
|
||||
kind: "product_occurrence";
|
||||
root_id: LogicalId;
|
||||
component_path: ProductId[];
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "shapeRootSource".
|
||||
*/
|
||||
export interface ShapeRootSource {
|
||||
kind: "shape_root";
|
||||
root_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "geometryAsset".
|
||||
*/
|
||||
export interface GeometryAsset {
|
||||
asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "model/gltf-binary";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
scene_local_bounds: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
tessellation: {
|
||||
linear_tolerance: PositiveNumber;
|
||||
angular_tolerance: PositiveNumber;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "bounds".
|
||||
*/
|
||||
export interface Bounds {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "edgeAsset".
|
||||
*/
|
||||
export interface EdgeAsset {
|
||||
asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "model/gltf-binary";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
scene_local_bounds: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
tessellation: {
|
||||
linear_tolerance: PositiveNumber;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "appearance".
|
||||
*/
|
||||
export interface Appearance {
|
||||
appearance_id: string;
|
||||
name: string | null;
|
||||
source: null | ProductMaterialSource | PresentationAppearanceSource;
|
||||
base_color: Color;
|
||||
metallic: number;
|
||||
roughness: number;
|
||||
alpha_mode: "opaque";
|
||||
double_sided: boolean;
|
||||
edge_color: Color;
|
||||
sdk_metadata: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "productMaterialSource".
|
||||
*/
|
||||
export interface ProductMaterialSource {
|
||||
kind: "product_material";
|
||||
root_id: LogicalId;
|
||||
material_id: ProductId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "presentationAppearanceSource".
|
||||
*/
|
||||
export interface PresentationAppearanceSource {
|
||||
kind: "presentation";
|
||||
presentation_id: LogicalId;
|
||||
appearance_name: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "entityAsset".
|
||||
*/
|
||||
export interface EntityAsset {
|
||||
entity_asset_id: Hash;
|
||||
uri: Uri;
|
||||
media_type: "application/vnd.simplecad.entities+json";
|
||||
byte_length: SafeInteger;
|
||||
content_hash: Hash;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "connectorTarget".
|
||||
*/
|
||||
export interface ConnectorTarget {
|
||||
entity_asset_id: Hash;
|
||||
entity_id: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "forwardedFrom".
|
||||
*/
|
||||
export interface ForwardedFrom {
|
||||
source_component_id: ProductId;
|
||||
source_definition_id: StructuralId;
|
||||
source_connector_id: ProductId;
|
||||
source_connector_snapshot_id: StructuralId;
|
||||
offset: null | Transform;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "modelOperationSource".
|
||||
*/
|
||||
export interface ModelOperationSource {
|
||||
kind: "model_operation";
|
||||
graph_id: ProductId;
|
||||
node_id: ProductId;
|
||||
output_slot: SafeInteger;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "manualConnectorSource".
|
||||
*/
|
||||
export interface ManualConnectorSource {
|
||||
kind: "manual";
|
||||
source_id: LogicalId;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "perspectiveCamera".
|
||||
*/
|
||||
export interface PerspectiveCamera {
|
||||
camera_id: StructuralId;
|
||||
name: string;
|
||||
projection: "perspective";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: PositiveNumber;
|
||||
far: PositiveNumber;
|
||||
vertical_fov_degrees: number;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "orthographicCamera".
|
||||
*/
|
||||
export interface OrthographicCamera {
|
||||
camera_id: StructuralId;
|
||||
name: string;
|
||||
projection: "orthographic";
|
||||
parent_node_id: null | StructuralId;
|
||||
transform: Transform;
|
||||
near: PositiveNumber;
|
||||
far: PositiveNumber;
|
||||
vertical_span: PositiveNumber;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "assetBase".
|
||||
*/
|
||||
export interface AssetBase {
|
||||
asset_id?: Hash;
|
||||
uri?: Uri;
|
||||
media_type?: "model/gltf-binary";
|
||||
byte_length?: SafeInteger;
|
||||
content_hash?: Hash;
|
||||
scene_local_bounds?: Bounds;
|
||||
/**
|
||||
* @minItems 16
|
||||
* @maxItems 16
|
||||
*/
|
||||
asset_to_scene?: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `SceneDocument`'s JSON-Schema
|
||||
* via the `definition` "cameraBase".
|
||||
*/
|
||||
export interface CameraBase {
|
||||
camera_id?: StructuralId;
|
||||
name?: string;
|
||||
parent_node_id?: null | StructuralId;
|
||||
transform?: Transform;
|
||||
near?: PositiveNumber;
|
||||
far?: PositiveNumber;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { canonicalJsonBytes, parseStrictJson } from "./canonical.js";
|
||||
import { BASE_LIMITS, type SceneResourceLimits } from "./resources.js";
|
||||
|
||||
export type GlbKind = "triangle" | "line";
|
||||
|
||||
export interface GlbInfo {
|
||||
kind: GlbKind;
|
||||
vertexCount: number;
|
||||
indexCount: number;
|
||||
primitiveCount: number;
|
||||
decodedBufferBytes: number;
|
||||
positionBounds: readonly [readonly [number, number, number], readonly [number, number, number]];
|
||||
}
|
||||
|
||||
const maxSafeInteger = 9_007_199_254_740_991;
|
||||
|
||||
function record(value: unknown, context: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${context} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireKeys(value: unknown, expected: readonly string[], context: string): Record<string, unknown> {
|
||||
const result = record(value, context);
|
||||
const keys = Object.keys(result).sort();
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== [...expected].sort()[index])) {
|
||||
throw new Error(`${context} fields do not match closed GLB profile`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function integer(value: unknown, context: string): number {
|
||||
if (!Number.isSafeInteger(value) || typeof value !== "number" || value < 0 || value > maxSafeInteger) {
|
||||
throw new Error(`${context} must be a non-negative safe integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function vec3(value: unknown, context: string): [number, number, number] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 3 ||
|
||||
value.some((component) => typeof component !== "number")
|
||||
) {
|
||||
throw new Error(`${context} must be a three-number array`);
|
||||
}
|
||||
if (!value.every(Number.isFinite)) throw new Error(`${context} must contain finite values`);
|
||||
return value as [number, number, number];
|
||||
}
|
||||
|
||||
export function profileF32Bits(value: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("f32 input must be a finite number");
|
||||
const converted = Math.fround(value);
|
||||
if (!Number.isFinite(converted)) throw new Error("f32 conversion overflowed");
|
||||
const bytes = Buffer.allocUnsafe(4);
|
||||
bytes.writeFloatLE(converted, 0);
|
||||
const bits = bytes.readUInt32LE();
|
||||
return bits === 0x80000000 ? 0 : bits;
|
||||
}
|
||||
|
||||
export function profileF32(value: number): number {
|
||||
const bytes = Buffer.allocUnsafe(4);
|
||||
bytes.writeUInt32LE(profileF32Bits(value));
|
||||
return bytes.readFloatLE();
|
||||
}
|
||||
|
||||
export function profileCross(
|
||||
left: readonly [number, number, number],
|
||||
right: readonly [number, number, number],
|
||||
): [number, number, number] {
|
||||
const a = vec3(left, "cross left");
|
||||
const b = vec3(right, "cross right");
|
||||
const p1x = a[1] * b[2];
|
||||
const p2x = a[2] * b[1];
|
||||
const p1y = a[2] * b[0];
|
||||
const p2y = a[0] * b[2];
|
||||
const p1z = a[0] * b[1];
|
||||
const p2z = a[1] * b[0];
|
||||
const result: [number, number, number] = [p1x - p2x, p1y - p2y, p1z - p2z];
|
||||
if (!result.every(Number.isFinite)) throw new Error("cross product produced a non-finite value");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function profileNormalize(value: readonly [number, number, number]): [number, number, number] {
|
||||
const vector = vec3(value, "normalize input");
|
||||
let squared = vector[0] * vector[0] + vector[1] * vector[1];
|
||||
squared += vector[2] * vector[2];
|
||||
if (!Number.isFinite(squared) || squared <= 0) throw new Error("normalize input has zero or non-finite length");
|
||||
const length = Math.sqrt(squared);
|
||||
const result: [number, number, number] = vector.map((component) => profileF32(component / length)) as [number, number, number];
|
||||
let normSquared = result[0] * result[0] + result[1] * result[1];
|
||||
normSquared += result[2] * result[2];
|
||||
const norm = Math.sqrt(normSquared);
|
||||
if (norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("normalized binary32 vector is outside the profile tolerance");
|
||||
return result;
|
||||
}
|
||||
|
||||
function array(value: unknown, context: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${context} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function equalArray(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function tupleCompare(left: readonly number[], right: readonly number[]): number {
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return left[index] - right[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function preflightGlbCounts(
|
||||
kind: GlbKind,
|
||||
vertexCountInput: number,
|
||||
indexCountInput: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
const vertexCount = integer(vertexCountInput, "POSITION count");
|
||||
const vertexLimit = kind === "triangle"
|
||||
? limits.triangle_vertices_per_asset
|
||||
: limits.line_vertices_per_asset;
|
||||
if (vertexCount === 0 || vertexCount > vertexLimit) {
|
||||
throw new Error("GLB vertex count is empty or exceeds resource limit");
|
||||
}
|
||||
const indexCount = integer(indexCountInput, "index count");
|
||||
const divisor = kind === "triangle" ? 3 : 2;
|
||||
const primitiveCount = indexCount / divisor;
|
||||
const primitiveLimit = kind === "triangle"
|
||||
? limits.triangles_per_asset
|
||||
: limits.line_segments_per_asset;
|
||||
if (indexCount === 0 || indexCount % divisor !== 0 || primitiveCount > primitiveLimit) {
|
||||
throw new Error("GLB index count is invalid or exceeds resource limit");
|
||||
}
|
||||
return primitiveCount;
|
||||
}
|
||||
|
||||
export function preflightGlb(
|
||||
data: Uint8Array,
|
||||
expectedKind?: GlbKind,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): GlbInfo {
|
||||
if (data.byteLength > limits.one_member_bytes) throw new Error("GLB exceeds resource limit");
|
||||
const raw = Buffer.from(data);
|
||||
if (raw.length < 28) throw new Error("truncated GLB");
|
||||
if (raw.readUInt32LE(0) !== 0x46546c67 || raw.readUInt32LE(4) !== 2 || raw.readUInt32LE(8) !== raw.length) {
|
||||
throw new Error("invalid GLB header");
|
||||
}
|
||||
const jsonLength = raw.readUInt32LE(12);
|
||||
if (raw.readUInt32LE(16) !== 0x4e4f534a || jsonLength % 4 !== 0) {
|
||||
throw new Error("invalid GLB JSON chunk header");
|
||||
}
|
||||
const jsonEnd = 20 + jsonLength;
|
||||
if (jsonEnd + 8 > raw.length) throw new Error("truncated GLB JSON chunk");
|
||||
const paddedJson = raw.subarray(20, jsonEnd);
|
||||
let unpaddedLength = paddedJson.length;
|
||||
while (unpaddedLength > 0 && paddedJson[unpaddedLength - 1] === 0x20) unpaddedLength -= 1;
|
||||
const jsonBytes = paddedJson.subarray(0, unpaddedLength);
|
||||
const expectedPadding = (4 - (jsonBytes.length % 4)) % 4;
|
||||
if (
|
||||
paddedJson.length !== jsonBytes.length + expectedPadding ||
|
||||
paddedJson.subarray(jsonBytes.length).some((byte) => byte !== 0x20)
|
||||
) {
|
||||
throw new Error("GLB JSON padding must use the minimal ASCII-space suffix");
|
||||
}
|
||||
const document = parseStrictJson(jsonBytes);
|
||||
if (document === null || typeof document !== "object" || Array.isArray(document) || !Buffer.from(jsonBytes).equals(canonicalJsonBytes(document))) {
|
||||
throw new Error("GLB JSON chunk must be RFC 8785 canonical");
|
||||
}
|
||||
const binLength = raw.readUInt32LE(jsonEnd);
|
||||
if (raw.readUInt32LE(jsonEnd + 4) !== 0x004e4942 || binLength % 4 !== 0 || jsonEnd + 8 + binLength !== raw.length) {
|
||||
throw new Error("invalid GLB BIN chunk header");
|
||||
}
|
||||
const binData = raw.subarray(jsonEnd + 8);
|
||||
const root = requireKeys(
|
||||
document,
|
||||
["accessors", "asset", "bufferViews", "buffers", "meshes", "nodes", "scene", "scenes"],
|
||||
"GLB root",
|
||||
);
|
||||
const asset = root.asset;
|
||||
if (JSON.stringify(asset) !== JSON.stringify({ generator: "SimpleCAD Scene GLB Profile 1", version: "2.0" })) {
|
||||
throw new Error("GLB asset record does not match Scene profile");
|
||||
}
|
||||
const nodes = array(root.nodes, "GLB nodes");
|
||||
const scenes = array(root.scenes, "GLB scenes");
|
||||
let skeletonValid = integer(root.scene, "GLB default scene") === 0 && nodes.length === 1 && scenes.length === 1;
|
||||
if (skeletonValid) {
|
||||
const node = requireKeys(nodes[0], ["mesh"], "GLB node");
|
||||
const scene = requireKeys(scenes[0], ["nodes"], "GLB scene");
|
||||
skeletonValid = integer(node.mesh, "GLB node mesh") === 0 && Array.isArray(scene.nodes) && equalArray(scene.nodes, [0]);
|
||||
if (Array.isArray(scene.nodes) && scene.nodes.some((item) => !Number.isSafeInteger(item) || typeof item !== "number")) {
|
||||
skeletonValid = false;
|
||||
}
|
||||
}
|
||||
if (!skeletonValid) throw new Error("GLB scene/node skeleton does not match Scene profile");
|
||||
|
||||
const meshes = array(root.meshes, "GLB meshes");
|
||||
if (meshes.length !== 1) throw new Error("GLB must contain exactly one mesh");
|
||||
const mesh = requireKeys(meshes[0], ["primitives"], "GLB mesh");
|
||||
const primitives = array(mesh.primitives, "GLB primitives");
|
||||
if (primitives.length !== 1) throw new Error("GLB must contain exactly one primitive");
|
||||
const primitive = requireKeys(primitives[0], ["attributes", "indices", "mode"], "GLB primitive");
|
||||
const accessors = array(root.accessors, "GLB accessors");
|
||||
const views = array(root.bufferViews, "GLB bufferViews");
|
||||
const buffers = array(root.buffers, "GLB buffers");
|
||||
if (buffers.length !== 1) throw new Error("GLB accessors/bufferViews/buffer skeleton is invalid");
|
||||
const buffer = requireKeys(buffers[0], ["byteLength"], "GLB buffer");
|
||||
const bufferByteLength = integer(buffer.byteLength, "GLB buffer byteLength");
|
||||
const attributesRecord = record(primitive.attributes, "GLB primitive attributes");
|
||||
const attributes = Object.fromEntries(
|
||||
Object.entries(attributesRecord).map(([name, value]) => [name, integer(value, `GLB ${name} attribute accessor`)]),
|
||||
);
|
||||
const primitiveIndices = integer(primitive.indices, "GLB primitive index accessor");
|
||||
const primitiveMode = integer(primitive.mode, "GLB primitive mode");
|
||||
let kind: GlbKind;
|
||||
let accessorCount: number;
|
||||
let indexView: number;
|
||||
if (JSON.stringify(attributes) === JSON.stringify({ NORMAL: 1, POSITION: 0 }) && primitiveIndices === 2 && primitiveMode === 4) {
|
||||
kind = "triangle";
|
||||
accessorCount = 3;
|
||||
indexView = 2;
|
||||
} else if (JSON.stringify(attributes) === JSON.stringify({ POSITION: 0 }) && primitiveIndices === 1 && primitiveMode === 1) {
|
||||
kind = "line";
|
||||
accessorCount = 2;
|
||||
indexView = 1;
|
||||
} else {
|
||||
throw new Error("GLB primitive does not match triangle or line profile");
|
||||
}
|
||||
if (expectedKind !== undefined && kind !== expectedKind) throw new Error(`expected ${expectedKind} GLB, got ${kind}`);
|
||||
if (accessors.length !== accessorCount || views.length !== accessorCount) {
|
||||
throw new Error("GLB accessor/bufferView count mismatch");
|
||||
}
|
||||
|
||||
const position = requireKeys(accessors[0], ["bufferView", "componentType", "count", "max", "min", "type"], "POSITION accessor");
|
||||
const vertexCount = integer(position.count, "POSITION count");
|
||||
if (integer(position.bufferView, "POSITION bufferView") !== 0 || integer(position.componentType, "POSITION componentType") !== 5126 || position.type !== "VEC3") {
|
||||
throw new Error("invalid POSITION accessor");
|
||||
}
|
||||
const minimum = vec3(position.min, "POSITION min");
|
||||
const maximum = vec3(position.max, "POSITION max");
|
||||
if (minimum.some((value, index) => value > maximum[index])) throw new Error("POSITION min exceeds max");
|
||||
|
||||
if (kind === "triangle") {
|
||||
const normal = requireKeys(accessors[1], ["bufferView", "componentType", "count", "type"], "NORMAL accessor");
|
||||
if (integer(normal.bufferView, "NORMAL bufferView") !== 1 || integer(normal.componentType, "NORMAL componentType") !== 5126 || integer(normal.count, "NORMAL count") !== vertexCount || normal.type !== "VEC3") {
|
||||
throw new Error("invalid NORMAL accessor");
|
||||
}
|
||||
}
|
||||
const indexAccessor = requireKeys(accessors[indexView], ["bufferView", "componentType", "count", "type"], "index accessor");
|
||||
const indexCount = integer(indexAccessor.count, "index count");
|
||||
const primitiveCount = preflightGlbCounts(kind, vertexCount, indexCount, limits);
|
||||
const componentType = vertexCount <= 65536 ? 5123 : 5125;
|
||||
if (integer(indexAccessor.bufferView, "index bufferView") !== indexView || integer(indexAccessor.componentType, "index componentType") !== componentType || indexAccessor.type !== "SCALAR") {
|
||||
throw new Error("invalid GLB index accessor");
|
||||
}
|
||||
|
||||
let expectedOffset = 0;
|
||||
const parsedViews: Record<string, unknown>[] = [];
|
||||
for (let viewIndex = 0; viewIndex < views.length; viewIndex += 1) {
|
||||
const view = requireKeys(views[viewIndex], ["buffer", "byteLength", "byteOffset", "target"], `bufferView[${viewIndex}]`);
|
||||
parsedViews.push(view);
|
||||
const offset = integer(view.byteOffset, `bufferView[${viewIndex}].byteOffset`);
|
||||
const length = integer(view.byteLength, `bufferView[${viewIndex}].byteLength`);
|
||||
if (integer(view.buffer, `bufferView[${viewIndex}].buffer`) !== 0 || offset !== expectedOffset) throw new Error("GLB bufferView offset mismatch");
|
||||
if (integer(view.target, `bufferView[${viewIndex}].target`) !== (viewIndex < indexView ? 34962 : 34963)) throw new Error("GLB bufferView target mismatch");
|
||||
const expectedLength = viewIndex < indexView ? 12 * vertexCount : indexCount * (componentType === 5123 ? 2 : 4);
|
||||
if (length !== expectedLength) throw new Error("GLB bufferView byteLength mismatch");
|
||||
expectedOffset = (expectedOffset + expectedLength + 3) & ~3;
|
||||
}
|
||||
const lastView = parsedViews.at(-1)!;
|
||||
const unpaddedBinLength = integer(lastView.byteOffset, "last bufferView byteOffset") + integer(lastView.byteLength, "last bufferView byteLength");
|
||||
if (bufferByteLength !== unpaddedBinLength) throw new Error("GLB buffer byteLength mismatch");
|
||||
if (binLength !== ((unpaddedBinLength + 3) & ~3)) throw new Error("GLB BIN chunk length mismatch");
|
||||
if (binData.subarray(unpaddedBinLength).some((byte) => byte !== 0)) throw new Error("GLB BIN padding must be zero");
|
||||
|
||||
const positions: [number, number, number][] = [];
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const offset = index * 12;
|
||||
const point: [number, number, number] = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)];
|
||||
if (!point.every(Number.isFinite)) throw new Error("GLB POSITION contains non-finite values");
|
||||
if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign");
|
||||
positions.push(point);
|
||||
}
|
||||
const actualMin: [number, number, number] = [...positions[0]];
|
||||
const actualMax: [number, number, number] = [...positions[0]];
|
||||
for (const point of positions) {
|
||||
for (let axis = 0; axis < 3; axis += 1) {
|
||||
actualMin[axis] = Math.min(actualMin[axis], point[axis]);
|
||||
actualMax[axis] = Math.max(actualMax[axis], point[axis]);
|
||||
}
|
||||
}
|
||||
if (!equalArray(minimum, actualMin) || !equalArray(maximum, actualMax)) throw new Error("POSITION accessor bounds do not match buffer");
|
||||
|
||||
if (kind === "triangle") {
|
||||
const normalOffset = integer(parsedViews[1].byteOffset, "NORMAL byteOffset");
|
||||
for (let index = 0; index < vertexCount; index += 1) {
|
||||
const offset = normalOffset + index * 12;
|
||||
const normal = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)];
|
||||
if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign");
|
||||
const norm = Math.sqrt(normal.reduce((sum, component) => sum + component * component, 0));
|
||||
if (!normal.every(Number.isFinite) || norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("GLB NORMAL must be finite and normalized");
|
||||
}
|
||||
}
|
||||
const indices: number[] = [];
|
||||
const indexOffset = integer(parsedViews[indexView].byteOffset, "index byteOffset");
|
||||
for (let index = 0; index < indexCount; index += 1) {
|
||||
indices.push(componentType === 5123 ? binData.readUInt16LE(indexOffset + index * 2) : binData.readUInt32LE(indexOffset + index * 4));
|
||||
}
|
||||
let maximumIndex = -1;
|
||||
for (const index of indices) maximumIndex = Math.max(maximumIndex, index);
|
||||
if (maximumIndex >= vertexCount || new Set(indices).size !== vertexCount) throw new Error("GLB indices must be in range and reference every vertex");
|
||||
if (kind === "triangle") {
|
||||
const triangles: number[][] = [];
|
||||
for (let index = 0; index < indexCount; index += 3) {
|
||||
const triple = indices.slice(index, index + 3);
|
||||
const rotations = [triple, [triple[1], triple[2], triple[0]], [triple[2], triple[0], triple[1]]].sort(tupleCompare);
|
||||
if (!equalArray(triple, rotations[0])) throw new Error("GLB triangle indices are not in canonical cyclic rotation");
|
||||
const [a, b, c] = triple.map((item) => positions[item]);
|
||||
const ab = b.map((value, axis) => value - a[axis]);
|
||||
const ac = c.map((value, axis) => value - a[axis]);
|
||||
const cross = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
|
||||
if (equalArray(a, b) || equalArray(b, c) || equalArray(a, c) || cross.every((value) => value === 0)) throw new Error("GLB contains a collapsed triangle");
|
||||
triangles.push(triple);
|
||||
}
|
||||
const sorted = [...triangles].sort(tupleCompare);
|
||||
if (triangles.some((triple, index) => !equalArray(triple, sorted[index]))) throw new Error("GLB triangle records are not canonically sorted");
|
||||
} else {
|
||||
const segments: number[][] = [];
|
||||
for (let index = 0; index < indexCount; index += 2) {
|
||||
const pair = indices.slice(index, index + 2);
|
||||
if (pair[0] >= pair[1]) throw new Error("GLB line indices are not in canonical endpoint order");
|
||||
if (equalArray(positions[pair[0]], positions[pair[1]])) throw new Error("GLB contains a collapsed line segment");
|
||||
segments.push(pair);
|
||||
}
|
||||
const sortedUnique = [...new Map(segments.map((pair) => [pair.join(","), pair])).values()].sort(tupleCompare);
|
||||
if (segments.length !== sortedUnique.length || segments.some((pair, index) => !equalArray(pair, sortedUnique[index]))) throw new Error("GLB line records are not sorted and unique");
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
vertexCount,
|
||||
indexCount,
|
||||
primitiveCount,
|
||||
decodedBufferBytes: 2 * parsedViews.reduce((sum, view) => sum + integer(view.byteLength, "bufferView byteLength"), 0),
|
||||
positionBounds: [minimum, maximum],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./canonical.js";
|
||||
export * from "./documents.js";
|
||||
export * from "./glb.js";
|
||||
export * from "./report.js";
|
||||
export * from "./resources.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./zip.js";
|
||||
@@ -0,0 +1,154 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { parseStrictJson } from "./canonical.js";
|
||||
|
||||
export type ValidationPhase = "parse" | "structure" | "semantic" | "package" | "budget";
|
||||
export type ValidationArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product" | "package" | "glb" | "zip";
|
||||
|
||||
export interface ValidationRule {
|
||||
readonly id: string;
|
||||
readonly phase: ValidationPhase;
|
||||
readonly precedence: number;
|
||||
readonly artifacts: readonly ValidationArtifact[];
|
||||
readonly pointer_policy: "exact" | "nearest_record" | "root";
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
interface ValidationRuleRegistry {
|
||||
readonly schema_version: "1.0";
|
||||
readonly registry_id: "scene-1.0-rules";
|
||||
readonly phases: readonly ValidationPhase[];
|
||||
readonly rules: readonly ValidationRule[];
|
||||
}
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: "error";
|
||||
code: string;
|
||||
path: string;
|
||||
message: string;
|
||||
phase: ValidationPhase;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
valid: boolean;
|
||||
issues: readonly ValidationIssue[];
|
||||
firstError: ValidationIssue | null;
|
||||
}
|
||||
|
||||
const phaseOrder: Record<ValidationPhase, number> = {
|
||||
parse: 0,
|
||||
structure: 1,
|
||||
semantic: 2,
|
||||
package: 3,
|
||||
budget: 4,
|
||||
};
|
||||
|
||||
function utf8Compare(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
||||
}
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts",
|
||||
);
|
||||
const ruleSchema = parseStrictJson(readFileSync(resolve(contractRoot, "schemas/rules-1.schema.json")));
|
||||
const validateRuleSchema = new Ajv2020({ strict: true, allErrors: true }).compile(ruleSchema as object);
|
||||
|
||||
export function validateRuleRegistry(value: unknown): ReadonlyMap<string, ValidationRule> {
|
||||
if (!validateRuleSchema(value)) {
|
||||
const errors = [...(validateRuleSchema.errors ?? [])].sort(
|
||||
(left, right) => utf8Compare(left.instancePath, right.instancePath) ||
|
||||
utf8Compare(left.message ?? "", right.message ?? ""),
|
||||
);
|
||||
const first = errors[0];
|
||||
throw new Error(`invalid scene rule registry at ${first?.instancePath ?? ""}: ${first?.message ?? "schema validation failed"}`);
|
||||
}
|
||||
|
||||
const registry = value as ValidationRuleRegistry;
|
||||
const registryPhaseOrder = new Map(registry.phases.map((phase, index) => [phase, index]));
|
||||
const byId = new Map<string, ValidationRule>();
|
||||
const phasePrecedence = new Set<string>();
|
||||
for (const rule of registry.rules) {
|
||||
if (byId.has(rule.id)) throw new Error(`invalid scene rule registry: duplicate rule ID ${JSON.stringify(rule.id)}`);
|
||||
const precedenceKey = `${rule.phase}\0${rule.precedence}`;
|
||||
if (phasePrecedence.has(precedenceKey)) {
|
||||
throw new Error(`invalid scene rule registry: duplicate precedence ${rule.precedence} in phase ${JSON.stringify(rule.phase)}`);
|
||||
}
|
||||
byId.set(rule.id, rule);
|
||||
phasePrecedence.add(precedenceKey);
|
||||
}
|
||||
const expected = [...registry.rules].sort(
|
||||
(left, right) =>
|
||||
registryPhaseOrder.get(left.phase)! - registryPhaseOrder.get(right.phase)! ||
|
||||
left.precedence - right.precedence,
|
||||
);
|
||||
if (registry.rules.some((rule, index) => rule !== expected[index])) {
|
||||
throw new Error("invalid scene rule registry: rules must be ordered by phase and precedence");
|
||||
}
|
||||
for (const phase of registry.phases) {
|
||||
const phaseRules = registry.rules.filter((rule) => rule.phase === phase);
|
||||
const ids = phaseRules.map((rule) => rule.id);
|
||||
const expectedIds = [...ids].sort(utf8Compare);
|
||||
if (ids.some((id, index) => id !== expectedIds[index])) {
|
||||
throw new Error(`invalid scene rule registry: precedence in phase ${JSON.stringify(phase)} must match unsigned UTF-8 rule-ID order`);
|
||||
}
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
const rules = validateRuleRegistry(
|
||||
parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))),
|
||||
);
|
||||
|
||||
export function hasRootPointerPolicy(code: string): boolean {
|
||||
return rules.get(code)?.pointer_policy === "root";
|
||||
}
|
||||
|
||||
export function issue(
|
||||
code: string,
|
||||
path: string,
|
||||
message: string,
|
||||
phase: ValidationPhase = "semantic",
|
||||
): ValidationIssue {
|
||||
return { severity: "error", code, path, message, phase };
|
||||
}
|
||||
|
||||
export function report(input: Iterable<ValidationIssue>, artifact?: ValidationArtifact): ValidationReport {
|
||||
const unique = new Map<string, ValidationIssue>();
|
||||
for (const item of input) {
|
||||
if (artifact !== undefined) {
|
||||
const rule = rules.get(item.code);
|
||||
if (rule === undefined) throw new Error(`unregistered scene validation issue code: ${JSON.stringify(item.code)}`);
|
||||
if (item.phase !== rule.phase) {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} has phase ${JSON.stringify(item.phase)}; registered phase is ${JSON.stringify(rule.phase)}`);
|
||||
}
|
||||
if (!rule.artifacts.includes(artifact)) {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} does not apply to artifact ${JSON.stringify(artifact)}`);
|
||||
}
|
||||
if (rule.pointer_policy === "root" && item.path !== "") {
|
||||
throw new Error(`scene validation issue ${JSON.stringify(item.code)} requires the root pointer`);
|
||||
}
|
||||
}
|
||||
const key = artifact === undefined
|
||||
? `${item.phase}\0${item.code}\0${item.path}`
|
||||
: `${item.phase}\0${item.code}\0${item.path}\0${item.message}`;
|
||||
if (!unique.has(key)) unique.set(key, item);
|
||||
}
|
||||
const issues = [...unique.values()].sort(
|
||||
(left, right) =>
|
||||
phaseOrder[left.phase] - phaseOrder[right.phase] ||
|
||||
(artifact === undefined
|
||||
? utf8Compare(left.code, right.code)
|
||||
: rules.get(left.code)!.precedence - rules.get(right.code)!.precedence) ||
|
||||
utf8Compare(left.path, right.path) ||
|
||||
utf8Compare(left.message, right.message),
|
||||
);
|
||||
return { valid: issues.length === 0, issues, firstError: issues[0] ?? null };
|
||||
}
|
||||
|
||||
export function pointer(parts: readonly (string | number)[]): string {
|
||||
if (parts.length === 0) return "";
|
||||
return `/${parts.map((part) => String(part).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const BASE_LIMITS = {
|
||||
zip_members: 50_000,
|
||||
input_archive_bytes: 256 * 1024 * 1024,
|
||||
canonical_archive_bytes: 256 * 1024 * 1024,
|
||||
total_uncompressed_bytes: 1024 * 1024 * 1024,
|
||||
one_member_bytes: 256 * 1024 * 1024,
|
||||
scene_json_bytes: 32 * 1024 * 1024,
|
||||
entity_json_bytes: 64 * 1024 * 1024,
|
||||
model_json_bytes: 64 * 1024 * 1024,
|
||||
presentation_json_bytes: 8 * 1024 * 1024,
|
||||
compression_ratio: 100,
|
||||
json_depth: 64,
|
||||
json_string_bytes: 1024 * 1024,
|
||||
uri_bytes: 1024,
|
||||
structural_id_bytes: 4096,
|
||||
definitions: 25_000,
|
||||
nodes: 100_000,
|
||||
assets_per_kind: 25_000,
|
||||
appearances: 25_000,
|
||||
connectors: 100_000,
|
||||
cameras: 1000,
|
||||
hierarchy_depth: 256,
|
||||
forwarded_connector_depth: 64,
|
||||
entities_per_sidecar: 500_000,
|
||||
entities_total: 2_000_000,
|
||||
triangle_vertices_per_asset: 2_000_000,
|
||||
triangle_vertices_total: 10_000_000,
|
||||
triangles_per_asset: 2_000_000,
|
||||
triangles_total: 10_000_000,
|
||||
line_vertices_per_asset: 2_000_000,
|
||||
line_vertices_total: 10_000_000,
|
||||
line_segments_per_asset: 2_000_000,
|
||||
line_segments_total: 10_000_000,
|
||||
static_decoded_buffer_bytes: 512 * 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export type SceneResourceLimits = { readonly [Name in keyof typeof BASE_LIMITS]: number };
|
||||
export type SceneResourceLimitName = keyof typeof BASE_LIMITS;
|
||||
|
||||
export function preflightInputArchiveSize(size: number, limits: SceneResourceLimits = BASE_LIMITS): number {
|
||||
if (!Number.isSafeInteger(size) || size < 0) throw new Error("invalid input archive size");
|
||||
if (size > limits.input_archive_bytes) throw new Error("input archive exceeds resource limit");
|
||||
return size;
|
||||
}
|
||||
|
||||
export function preflightResourceCount(
|
||||
count: number,
|
||||
limitName: SceneResourceLimitName,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
if (!Number.isSafeInteger(count) || count < 0) throw new Error("invalid resource count");
|
||||
if (count > limits[limitName]) throw new Error(`${limitName} exceeds resource limit`);
|
||||
return count;
|
||||
}
|
||||
|
||||
export function preflightAggregateCompressionRatio(
|
||||
uncompressedSize: number,
|
||||
archiveSize: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(archiveSize) || archiveSize <= 0) {
|
||||
throw new Error("invalid compression ratio sizes");
|
||||
}
|
||||
if (uncompressedSize > limits.compression_ratio * archiveSize) throw new Error("aggregate compression ratio exceeds limit");
|
||||
}
|
||||
|
||||
export function preflightMemberCompressionRatio(
|
||||
uncompressedSize: number,
|
||||
compressedSize: number,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(compressedSize) || compressedSize < 0) {
|
||||
throw new Error("invalid compression ratio sizes");
|
||||
}
|
||||
if (uncompressedSize > limits.compression_ratio * Math.max(1, compressedSize)) throw new Error("member compression ratio exceeds limit");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js";
|
||||
import { parseStrictJson } from "./canonical.js";
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts/schemas",
|
||||
);
|
||||
|
||||
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
||||
|
||||
function declareRequiredProperties(value: unknown): void {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
const schema = value as Record<string, unknown>;
|
||||
if (
|
||||
schema.type === undefined &&
|
||||
(schema.properties !== undefined || schema.required !== undefined)
|
||||
) {
|
||||
schema.type = "object";
|
||||
}
|
||||
if (Array.isArray(schema.required) && schema.required.every((item) => typeof item === "string")) {
|
||||
const properties =
|
||||
schema.properties !== null && typeof schema.properties === "object" && !Array.isArray(schema.properties)
|
||||
? (schema.properties as Record<string, unknown>)
|
||||
: {};
|
||||
for (const name of schema.required) properties[name] ??= {};
|
||||
schema.properties = properties;
|
||||
}
|
||||
for (const key of ["properties", "$defs", "patternProperties", "dependentSchemas"]) {
|
||||
const schemas = schema[key];
|
||||
if (schemas !== null && typeof schemas === "object" && !Array.isArray(schemas)) {
|
||||
Object.values(schemas).forEach(declareRequiredProperties);
|
||||
}
|
||||
}
|
||||
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) {
|
||||
const schemas = schema[key];
|
||||
if (Array.isArray(schemas)) schemas.forEach(declareRequiredProperties);
|
||||
}
|
||||
for (const key of [
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"items",
|
||||
"contains",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
]) {
|
||||
declareRequiredProperties(schema[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function load(name: string): ValidateFunction {
|
||||
const schema = parseStrictJson(readFileSync(resolve(contractRoot, name)));
|
||||
// Ajv strictRequired expects declarations in the same nested subschema. The
|
||||
// contract uses valid required-only guards, so add behavior-neutral schemas.
|
||||
declareRequiredProperties(schema);
|
||||
return ajv.compile(schema as object);
|
||||
}
|
||||
|
||||
export type SchemaArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product";
|
||||
|
||||
const schemaFiles: Record<SchemaArtifact, string> = {
|
||||
scene: "scene-1.0.schema.json",
|
||||
entities: "entities-1.0.schema.json",
|
||||
presentation: "presentation-1.0.schema.json",
|
||||
connector_binding: "connector-binding-1.0.schema.json",
|
||||
normalized_product: "normalized-product-1.schema.json",
|
||||
};
|
||||
const schemaDocuments = Object.fromEntries(
|
||||
Object.entries(schemaFiles).map(([artifact, name]) => [artifact, parseStrictJson(readFileSync(resolve(contractRoot, name)))]),
|
||||
) as Record<SchemaArtifact, Record<string, unknown>>;
|
||||
const pointerValidators = new Map<string, ValidateFunction>();
|
||||
|
||||
export function validateSchemaPointer(artifact: SchemaArtifact, schemaPointer: string, value: unknown): boolean {
|
||||
const key = `${artifact}:${schemaPointer}`;
|
||||
let validate = pointerValidators.get(key);
|
||||
if (validate === undefined) {
|
||||
const root = structuredClone(schemaDocuments[artifact]);
|
||||
delete root.$id;
|
||||
const wrapper = schemaPointer === "#"
|
||||
? root
|
||||
: { $schema: root.$schema, $ref: schemaPointer, $defs: root.$defs };
|
||||
declareRequiredProperties(wrapper);
|
||||
validate = ajv.compile(wrapper);
|
||||
pointerValidators.set(key, validate);
|
||||
}
|
||||
return Boolean(validate(value));
|
||||
}
|
||||
|
||||
export const validateSceneSchema = load("scene-1.0.schema.json");
|
||||
export const validateEntitiesSchema = load("entities-1.0.schema.json");
|
||||
export const validatePresentationSchema = load("presentation-1.0.schema.json");
|
||||
export const validateConnectorBindingSchema = load("connector-binding-1.0.schema.json");
|
||||
export const validateNormalizedProductSchema = load("normalized-product-1.schema.json");
|
||||
|
||||
export function schemaErrors(validate: ValidateFunction, value: unknown): ErrorObject[] {
|
||||
validate(value);
|
||||
return validate.errors === null || validate.errors === undefined ? [] : [...validate.errors];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Deterministic rigid-transform primitives used by Scene validation. */
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function composeRigidTransforms(left: JsonRecord, right: JsonRecord): JsonRecord {
|
||||
const origin = left.origin as number[];
|
||||
const xAxis = left.x_axis as number[];
|
||||
const yAxis = left.y_axis as number[];
|
||||
const zAxis = left.z_axis as number[];
|
||||
const rotate = (vector: number[]): number[] => [0, 1, 2].map((index) =>
|
||||
(xAxis[index] * vector[0] + yAxis[index] * vector[1]) + zAxis[index] * vector[2]
|
||||
);
|
||||
const rotatedOrigin = rotate(right.origin as number[]);
|
||||
return {
|
||||
origin: origin.map((component, index) => component + rotatedOrigin[index]),
|
||||
x_axis: rotate(right.x_axis as number[]),
|
||||
y_axis: rotate(right.y_axis as number[]),
|
||||
z_axis: rotate(right.z_axis as number[]),
|
||||
};
|
||||
}
|
||||
|
||||
export function rigidTransformsEqual(actual: JsonRecord, expected: JsonRecord): boolean {
|
||||
const actualOrigin = actual.origin as number[];
|
||||
const expectedOrigin = expected.origin as number[];
|
||||
const originEpsilon = Math.max(
|
||||
1e-9,
|
||||
1e-12 * Math.max(1, ...actualOrigin.map(Math.abs), ...expectedOrigin.map(Math.abs)),
|
||||
);
|
||||
return actualOrigin.every((component, index) => Math.abs(component - expectedOrigin[index]) <= originEpsilon) &&
|
||||
["x_axis", "y_axis", "z_axis"].every((key) =>
|
||||
(actual[key] as number[]).every((component, index) =>
|
||||
Math.abs(component - (expected[key] as number[])[index]) <= 1e-12
|
||||
)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import {
|
||||
BASE_LIMITS,
|
||||
preflightAggregateCompressionRatio,
|
||||
preflightInputArchiveSize,
|
||||
preflightMemberCompressionRatio,
|
||||
type SceneResourceLimits,
|
||||
} from "./resources.js";
|
||||
|
||||
export interface ArchiveInfo {
|
||||
members: ReadonlyMap<string, Buffer>;
|
||||
inputSize: number;
|
||||
canonicalSize: number;
|
||||
usedDeflate: boolean;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
method: number;
|
||||
crc32: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localOffset: number;
|
||||
}
|
||||
|
||||
const memberPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$/;
|
||||
function crc32Table(): Uint32Array {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const crcTable = crc32Table();
|
||||
|
||||
function crc32(data: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function ascii(raw: Uint8Array, context: string): string {
|
||||
if (raw.some((byte) => byte > 0x7f)) throw new Error("archive member names must be ASCII");
|
||||
return Buffer.from(raw).toString("ascii");
|
||||
}
|
||||
|
||||
function validateName(name: string, seen: Set<string>): Buffer {
|
||||
const encoded = Buffer.from(name, "ascii");
|
||||
if (encoded.toString("ascii") !== name) throw new Error("archive member names must be ASCII");
|
||||
if (!memberPattern.test(name)) throw new Error(`invalid archive member name: ${JSON.stringify(name)}`);
|
||||
if (name.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new Error(`invalid archive member path segment: ${JSON.stringify(name)}`);
|
||||
}
|
||||
const folded = name.toLowerCase();
|
||||
if (seen.has(folded)) throw new Error(`duplicate or case-colliding archive member: ${JSON.stringify(name)}`);
|
||||
seen.add(folded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function canonicalArchiveSize(sizes: ReadonlyMap<string, number>): number {
|
||||
let total = 22;
|
||||
for (const [name, size] of sizes) total += 76 + 2 * Buffer.byteLength(name, "ascii") + size;
|
||||
return total;
|
||||
}
|
||||
|
||||
export function preflightArchiveMemberSizes(
|
||||
sizes: ReadonlyMap<string, number>,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): number {
|
||||
if (sizes.size === 0 || !sizes.has("scene.json")) throw new Error("scene package must contain scene.json");
|
||||
if (sizes.size > limits.zip_members) throw new Error("archive member count exceeds resource limit");
|
||||
const seen = new Set<string>();
|
||||
let total = 0;
|
||||
for (const [name, size] of sizes) {
|
||||
validateName(name, seen);
|
||||
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`invalid member size for ${JSON.stringify(name)}`);
|
||||
if (size > limits.one_member_bytes) throw new Error(`archive member exceeds resource limit: ${JSON.stringify(name)}`);
|
||||
if (name === "scene.json" && size > limits.scene_json_bytes) throw new Error("scene.json exceeds resource limit");
|
||||
if (name.startsWith("entities/") && size > limits.entity_json_bytes) throw new Error(`entity sidecar exceeds resource limit: ${JSON.stringify(name)}`);
|
||||
if (name === "model/model.json" && size > limits.model_json_bytes) throw new Error("embedded model.json exceeds resource limit");
|
||||
if (name === "presentation/presentation.json" && size > limits.presentation_json_bytes) throw new Error("presentation JSON exceeds resource limit");
|
||||
total += size;
|
||||
if (total > limits.total_uncompressed_bytes) throw new Error("total uncompressed bytes exceed resource limit");
|
||||
}
|
||||
const result = canonicalArchiveSize(sizes);
|
||||
if (result > limits.canonical_archive_bytes) throw new Error("canonical stored archive exceeds resource limit");
|
||||
if (sizes.size > 0xffff || result > 0xffffffff) throw new Error("archive would require ZIP64");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function canonicalZipBytes(
|
||||
input: ReadonlyMap<string, Uint8Array> | Record<string, Uint8Array>,
|
||||
limits: SceneResourceLimits = BASE_LIMITS,
|
||||
): Buffer {
|
||||
const members = input instanceof Map ? new Map(input) : new Map(Object.entries(input));
|
||||
const payloads = new Map([...members].map(([name, value]) => [name, Buffer.from(value)]));
|
||||
const canonicalSize = preflightArchiveMemberSizes(new Map([...payloads].map(([name, value]) => [name, value.length])), limits);
|
||||
const localParts: Buffer[] = [];
|
||||
const centralParts: Buffer[] = [];
|
||||
let localOffset = 0;
|
||||
for (const name of [...payloads.keys()].sort((left, right) => Buffer.compare(Buffer.from(left, "ascii"), Buffer.from(right, "ascii")))) {
|
||||
const nameBytes = Buffer.from(name, "ascii");
|
||||
const payload = payloads.get(name)!;
|
||||
const crc = crc32(payload);
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(0, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(payload.length, 18);
|
||||
local.writeUInt32LE(payload.length, 22);
|
||||
local.writeUInt16LE(nameBytes.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
localParts.push(local, nameBytes, payload);
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(0, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0x0021, 14);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(payload.length, 20);
|
||||
central.writeUInt32LE(payload.length, 24);
|
||||
central.writeUInt16LE(nameBytes.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0x81a40000, 38);
|
||||
central.writeUInt32LE(localOffset, 42);
|
||||
centralParts.push(central, nameBytes);
|
||||
localOffset += local.length + nameBytes.length + payload.length;
|
||||
}
|
||||
const centralBytes = Buffer.concat(centralParts);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(payloads.size, 8);
|
||||
eocd.writeUInt16LE(payloads.size, 10);
|
||||
eocd.writeUInt32LE(centralBytes.length, 12);
|
||||
eocd.writeUInt32LE(localOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
const result = Buffer.concat([...localParts, centralBytes, eocd]);
|
||||
if (result.length !== canonicalSize) throw new Error("canonical ZIP size formula disagrees with encoder");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function preflightZipBytes(data: Uint8Array, limits: SceneResourceLimits = BASE_LIMITS): ArchiveInfo {
|
||||
preflightInputArchiveSize(data.byteLength, limits);
|
||||
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
if (raw.length < 22) throw new Error("truncated ZIP archive");
|
||||
const eocdOffset = raw.length - 22;
|
||||
if (raw.readUInt32LE(eocdOffset) !== 0x06054b50 || raw.readUInt16LE(eocdOffset + 20) !== 0) {
|
||||
throw new Error("ZIP EOCD must be final and have no comment");
|
||||
}
|
||||
const disk = raw.readUInt16LE(eocdOffset + 4);
|
||||
const centralDisk = raw.readUInt16LE(eocdOffset + 6);
|
||||
const diskCount = raw.readUInt16LE(eocdOffset + 8);
|
||||
const totalCount = raw.readUInt16LE(eocdOffset + 10);
|
||||
const centralSize = raw.readUInt32LE(eocdOffset + 12);
|
||||
const centralOffset = raw.readUInt32LE(eocdOffset + 16);
|
||||
if (disk !== 0 || centralDisk !== 0 || diskCount !== totalCount) throw new Error("multi-disk ZIP archives are forbidden");
|
||||
if (totalCount > limits.zip_members) throw new Error("archive member count exceeds resource limit");
|
||||
if (centralOffset + centralSize !== eocdOffset) throw new Error("ZIP central directory offset/size mismatch");
|
||||
const entries: Entry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor = centralOffset;
|
||||
for (let index = 0; index < totalCount; index += 1) {
|
||||
if (cursor + 46 > eocdOffset) throw new Error("truncated ZIP central directory");
|
||||
if (raw.readUInt32LE(cursor) !== 0x02014b50) throw new Error("invalid ZIP central header signature");
|
||||
const madeBy = raw.readUInt16LE(cursor + 4);
|
||||
const needed = raw.readUInt16LE(cursor + 6);
|
||||
const flags = raw.readUInt16LE(cursor + 8);
|
||||
const method = raw.readUInt16LE(cursor + 10);
|
||||
const dosTime = raw.readUInt16LE(cursor + 12);
|
||||
const dosDate = raw.readUInt16LE(cursor + 14);
|
||||
const crc = raw.readUInt32LE(cursor + 16);
|
||||
const compressedSize = raw.readUInt32LE(cursor + 20);
|
||||
const uncompressedSize = raw.readUInt32LE(cursor + 24);
|
||||
const nameLength = raw.readUInt16LE(cursor + 28);
|
||||
const extraLength = raw.readUInt16LE(cursor + 30);
|
||||
const commentLength = raw.readUInt16LE(cursor + 32);
|
||||
const diskStart = raw.readUInt16LE(cursor + 34);
|
||||
const internalAttributes = raw.readUInt16LE(cursor + 36);
|
||||
const externalAttributes = raw.readUInt32LE(cursor + 38);
|
||||
const localOffset = raw.readUInt32LE(cursor + 42);
|
||||
if (madeBy !== 0x0314 || needed !== 20) throw new Error("ZIP creator/version profile mismatch");
|
||||
if (flags !== 0x0800 || (method !== 0 && method !== 8)) throw new Error("unsupported ZIP flags or compression method");
|
||||
if (dosTime !== 0 || dosDate !== 0x0021) throw new Error("ZIP timestamp profile mismatch");
|
||||
if (extraLength !== 0 || commentLength !== 0 || diskStart !== 0 || internalAttributes !== 0) throw new Error("ZIP extra/comment/disk/attributes are forbidden");
|
||||
if (externalAttributes !== 0x81a40000) throw new Error("ZIP member must be Unix regular mode 0100644");
|
||||
const nameStart = cursor + 46;
|
||||
const recordEnd = nameStart + nameLength + extraLength + commentLength;
|
||||
if (recordEnd > eocdOffset) throw new Error("truncated ZIP central member record");
|
||||
const name = ascii(raw.subarray(nameStart, nameStart + nameLength), "central name");
|
||||
validateName(name, seen);
|
||||
entries.push({ name, method, crc32: crc, compressedSize, uncompressedSize, localOffset });
|
||||
cursor = recordEnd;
|
||||
}
|
||||
if (cursor !== eocdOffset) throw new Error("unexpected bytes in ZIP central directory");
|
||||
const canonicalSize = preflightArchiveMemberSizes(new Map(entries.map((entry) => [entry.name, entry.uncompressedSize])), limits);
|
||||
const occupied: [number, number][] = [];
|
||||
const payloadRanges: { entry: Entry; start: number; end: number }[] = [];
|
||||
let totalUncompressed = 0;
|
||||
for (const entry of entries) {
|
||||
const offset = entry.localOffset;
|
||||
if (offset + 30 > centralOffset) throw new Error("truncated ZIP local header");
|
||||
if (raw.readUInt32LE(offset) !== 0x04034b50) throw new Error("invalid ZIP local header signature");
|
||||
const needed = raw.readUInt16LE(offset + 4);
|
||||
const flags = raw.readUInt16LE(offset + 6);
|
||||
const method = raw.readUInt16LE(offset + 8);
|
||||
const dosTime = raw.readUInt16LE(offset + 10);
|
||||
const dosDate = raw.readUInt16LE(offset + 12);
|
||||
const crc = raw.readUInt32LE(offset + 14);
|
||||
const compressedSize = raw.readUInt32LE(offset + 18);
|
||||
const uncompressedSize = raw.readUInt32LE(offset + 22);
|
||||
const nameLength = raw.readUInt16LE(offset + 26);
|
||||
const extraLength = raw.readUInt16LE(offset + 28);
|
||||
if (needed !== 20 || flags !== 0x0800 || method !== entry.method || dosTime !== 0 || dosDate !== 0x0021 || crc !== entry.crc32 || compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize || extraLength !== 0) {
|
||||
throw new Error("ZIP central/local header mismatch");
|
||||
}
|
||||
const nameStart = offset + 30;
|
||||
const nameEnd = nameStart + nameLength;
|
||||
const payloadEnd = nameEnd + compressedSize;
|
||||
if (payloadEnd > centralOffset) throw new Error("ZIP member payload crosses central directory");
|
||||
if (ascii(raw.subarray(nameStart, nameEnd), "local name") !== entry.name) throw new Error("ZIP central/local member name mismatch");
|
||||
occupied.push([offset, payloadEnd]);
|
||||
payloadRanges.push({ entry, start: nameEnd, end: payloadEnd });
|
||||
totalUncompressed += uncompressedSize;
|
||||
}
|
||||
occupied.sort((left, right) => left[0] - right[0]);
|
||||
if (occupied.length > 0) {
|
||||
if (occupied[0][0] !== 0) throw new Error("ZIP archive has a leading prefix");
|
||||
for (let index = 1; index < occupied.length; index += 1) {
|
||||
if (occupied[index - 1][1] !== occupied[index][0]) throw new Error("ZIP local records overlap or contain gaps");
|
||||
}
|
||||
if (occupied.at(-1)![1] !== centralOffset) throw new Error("ZIP bytes exist between local records and central directory");
|
||||
}
|
||||
preflightAggregateCompressionRatio(totalUncompressed, raw.length, limits);
|
||||
|
||||
const members = new Map<string, Buffer>();
|
||||
for (const { entry, start, end } of payloadRanges) {
|
||||
const compressed = raw.subarray(start, end);
|
||||
try {
|
||||
preflightMemberCompressionRatio(entry.uncompressedSize, entry.compressedSize, limits);
|
||||
} catch {
|
||||
throw new Error(`member compression ratio exceeds limit: ${JSON.stringify(entry.name)}`);
|
||||
}
|
||||
let payload: Buffer;
|
||||
if (entry.method === 0) {
|
||||
payload = Buffer.from(compressed);
|
||||
} else {
|
||||
try {
|
||||
const inflated = inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1, info: true }) as unknown as {
|
||||
buffer: Buffer;
|
||||
engine: { bytesWritten: number };
|
||||
};
|
||||
if (inflated.engine.bytesWritten !== compressed.length) throw new Error("trailing deflate data");
|
||||
payload = Buffer.from(inflated.buffer);
|
||||
} catch {
|
||||
throw new Error(`invalid deflate stream: ${JSON.stringify(entry.name)}`);
|
||||
}
|
||||
}
|
||||
if (payload.length !== entry.uncompressedSize) throw new Error(`ZIP member decoded size mismatch: ${JSON.stringify(entry.name)}`);
|
||||
if (crc32(payload) !== entry.crc32) throw new Error(`ZIP member CRC mismatch: ${JSON.stringify(entry.name)}`);
|
||||
members.set(entry.name, payload);
|
||||
}
|
||||
return { members, inputSize: raw.length, canonicalSize, usedDeflate: entries.some((entry) => entry.method === 8) };
|
||||
}
|
||||
Reference in New Issue
Block a user