import { PAINT_BUDGET } from "./paint"; export const PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION = 1 as const; export const PAINT_DEPTH_VISIBILITY_BUDGET = { maxVertexSamples: PAINT_BUDGET.maxSamples, maxReadbackBytes: PAINT_BUDGET.maxStrokeBytes, maxDimension: 4096, } as const; export type PaintDepthVisibilityBackend = "MAIN_THREAD_WEBGL2" | "OFFSCREEN_WEBGL2"; export interface PaintDepthVisibilityRequestIR { schemaVersion: typeof PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION; objectId: string; meshId: string; revision: number; vertexIndices: number[]; } export interface PaintDepthVisibilityResultIR extends PaintDepthVisibilityRequestIR { backend: PaintDepthVisibilityBackend; source: "GPU_RGBA_DEPTH_READBACK"; width: number; height: number; depthReadbackBytes: number; occluderPixelCount: number; visibleVertexIndices: number[]; } export type PaintDepthVisibilityErrorCode = | "PAINT_SCHEMA_INVALID" | "PAINT_BUDGET_EXCEEDED" | "PAINT_DEPTH_UNAVAILABLE" | "REVISION_CONFLICT"; export class PaintDepthVisibilityError extends Error { constructor(readonly code: PaintDepthVisibilityErrorCode, message: string) { super(`${code}: ${message}`); this.name = "PaintDepthVisibilityError"; } } const encoder = new TextEncoder(); const REQUEST_FIELDS = new Set(["schemaVersion", "objectId", "meshId", "revision", "vertexIndices"]); const RESULT_FIELDS = new Set([ ...REQUEST_FIELDS, "backend", "source", "width", "height", "depthReadbackBytes", "occluderPixelCount", "visibleVertexIndices", ]); function fail(code: PaintDepthVisibilityErrorCode, message: string): never { throw new PaintDepthVisibilityError(code, message); } function record(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`); return value as Record; } function exact(value: Record, fields: ReadonlySet, label: string): void { if (Object.keys(value).some((field) => !fields.has(field))) fail("PAINT_SCHEMA_INVALID", `${label} contains undeclared fields`); } function identity(value: unknown, prefix: "object:" | "mesh:", label: string): string { if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || encoder.encode(value).byteLength > 256) { fail("PAINT_SCHEMA_INVALID", `${label} is not a bounded ${prefix} identity`); } return value; } function nonNegativeInteger(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail("PAINT_SCHEMA_INVALID", `${label} must be a non-negative safe integer`); return value; } function vertexIndices(value: unknown, label: string): number[] { if (!Array.isArray(value) || value.length === 0) fail("PAINT_SCHEMA_INVALID", `${label} must contain at least one vertex`); if (value.length > PAINT_DEPTH_VISIBILITY_BUDGET.maxVertexSamples) fail("PAINT_BUDGET_EXCEEDED", `${label} exceeds the depth sample budget`); const parsed = value.map((item, index) => nonNegativeInteger(item, `${label}[${index}]`)); const unique = new Set(parsed); if (unique.size !== parsed.length) fail("PAINT_SCHEMA_INVALID", `${label} contains duplicate vertex identities`); return [...unique].sort((left, right) => left - right); } function resultVertexIndices(value: unknown): number[] { if (!Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", "visibleVertexIndices must be an array"); if (value.length === 0) return []; return vertexIndices(value, "visibleVertexIndices"); } export function validatePaintDepthVisibilityRequest(value: unknown, currentRevision: number): PaintDepthVisibilityRequestIR { const request = record(value, "Paint depth visibility request"); exact(request, REQUEST_FIELDS, "Paint depth visibility request"); if (request.schemaVersion !== PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint depth visibility schema is unsupported"); const revision = nonNegativeInteger(request.revision, "revision"); if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid"); if (revision !== currentRevision) fail("REVISION_CONFLICT", "Paint depth visibility request is stale"); return { schemaVersion: PAINT_DEPTH_VISIBILITY_SCHEMA_VERSION, objectId: identity(request.objectId, "object:", "objectId"), meshId: identity(request.meshId, "mesh:", "meshId"), revision, vertexIndices: vertexIndices(request.vertexIndices, "vertexIndices"), }; } export function validatePaintDepthVisibilityResult( value: unknown, requestValue: PaintDepthVisibilityRequestIR, ): PaintDepthVisibilityResultIR { const result = record(value, "Paint depth visibility result"); exact(result, RESULT_FIELDS, "Paint depth visibility result"); const request = validatePaintDepthVisibilityRequest({ schemaVersion: result.schemaVersion, objectId: result.objectId, meshId: result.meshId, revision: result.revision, vertexIndices: result.vertexIndices, }, requestValue.revision); if (request.objectId !== requestValue.objectId || request.meshId !== requestValue.meshId || request.vertexIndices.length !== requestValue.vertexIndices.length || request.vertexIndices.some((index, offset) => index !== requestValue.vertexIndices[offset])) { fail("PAINT_SCHEMA_INVALID", "Paint depth visibility result does not match its request"); } const backend = result.backend; if (backend !== "MAIN_THREAD_WEBGL2" && backend !== "OFFSCREEN_WEBGL2") fail("PAINT_SCHEMA_INVALID", "Paint depth backend is invalid"); if (result.source !== "GPU_RGBA_DEPTH_READBACK") fail("PAINT_SCHEMA_INVALID", "Paint depth source is invalid"); const width = nonNegativeInteger(result.width, "width"); const height = nonNegativeInteger(result.height, "height"); if (width < 1 || height < 1 || width > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension || height > PAINT_DEPTH_VISIBILITY_BUDGET.maxDimension) { fail("PAINT_BUDGET_EXCEEDED", "Paint depth dimensions exceed the readback budget"); } const depthReadbackBytes = nonNegativeInteger(result.depthReadbackBytes, "depthReadbackBytes"); if (depthReadbackBytes !== width * height * 4 || depthReadbackBytes > PAINT_DEPTH_VISIBILITY_BUDGET.maxReadbackBytes) { fail("PAINT_BUDGET_EXCEEDED", "Paint depth readback byte length is invalid"); } const occluderPixelCount = nonNegativeInteger(result.occluderPixelCount, "occluderPixelCount"); if (occluderPixelCount > width * height) fail("PAINT_SCHEMA_INVALID", "Paint depth occluder count exceeds the target"); const visibleVertexIndices = resultVertexIndices(result.visibleVertexIndices); const requested = new Set(request.vertexIndices); if (visibleVertexIndices.some((index) => !requested.has(index))) fail("PAINT_SCHEMA_INVALID", "Paint depth result contains an unrequested vertex"); return { ...request, backend, source: "GPU_RGBA_DEPTH_READBACK", width, height, depthReadbackBytes, occluderPixelCount, visibleVertexIndices, }; }