feat: integrate SimpleCADAPI 2.0.2 CAD workflows

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