Files
workinf_Blender_Wasm/web/protocol/geometry-stream.ts
2026-08-14 22:32:09 -04:00

211 lines
8.8 KiB
TypeScript

export const MESH_GEOMETRY_STREAM_SCHEMA = 1 as const;
export const MESH_GEOMETRY_STREAM_MAX_TRIANGLES = 20_000_000;
export const MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES = 500_000;
export const MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES = 100_000;
export interface MeshGeometryStreamRequestIR {
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
streamId: string;
meshId: string;
triangleCount: number;
chunkTriangleCount: number;
lodTriangleCount: number;
}
export interface MeshGeometryStreamChunkIR {
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
streamId: string;
meshId: string;
chunkIndex: number;
chunkCount: number;
triangleOffset: number;
triangleCount: number;
vertexCount: number;
byteLength: number;
positions: ArrayBuffer;
indices: ArrayBuffer;
sha256: string;
}
export interface MeshGeometryStreamChunkRecordIR {
chunkIndex: number;
triangleOffset: number;
triangleCount: number;
vertexCount: number;
byteLength: number;
sha256: string;
}
export interface MeshGeometryStreamReportIR {
streamId: string;
meshId: string;
status: "COMPLETED" | "CANCELLED";
triangleCount: number;
chunkCount: number;
transferredBytes: number;
peakWorkingSetBytes: number;
manifestSha256?: string;
}
export type MeshGeometryStreamWorkerRequest =
| { type: "start"; request: MeshGeometryStreamRequestIR }
| { type: "ack"; streamId: string; chunkIndex: number }
| { type: "cancel"; streamId: string };
export type MeshGeometryStreamWorkerResponse =
| { type: "lod"; streamId: string; geometry: MeshGeometryStreamChunkIR }
| { type: "chunk"; streamId: string; chunk: MeshGeometryStreamChunkIR }
| { type: "detached"; streamId: string; chunkIndex: number; positionsByteLength: number; indicesByteLength: number }
| { type: "complete"; report: MeshGeometryStreamReportIR; chunks: MeshGeometryStreamChunkRecordIR[] }
| { type: "cancelled"; report: MeshGeometryStreamReportIR }
| { type: "error"; streamId?: string; code: string; message: string };
export class MeshGeometryStreamError extends Error {
constructor(readonly code: "GEOMETRY_STREAM_INVALID" | "GEOMETRY_STREAM_RANGE_INVALID" | "GEOMETRY_STREAM_BUDGET_EXCEEDED", message: string) {
super(`${code}: ${message}`);
this.name = "MeshGeometryStreamError";
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} must be an integer in [${minimum}, ${maximum}]`);
}
return value as number;
}
function identifier(value: unknown, name: string): string {
if (typeof value !== "string" || !/^[A-Za-z0-9:._-]{1,128}$/.test(value)) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} is invalid`);
}
return value;
}
export function parseMeshGeometryStreamRequest(value: unknown): MeshGeometryStreamRequestIR {
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "unsupported stream request schema");
}
const request: MeshGeometryStreamRequestIR = {
schemaVersion: MESH_GEOMETRY_STREAM_SCHEMA,
streamId: identifier(value.streamId, "streamId"),
meshId: identifier(value.meshId, "meshId"),
triangleCount: boundedInteger(value.triangleCount, "triangleCount", 1, MESH_GEOMETRY_STREAM_MAX_TRIANGLES),
chunkTriangleCount: boundedInteger(value.chunkTriangleCount, "chunkTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES),
lodTriangleCount: boundedInteger(value.lodTriangleCount, "lodTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES),
};
if (request.lodTriangleCount > request.triangleCount) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "lodTriangleCount exceeds triangleCount");
}
return request;
}
function hex(bytes: ArrayBuffer): string {
return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join("");
}
async function sha256(value: BufferSource): Promise<string> {
return hex(await crypto.subtle.digest("SHA-256", value));
}
export async function meshGeometryStreamChunkSha256(chunk: Omit<MeshGeometryStreamChunkIR, "sha256">): Promise<string> {
const metadata = JSON.stringify([
chunk.schemaVersion,
chunk.streamId,
chunk.meshId,
chunk.chunkIndex,
chunk.chunkCount,
chunk.triangleOffset,
chunk.triangleCount,
chunk.vertexCount,
chunk.byteLength,
await sha256(chunk.positions),
await sha256(chunk.indices),
]);
return sha256(new TextEncoder().encode(metadata));
}
export async function meshGeometryStreamManifestSha256(
request: MeshGeometryStreamRequestIR,
chunks: readonly MeshGeometryStreamChunkRecordIR[],
): Promise<string> {
const canonical = JSON.stringify({
schemaVersion: request.schemaVersion,
streamId: request.streamId,
meshId: request.meshId,
triangleCount: request.triangleCount,
chunkTriangleCount: request.chunkTriangleCount,
lodTriangleCount: request.lodTriangleCount,
chunks,
});
return sha256(new TextEncoder().encode(canonical));
}
export class MeshGeometryStreamValidator {
private readonly request: MeshGeometryStreamRequestIR;
private readonly expectedChunkCount: number;
private nextChunkIndex = 0;
private nextTriangleOffset = 0;
private transferredBytes = 0;
private peakWorkingSetBytes = 0;
private readonly chunks: MeshGeometryStreamChunkRecordIR[] = [];
constructor(value: MeshGeometryStreamRequestIR) {
this.request = parseMeshGeometryStreamRequest(value);
this.expectedChunkCount = Math.ceil(this.request.triangleCount / this.request.chunkTriangleCount);
}
async accept(value: MeshGeometryStreamChunkIR): Promise<void> {
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA || value.streamId !== this.request.streamId || value.meshId !== this.request.meshId) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk envelope does not match the active stream");
}
const expectedTriangles = Math.min(this.request.chunkTriangleCount, this.request.triangleCount - this.nextTriangleOffset);
if (value.chunkIndex !== this.nextChunkIndex || value.chunkCount !== this.expectedChunkCount || value.triangleOffset !== this.nextTriangleOffset || value.triangleCount !== expectedTriangles) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk ranges are not contiguous and complete");
}
if (!(value.positions instanceof ArrayBuffer) || !(value.indices instanceof ArrayBuffer) ||
!Number.isSafeInteger(value.vertexCount) || value.vertexCount < 3 ||
value.positions.byteLength !== value.vertexCount * 3 * Float32Array.BYTES_PER_ELEMENT ||
value.indices.byteLength !== value.triangleCount * 3 * Uint32Array.BYTES_PER_ELEMENT ||
value.byteLength !== value.positions.byteLength + value.indices.byteLength) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk binary lengths are invalid");
}
const indices = new Uint32Array(value.indices);
for (const index of indices) {
if (index >= value.vertexCount) throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk index exceeds its local vertex range");
}
const { sha256: declaredSha256, ...unsigned } = value;
const digest = await meshGeometryStreamChunkSha256(unsigned);
if (!/^[a-f0-9]{64}$/.test(declaredSha256) || digest !== declaredSha256) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk SHA-256 mismatch");
}
this.chunks.push({
chunkIndex: value.chunkIndex,
triangleOffset: value.triangleOffset,
triangleCount: value.triangleCount,
vertexCount: value.vertexCount,
byteLength: value.byteLength,
sha256: declaredSha256,
});
this.nextChunkIndex += 1;
this.nextTriangleOffset += value.triangleCount;
this.transferredBytes += value.byteLength;
this.peakWorkingSetBytes = Math.max(this.peakWorkingSetBytes, value.byteLength);
}
async finish(): Promise<{ chunks: MeshGeometryStreamChunkRecordIR[]; transferredBytes: number; peakWorkingSetBytes: number; manifestSha256: string }> {
if (this.nextChunkIndex !== this.expectedChunkCount || this.nextTriangleOffset !== this.request.triangleCount) {
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "stream ended before all triangle ranges arrived");
}
return {
chunks: [...this.chunks],
transferredBytes: this.transferredBytes,
peakWorkingSetBytes: this.peakWorkingSetBytes,
manifestSha256: await meshGeometryStreamManifestSha256(this.request, this.chunks),
};
}
}