414 lines
24 KiB
TypeScript
414 lines
24 KiB
TypeScript
import type {
|
|
NonMeshAttributeDataType,
|
|
NonMeshAttributeDomain,
|
|
NonMeshAttributeIR,
|
|
} from "./scene-ir";
|
|
|
|
export const NON_MESH_BINARY_SCHEMA = 1 as const;
|
|
export const NON_MESH_MAX_POINTS = 1_000_000;
|
|
export const NON_MESH_DEFAULT_CHUNK_POINTS = 65_536;
|
|
export const NON_MESH_MAX_SCENE_BYTES = 256 * 1024 * 1024;
|
|
|
|
export type NonMeshAttributeArray = Float32Array | Int32Array | Uint8Array;
|
|
|
|
export interface NonMeshAttributeSource extends NonMeshAttributeIR {
|
|
values: NonMeshAttributeArray;
|
|
}
|
|
|
|
export interface NonMeshGeometrySource {
|
|
dataId: string;
|
|
positions: Float32Array;
|
|
radii?: Float32Array;
|
|
curveOffsets?: Uint32Array;
|
|
attributes?: NonMeshAttributeSource[];
|
|
}
|
|
|
|
export interface NonMeshAttributeChunk extends NonMeshAttributeIR {
|
|
storage: "FLOAT32" | "INT32" | "UINT8";
|
|
elementOffset: number;
|
|
elementCount: number;
|
|
data: ArrayBuffer;
|
|
}
|
|
|
|
export interface NonMeshGeometryChunk {
|
|
schemaVersion: typeof NON_MESH_BINARY_SCHEMA;
|
|
dataId: string;
|
|
chunkIndex: number;
|
|
chunkCount: number;
|
|
pointOffset: number;
|
|
pointCount: number;
|
|
totalPointCount: number;
|
|
byteLength: number;
|
|
sha256: string;
|
|
positions: ArrayBuffer;
|
|
radii?: ArrayBuffer;
|
|
curveOffsets?: ArrayBuffer;
|
|
attributes: NonMeshAttributeChunk[];
|
|
}
|
|
|
|
export interface NonMeshBinaryBudget {
|
|
maxPoints: number;
|
|
maxChunkPoints: number;
|
|
maxSceneBytes: number;
|
|
}
|
|
|
|
export interface NonMeshPerformanceMetrics {
|
|
pointCount: number;
|
|
chunkCount: number;
|
|
transferredBytes: number;
|
|
elapsedMs: number;
|
|
peakBytes: number;
|
|
}
|
|
|
|
export interface NonMeshPerformanceGate {
|
|
status: "READY" | "BLOCKED";
|
|
code?: "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_PERFORMANCE_BUDGET_EXCEEDED";
|
|
metrics: NonMeshPerformanceMetrics;
|
|
}
|
|
|
|
export interface ReassembledNonMeshAttribute extends NonMeshAttributeIR {
|
|
storage: NonMeshAttributeChunk["storage"];
|
|
values: NonMeshAttributeArray;
|
|
}
|
|
|
|
export interface ReassembledNonMeshGeometry {
|
|
dataId: string;
|
|
positions: Float32Array;
|
|
radii?: Float32Array;
|
|
curveOffsets?: Uint32Array;
|
|
attributes: ReassembledNonMeshAttribute[];
|
|
}
|
|
|
|
const defaultBudget: NonMeshBinaryBudget = {
|
|
maxPoints: NON_MESH_MAX_POINTS,
|
|
maxChunkPoints: NON_MESH_DEFAULT_CHUNK_POINTS,
|
|
maxSceneBytes: NON_MESH_MAX_SCENE_BYTES,
|
|
};
|
|
|
|
function fail(message: string): never {
|
|
throw new Error(`NON_MESH_BINARY_INVALID: ${message}`);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isSafeNonNegativeInteger(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
|
|
function isArrayBuffer(value: unknown): value is ArrayBuffer {
|
|
return value instanceof ArrayBuffer;
|
|
}
|
|
|
|
function storageFor(dataType: NonMeshAttributeDataType): NonMeshAttributeChunk["storage"] {
|
|
if (dataType === "BOOL" || dataType === "BYTE_COLOR") return "UINT8";
|
|
if (dataType === "INT") return "INT32";
|
|
return "FLOAT32";
|
|
}
|
|
|
|
function bytesPerScalar(storage: NonMeshAttributeChunk["storage"]): number {
|
|
return storage === "UINT8" ? 1 : 4;
|
|
}
|
|
|
|
function domainSize(domain: NonMeshAttributeDomain, pointCount: number, curveCount: number): number {
|
|
if (domain === "POINT") return pointCount;
|
|
if (domain === "CURVE") return curveCount;
|
|
return 1;
|
|
}
|
|
|
|
function exactArrayType(source: NonMeshAttributeSource): boolean {
|
|
const storage = storageFor(source.dataType);
|
|
return (storage === "FLOAT32" && source.values instanceof Float32Array) ||
|
|
(storage === "INT32" && source.values instanceof Int32Array) ||
|
|
(storage === "UINT8" && source.values instanceof Uint8Array);
|
|
}
|
|
|
|
function validateSource(source: NonMeshGeometrySource, budget: NonMeshBinaryBudget): number {
|
|
if (!source.dataId) fail("dataId is required");
|
|
if (!(source.positions instanceof Float32Array) || source.positions.length % 3 !== 0) fail(`${source.dataId} positions must be float32 vec3 values`);
|
|
const pointCount = source.positions.length / 3;
|
|
if (pointCount > budget.maxPoints) fail(`${source.dataId} exceeds the ${budget.maxPoints} point budget`);
|
|
if (source.positions.some((value) => !Number.isFinite(value))) fail(`${source.dataId} positions contain NaN or Infinity`);
|
|
if (source.radii && (!(source.radii instanceof Float32Array) || source.radii.length !== pointCount || source.radii.some((value) => !Number.isFinite(value) || value < 0))) fail(`${source.dataId} radii do not match the point domain`);
|
|
let curveCount = 0;
|
|
if (source.curveOffsets) {
|
|
const offsets = source.curveOffsets;
|
|
curveCount = Math.max(0, offsets.length - 1);
|
|
if (!(offsets instanceof Uint32Array) || offsets.length < 2 || offsets[0] !== 0 || offsets[offsets.length - 1] !== pointCount) fail(`${source.dataId} curve offsets do not cover the point domain`);
|
|
for (let index = 1; index < offsets.length; index++) if (offsets[index] <= offsets[index - 1]) fail(`${source.dataId} curve offsets must be strictly increasing`);
|
|
}
|
|
const names = new Set<string>();
|
|
for (const attribute of source.attributes ?? []) {
|
|
if (!attribute.name || names.has(attribute.name)) fail(`${source.dataId} has a missing or duplicate attribute name`);
|
|
names.add(attribute.name);
|
|
if (![1, 2, 3, 4].includes(attribute.components)) fail(`${source.dataId}.${attribute.name} has an invalid component count`);
|
|
if (!exactArrayType(attribute)) fail(`${source.dataId}.${attribute.name} does not use the required scalar storage`);
|
|
const elements = domainSize(attribute.domain, pointCount, curveCount);
|
|
if (attribute.values.length !== elements * attribute.components) fail(`${source.dataId}.${attribute.name} does not match its ${attribute.domain} domain`);
|
|
if (attribute.values.some((value) => !Number.isFinite(value))) fail(`${source.dataId}.${attribute.name} contains NaN or Infinity`);
|
|
}
|
|
return pointCount;
|
|
}
|
|
|
|
function copyRange(values: NonMeshAttributeArray | Float32Array | Uint32Array, scalarStart: number, scalarCount: number): ArrayBuffer {
|
|
const bytesPerValue = values.BYTES_PER_ELEMENT;
|
|
const byteStart = values.byteOffset + scalarStart * bytesPerValue;
|
|
const output = new Uint8Array(scalarCount * bytesPerValue);
|
|
output.set(new Uint8Array(values.buffer, byteStart, output.byteLength));
|
|
return output.buffer;
|
|
}
|
|
|
|
function hex(bytes: Uint8Array): string {
|
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
async function digestChunk(chunk: Omit<NonMeshGeometryChunk, "sha256">): Promise<string> {
|
|
const subtle = globalThis.crypto?.subtle;
|
|
if (!subtle) throw new Error("NON_MESH_BINARY_INVALID: SHA-256 is unavailable");
|
|
const header = new TextEncoder().encode(JSON.stringify({
|
|
schemaVersion: chunk.schemaVersion,
|
|
dataId: chunk.dataId,
|
|
chunkIndex: chunk.chunkIndex,
|
|
chunkCount: chunk.chunkCount,
|
|
pointOffset: chunk.pointOffset,
|
|
pointCount: chunk.pointCount,
|
|
totalPointCount: chunk.totalPointCount,
|
|
attributes: chunk.attributes.map(({ name, domain, dataType, components, storage, elementOffset, elementCount }) => ({ name, domain, dataType, components, storage, elementOffset, elementCount })),
|
|
}));
|
|
const buffers = [chunk.positions, chunk.radii, chunk.curveOffsets, ...chunk.attributes.map((attribute) => attribute.data)].filter((value): value is ArrayBuffer => value instanceof ArrayBuffer);
|
|
const bytes = new Uint8Array(header.byteLength + buffers.reduce((total, buffer) => total + buffer.byteLength, 0));
|
|
bytes.set(header);
|
|
let offset = header.byteLength;
|
|
for (const buffer of buffers) {
|
|
bytes.set(new Uint8Array(buffer), offset);
|
|
offset += buffer.byteLength;
|
|
}
|
|
return hex(new Uint8Array(await subtle.digest("SHA-256", bytes)));
|
|
}
|
|
|
|
export async function chunkNonMeshGeometry(
|
|
source: NonMeshGeometrySource,
|
|
options: Partial<NonMeshBinaryBudget> = {},
|
|
): Promise<NonMeshGeometryChunk[]> {
|
|
const budget = { ...defaultBudget, ...options };
|
|
if (!Number.isSafeInteger(budget.maxPoints) || budget.maxPoints <= 0 || !Number.isSafeInteger(budget.maxChunkPoints) || budget.maxChunkPoints <= 0 || !Number.isSafeInteger(budget.maxSceneBytes) || budget.maxSceneBytes <= 0) fail("binary budget is invalid");
|
|
const totalPointCount = validateSource(source, budget);
|
|
const chunkCount = Math.max(1, Math.ceil(totalPointCount / budget.maxChunkPoints));
|
|
const curveCount = Math.max(0, (source.curveOffsets?.length ?? 1) - 1);
|
|
const chunks: NonMeshGeometryChunk[] = [];
|
|
let totalBytes = 0;
|
|
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {
|
|
const pointOffset = chunkIndex * budget.maxChunkPoints;
|
|
const pointCount = Math.min(budget.maxChunkPoints, totalPointCount - pointOffset);
|
|
const positions = copyRange(source.positions, pointOffset * 3, pointCount * 3);
|
|
const radii = source.radii ? copyRange(source.radii, pointOffset, pointCount) : undefined;
|
|
const curveOffsets = chunkIndex === 0 && source.curveOffsets ? copyRange(source.curveOffsets, 0, source.curveOffsets.length) : undefined;
|
|
const attributes = (source.attributes ?? []).flatMap((attribute): NonMeshAttributeChunk[] => {
|
|
const storage = storageFor(attribute.dataType);
|
|
const isPoint = attribute.domain === "POINT";
|
|
if (!isPoint && chunkIndex !== 0) return [];
|
|
const elementOffset = isPoint ? pointOffset : 0;
|
|
const elementCount = isPoint ? pointCount : domainSize(attribute.domain, totalPointCount, curveCount);
|
|
return [{
|
|
name: attribute.name,
|
|
domain: attribute.domain,
|
|
dataType: attribute.dataType,
|
|
components: attribute.components,
|
|
storage,
|
|
elementOffset,
|
|
elementCount,
|
|
data: copyRange(attribute.values, elementOffset * attribute.components, elementCount * attribute.components),
|
|
}];
|
|
});
|
|
const byteLength = positions.byteLength + (radii?.byteLength ?? 0) + (curveOffsets?.byteLength ?? 0) + attributes.reduce((total, attribute) => total + attribute.data.byteLength, 0);
|
|
totalBytes += byteLength;
|
|
if (totalBytes > budget.maxSceneBytes) fail(`${source.dataId} exceeds the ${budget.maxSceneBytes} byte scene budget`);
|
|
const unsigned = { schemaVersion: NON_MESH_BINARY_SCHEMA, dataId: source.dataId, chunkIndex, chunkCount, pointOffset, pointCount, totalPointCount, byteLength, positions, radii, curveOffsets, attributes };
|
|
chunks.push({ ...unsigned, sha256: await digestChunk(unsigned) });
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export async function validateNonMeshGeometryChunks(
|
|
chunks: readonly unknown[],
|
|
options: Partial<NonMeshBinaryBudget> = {},
|
|
): Promise<void> {
|
|
const budget = { ...defaultBudget, ...options };
|
|
if (!Array.isArray(chunks) || chunks.length === 0) fail("at least one chunk is required");
|
|
if (!Number.isSafeInteger(budget.maxPoints) || budget.maxPoints <= 0 ||
|
|
!Number.isSafeInteger(budget.maxChunkPoints) || budget.maxChunkPoints <= 0 ||
|
|
!Number.isSafeInteger(budget.maxSceneBytes) || budget.maxSceneBytes <= 0) fail("binary budget is invalid");
|
|
const first = chunks[0];
|
|
if (!isRecord(first) || typeof first.dataId !== "string" || first.dataId.length === 0 || first.dataId.length > 256) fail("chunk dataId is invalid");
|
|
const dataId = first.dataId;
|
|
const totalPointCount = first.totalPointCount;
|
|
if (!isSafeNonNegativeInteger(totalPointCount) || totalPointCount > budget.maxPoints) fail(`${dataId} total point count exceeds its declared budget`);
|
|
let expectedPointOffset = 0;
|
|
let totalBytes = 0;
|
|
for (const [index, rawChunk] of chunks.entries()) {
|
|
if (!isRecord(rawChunk)) fail(`${dataId} chunk ${index} is invalid`);
|
|
const chunk = rawChunk;
|
|
if (chunk.schemaVersion !== NON_MESH_BINARY_SCHEMA || chunk.dataId !== dataId ||
|
|
!isSafeNonNegativeInteger(chunk.chunkIndex) || chunk.chunkIndex !== index ||
|
|
!isSafeNonNegativeInteger(chunk.chunkCount) || chunk.chunkCount !== chunks.length ||
|
|
chunk.chunkCount === 0 || !isSafeNonNegativeInteger(chunk.totalPointCount) ||
|
|
chunk.totalPointCount !== totalPointCount || !isSafeNonNegativeInteger(chunk.pointOffset) ||
|
|
chunk.pointOffset !== expectedPointOffset) fail(`${dataId} chunk sequence is inconsistent`);
|
|
if (!isSafeNonNegativeInteger(chunk.pointCount) || chunk.pointCount > budget.maxChunkPoints ||
|
|
chunk.pointOffset > Number.MAX_SAFE_INTEGER - chunk.pointCount ||
|
|
!isArrayBuffer(chunk.positions) || chunk.positions.byteLength !== chunk.pointCount * 3 * 4) fail(`${dataId} chunk ${index} point payload is invalid`);
|
|
const positions = chunk.positions;
|
|
const radii = chunk.radii;
|
|
if (radii !== undefined && (!isArrayBuffer(radii) || radii.byteLength !== chunk.pointCount * 4)) fail(`${dataId} chunk ${index} radius payload is invalid`);
|
|
const curveOffsets = chunk.curveOffsets;
|
|
if (curveOffsets !== undefined) {
|
|
if (!isArrayBuffer(curveOffsets) || index !== 0 || curveOffsets.byteLength < 8 || curveOffsets.byteLength % 4 !== 0) fail(`${dataId} curve offsets are only allowed in the first chunk`);
|
|
const offsets = new Uint32Array(curveOffsets);
|
|
if (offsets[0] !== 0 || offsets[offsets.length - 1] !== totalPointCount) fail(`${dataId} curve offsets do not cover the point domain`);
|
|
for (let offsetIndex = 1; offsetIndex < offsets.length; offsetIndex++) {
|
|
if (offsets[offsetIndex] <= offsets[offsetIndex - 1] || offsets[offsetIndex] > totalPointCount) fail(`${dataId} curve offsets are invalid`);
|
|
}
|
|
}
|
|
if (!Array.isArray(chunk.attributes)) fail(`${dataId} chunk ${index} attributes must be an array`);
|
|
const attributes = chunk.attributes;
|
|
const attributeNames = new Set<string>();
|
|
for (const rawAttribute of attributes) {
|
|
if (!isRecord(rawAttribute)) fail(`${dataId} chunk ${index} has an invalid attribute`);
|
|
const attribute = rawAttribute;
|
|
const attributeName = attribute.name;
|
|
if (typeof attributeName !== "string" || attributeName.length === 0 || attributeNames.has(attributeName)) fail(`${dataId} chunk ${index} has a duplicate or invalid attribute`);
|
|
attributeNames.add(attributeName);
|
|
if (!(["POINT", "CURVE", "INSTANCE"].includes(attribute.domain as string)) ||
|
|
!(["FLOAT", "FLOAT2", "FLOAT3", "FLOAT_COLOR", "INT", "BOOL", "BYTE_COLOR"].includes(attribute.dataType as string)) ||
|
|
!(["FLOAT32", "INT32", "UINT8"].includes(attribute.storage as string)) ||
|
|
attribute.storage !== storageFor(attribute.dataType as NonMeshAttributeDataType) ||
|
|
!isSafeNonNegativeInteger(attribute.components) || attribute.components < 1 || attribute.components > 4 ||
|
|
!isSafeNonNegativeInteger(attribute.elementOffset) || !isSafeNonNegativeInteger(attribute.elementCount) ||
|
|
!isArrayBuffer(attribute.data)) fail(`${dataId}.${attributeName} chunk metadata is invalid`);
|
|
const scalarBytes = bytesPerScalar(attribute.storage as NonMeshAttributeChunk["storage"]);
|
|
if (attribute.elementCount > Math.floor(Number.MAX_SAFE_INTEGER / attribute.components / scalarBytes)) fail(`${dataId}.${attributeName} chunk scalar count overflows the safe integer range`);
|
|
const expectedBytes = attribute.elementCount * attribute.components * scalarBytes;
|
|
if (attribute.data.byteLength !== expectedBytes) fail(`${dataId}.${attributeName} chunk payload is invalid`);
|
|
if (attribute.domain === "POINT" && (attribute.elementOffset !== chunk.pointOffset || attribute.elementCount !== chunk.pointCount)) fail(`${dataId}.${attributeName} point attribute range is invalid`);
|
|
if (attribute.domain !== "POINT" && index !== 0) fail(`${dataId}.${attributeName} curve/instance attributes must be transferred once`);
|
|
}
|
|
const measuredBytes = positions.byteLength + (radii?.byteLength ?? 0) + (curveOffsets?.byteLength ?? 0) + attributes.reduce((total, attribute) => total + (attribute as Record<string, ArrayBuffer>).data.byteLength, 0);
|
|
if (!isSafeNonNegativeInteger(chunk.byteLength) || !isSafeNonNegativeInteger(measuredBytes) || chunk.byteLength !== measuredBytes || typeof chunk.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(chunk.sha256) || await digestChunk(chunk as unknown as NonMeshGeometryChunk) !== chunk.sha256) fail(`${dataId} chunk ${index} hash or length is invalid`);
|
|
totalBytes += measuredBytes;
|
|
if (!Number.isSafeInteger(totalBytes)) fail(`${dataId} chunk bytes overflow the safe integer range`);
|
|
expectedPointOffset += chunk.pointCount;
|
|
}
|
|
if (expectedPointOffset !== totalPointCount || !Number.isSafeInteger(expectedPointOffset) || totalBytes > budget.maxSceneBytes) fail(`${dataId} chunk set exceeds its declared budget`);
|
|
}
|
|
|
|
export async function chunkNonMeshScene(
|
|
sources: readonly NonMeshGeometrySource[],
|
|
options: Partial<NonMeshBinaryBudget> = {},
|
|
): Promise<NonMeshGeometryChunk[]> {
|
|
const budget = { ...defaultBudget, ...options };
|
|
const ids = new Set<string>();
|
|
let sceneBytes = 0;
|
|
const chunks: NonMeshGeometryChunk[] = [];
|
|
for (const source of sources) {
|
|
if (ids.has(source.dataId)) fail(`duplicate dataId ${source.dataId}`);
|
|
ids.add(source.dataId);
|
|
const next = await chunkNonMeshGeometry(source, budget);
|
|
await validateNonMeshGeometryChunks(next, budget);
|
|
sceneBytes += next.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
if (sceneBytes > budget.maxSceneBytes) fail(`scene exceeds the ${budget.maxSceneBytes} byte scene budget`);
|
|
chunks.push(...next);
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export async function benchmarkNonMeshTransfer(
|
|
source: NonMeshGeometrySource,
|
|
options: Partial<NonMeshBinaryBudget> = {},
|
|
): Promise<NonMeshPerformanceGate> {
|
|
const start = globalThis.performance?.now() ?? Date.now();
|
|
const chunks = await chunkNonMeshGeometry(source, options);
|
|
await validateNonMeshGeometryChunks(chunks, options);
|
|
const elapsedMs = (globalThis.performance?.now() ?? Date.now()) - start;
|
|
const transferredBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
const sourceBytes = source.positions.byteLength + (source.radii?.byteLength ?? 0) + (source.curveOffsets?.byteLength ?? 0) +
|
|
(source.attributes ?? []).reduce((total, attribute) => total + attribute.values.byteLength, 0);
|
|
return evaluateNonMeshPerformanceGate({
|
|
pointCount: source.positions.length / 3,
|
|
chunkCount: chunks.length,
|
|
transferredBytes,
|
|
elapsedMs,
|
|
peakBytes: sourceBytes + transferredBytes,
|
|
});
|
|
}
|
|
|
|
export function nonMeshChunkTransferables(chunks: readonly NonMeshGeometryChunk[]): Transferable[] {
|
|
return chunks.flatMap((chunk) => [chunk.positions, chunk.radii, chunk.curveOffsets, ...chunk.attributes.map((attribute) => attribute.data)].filter((value): value is ArrayBuffer => value instanceof ArrayBuffer));
|
|
}
|
|
|
|
function attributeArray(storage: NonMeshAttributeChunk["storage"], length: number): NonMeshAttributeArray {
|
|
return storage === "FLOAT32" ? new Float32Array(length) : storage === "INT32" ? new Int32Array(length) : new Uint8Array(length);
|
|
}
|
|
|
|
function chunkAttributeValues(attribute: NonMeshAttributeChunk): NonMeshAttributeArray {
|
|
return attribute.storage === "FLOAT32" ? new Float32Array(attribute.data) : attribute.storage === "INT32" ? new Int32Array(attribute.data) : new Uint8Array(attribute.data);
|
|
}
|
|
|
|
/** Reassembles already-transferred WNM chunks while rechecking all cross-chunk invariants synchronously. */
|
|
export function reassembleNonMeshGeometry(dataId: string, chunks: readonly NonMeshGeometryChunk[]): ReassembledNonMeshGeometry {
|
|
const matching = chunks.filter((chunk) => chunk.dataId === dataId).sort((left, right) => left.chunkIndex - right.chunkIndex);
|
|
if (matching.length === 0) fail(`${dataId} has no geometry chunks`);
|
|
const first = matching[0];
|
|
if (first.totalPointCount > NON_MESH_MAX_POINTS || matching.length !== first.chunkCount) fail(`${dataId} chunk set is incomplete`);
|
|
const positions = new Float32Array(first.totalPointCount * 3);
|
|
const radii = matching.some((chunk) => chunk.radii) ? new Float32Array(first.totalPointCount) : undefined;
|
|
const descriptors = new Map<string, ReassembledNonMeshAttribute>();
|
|
let expectedPointAttributes: Set<string> | undefined;
|
|
let pointOffset = 0;
|
|
let totalBytes = 0;
|
|
for (const [chunkIndex, chunk] of matching.entries()) {
|
|
if (chunk.schemaVersion !== NON_MESH_BINARY_SCHEMA || chunk.chunkIndex !== chunkIndex || chunk.chunkCount !== matching.length ||
|
|
chunk.totalPointCount !== first.totalPointCount || chunk.pointOffset !== pointOffset || chunk.positions.byteLength !== chunk.pointCount * 12) fail(`${dataId} chunk sequence is inconsistent`);
|
|
positions.set(new Float32Array(chunk.positions), chunk.pointOffset * 3);
|
|
if (radii) {
|
|
if (!chunk.radii || chunk.radii.byteLength !== chunk.pointCount * 4) fail(`${dataId} radius chunks are incomplete`);
|
|
radii.set(new Float32Array(chunk.radii), chunk.pointOffset);
|
|
}
|
|
const pointAttributes = new Set(chunk.attributes.filter((attribute) => attribute.domain === "POINT").map((attribute) => `${attribute.domain}\0${attribute.name}`));
|
|
if (!expectedPointAttributes) expectedPointAttributes = pointAttributes;
|
|
else if (pointAttributes.size !== expectedPointAttributes.size || [...expectedPointAttributes].some((key) => !pointAttributes.has(key))) fail(`${dataId} point attribute chunks are incomplete`);
|
|
for (const attribute of chunk.attributes) {
|
|
const key = `${attribute.domain}\0${attribute.name}`;
|
|
let target = descriptors.get(key);
|
|
const scalarCount = attribute.elementCount * attribute.components;
|
|
if (!target) {
|
|
const totalElements = attribute.domain === "POINT" ? first.totalPointCount : attribute.elementCount;
|
|
target = { name: attribute.name, domain: attribute.domain, dataType: attribute.dataType, components: attribute.components, storage: attribute.storage, values: attributeArray(attribute.storage, totalElements * attribute.components) };
|
|
descriptors.set(key, target);
|
|
}
|
|
if (target.storage !== attribute.storage || target.dataType !== attribute.dataType || target.components !== attribute.components ||
|
|
attribute.data.byteLength !== scalarCount * bytesPerScalar(attribute.storage)) fail(`${dataId}.${attribute.name} chunks are inconsistent`);
|
|
target.values.set(chunkAttributeValues(attribute), attribute.elementOffset * attribute.components);
|
|
}
|
|
totalBytes += chunk.byteLength;
|
|
pointOffset += chunk.pointCount;
|
|
}
|
|
if (pointOffset !== first.totalPointCount || totalBytes > NON_MESH_MAX_SCENE_BYTES || positions.some((value) => !Number.isFinite(value)) || radii?.some((value) => !Number.isFinite(value) || value < 0)) fail(`${dataId} reassembled geometry is invalid`);
|
|
const curveOffsets = first.curveOffsets ? new Uint32Array(first.curveOffsets.slice(0)) : undefined;
|
|
if (curveOffsets) {
|
|
if (curveOffsets.length < 2 || curveOffsets[0] !== 0 || curveOffsets.at(-1) !== first.totalPointCount) fail(`${dataId} curve offsets do not cover the point domain`);
|
|
for (let index = 1; index < curveOffsets.length; index++) if (curveOffsets[index] <= curveOffsets[index - 1]) fail(`${dataId} curve offsets are invalid`);
|
|
}
|
|
return { dataId, positions, radii, curveOffsets, attributes: [...descriptors.values()] };
|
|
}
|
|
|
|
export function evaluateNonMeshPerformanceGate(metrics: NonMeshPerformanceMetrics): NonMeshPerformanceGate {
|
|
const valid = Object.values(metrics).every((value) => Number.isFinite(value) && value >= 0) &&
|
|
Number.isSafeInteger(metrics.pointCount) && Number.isSafeInteger(metrics.chunkCount) && Number.isSafeInteger(metrics.transferredBytes) && Number.isSafeInteger(metrics.peakBytes);
|
|
if (!valid || metrics.pointCount > NON_MESH_MAX_POINTS || metrics.transferredBytes > NON_MESH_MAX_SCENE_BYTES) return { status: "BLOCKED", code: "NON_MESH_DATA_BUDGET_EXCEEDED", metrics };
|
|
if (metrics.pointCount === NON_MESH_MAX_POINTS && (metrics.elapsedMs > 2_000 || metrics.peakBytes > 512 * 1024 * 1024 || metrics.chunkCount > Math.ceil(NON_MESH_MAX_POINTS / NON_MESH_DEFAULT_CHUNK_POINTS))) {
|
|
return { status: "BLOCKED", code: "NON_MESH_PERFORMANCE_BUDGET_EXCEEDED", metrics };
|
|
}
|
|
return { status: "READY", metrics };
|
|
}
|