211 lines
8.8 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|