import { PAINT_BUDGET } from "./paint"; import type { WebEngineEditCommand } from "./web-engine"; export const PAINT_STROKE_SESSION_SCHEMA_VERSION = 1 as const; export const PAINT_STROKE_SESSION_BUDGET = { maxActiveSessions: 8, maxChunks: 4096, maxEntriesPerChunk: 16_384, maxEntries: PAINT_BUDGET.maxWeightEntries, maxBytes: PAINT_BUDGET.maxStrokeBytes, } as const; export type PaintStrokeSessionTargetIR = | { mode: "VERTEX_COLOR"; meshId: string; attributeName: string; domain: "POINT" | "CORNER" } | { mode: "WEIGHT"; objectId: string; vertexGroup: string; normalize: boolean; limit?: number; mirror: boolean; mirrorAxis?: 0 | 1 | 2; mirrorTolerance?: number }; export interface PaintStrokeSessionBeginIR { schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; pointerSessionId: string; baseRevision: number; target: PaintStrokeSessionTargetIR; } export interface PaintStrokeSessionChunkIR { schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; pointerSessionId: string; baseRevision: number; chunkIndex: number; indices: number[]; values: number[]; } export interface PaintStrokeSessionCommitIR { schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; pointerSessionId: string; baseRevision: number; expectedChunkCount: number; } export interface PaintStrokeSessionCancelIR { schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; pointerSessionId: string; baseRevision: number; } export interface PaintStrokeSessionReceiptIR { schemaVersion: typeof PAINT_STROKE_SESSION_SCHEMA_VERSION; pointerSessionId: string; mode: PaintStrokeSessionTargetIR["mode"]; state: "OPEN" | "READY" | "COMMITTED" | "CANCELLED"; baseRevision: number; chunkCount: number; receivedEntryCount: number; uniqueEntryCount: number; bufferedBytes: number; committedRevision?: number; } export type PaintStrokeSessionErrorCode = "PAINT_SCHEMA_INVALID" | "PAINT_BUDGET_EXCEEDED" | "REVISION_CONFLICT"; export class PaintStrokeSessionError extends Error { constructor(readonly code: PaintStrokeSessionErrorCode, message: string) { super(`${code}: ${message}`); this.name = "PaintStrokeSessionError"; } } interface BufferedSession { begin: PaintStrokeSessionBeginIR; chunkCount: number; receivedEntryCount: number; bufferedBytes: number; values: Map; } const encoder = new TextEncoder(); const BEGIN_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "target"]); const COLOR_TARGET_FIELDS = new Set(["mode", "meshId", "attributeName", "domain"]); const WEIGHT_TARGET_FIELDS = new Set(["mode", "objectId", "vertexGroup", "normalize", "limit", "mirror", "mirrorAxis", "mirrorTolerance"]); const CHUNK_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "chunkIndex", "indices", "values"]); const COMMIT_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision", "expectedChunkCount"]); const CANCEL_FIELDS = new Set(["schemaVersion", "pointerSessionId", "baseRevision"]); function fail(code: PaintStrokeSessionErrorCode, message: string): never { throw new PaintStrokeSessionError(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 integer(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 boundedString(value: unknown, label: string, prefix?: string, maxBytes = 255): string { if (typeof value !== "string" || value.length === 0 || (prefix !== undefined && !value.startsWith(prefix)) || encoder.encode(value).byteLength > maxBytes) { fail("PAINT_SCHEMA_INVALID", `${label} is outside the bounded identity range`); } return value; } function revision(value: unknown, currentRevision: number, label: string): number { const parsed = integer(value, label); if (!Number.isSafeInteger(currentRevision) || currentRevision < 0) fail("PAINT_SCHEMA_INVALID", "current revision is invalid"); if (parsed !== currentRevision) fail("REVISION_CONFLICT", "Paint pointer session is stale"); return parsed; } function pointerSessionId(value: unknown): string { return boundedString(value, "pointerSessionId", "paint-pointer:", 128); } function parseTarget(value: unknown): PaintStrokeSessionTargetIR { const target = record(value, "Paint stroke target"); if (target.mode === "VERTEX_COLOR") { exact(target, COLOR_TARGET_FIELDS, "Paint color target"); const domain = target.domain; if (domain !== "POINT" && domain !== "CORNER") fail("PAINT_SCHEMA_INVALID", "Paint color domain is invalid"); return { mode: "VERTEX_COLOR", meshId: boundedString(target.meshId, "meshId", "mesh:", 256), attributeName: boundedString(target.attributeName, "attributeName", undefined, 63), domain, }; } if (target.mode === "WEIGHT") { exact(target, WEIGHT_TARGET_FIELDS, "Paint weight target"); if (typeof target.normalize !== "boolean" || typeof target.mirror !== "boolean") fail("PAINT_SCHEMA_INVALID", "Paint weight options must be boolean"); const limit = target.limit === undefined ? undefined : integer(target.limit, "limit"); if (limit !== undefined && (limit < 1 || limit > 32)) fail("PAINT_SCHEMA_INVALID", "Paint weight limit is outside [1,32]"); const mirrorAxis = target.mirrorAxis === undefined ? 0 : integer(target.mirrorAxis, "mirrorAxis"); if (mirrorAxis > 2) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis must be 0, 1 or 2"); const mirrorTolerance = target.mirrorTolerance === undefined ? 1e-4 : target.mirrorTolerance; if (typeof mirrorTolerance !== "number" || !Number.isFinite(mirrorTolerance) || mirrorTolerance <= 0 || mirrorTolerance > 1) fail("PAINT_SCHEMA_INVALID", "Paint mirror tolerance is outside (0,1]"); if (!target.mirror && (target.mirrorAxis !== undefined || target.mirrorTolerance !== undefined)) fail("PAINT_SCHEMA_INVALID", "Paint mirror axis/tolerance require mirror=true"); return { mode: "WEIGHT", objectId: boundedString(target.objectId, "objectId", "object:", 256), vertexGroup: boundedString(target.vertexGroup, "vertexGroup", undefined, 63), normalize: target.normalize, ...(limit === undefined ? {} : { limit }), mirror: target.mirror, ...(target.mirrorAxis === undefined ? {} : { mirrorAxis: mirrorAxis as 0 | 1 | 2 }), ...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance }), }; } fail("PAINT_SCHEMA_INVALID", "Paint stroke mode is invalid"); } function receipt(session: BufferedSession, state: PaintStrokeSessionReceiptIR["state"]): PaintStrokeSessionReceiptIR { return { schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, pointerSessionId: session.begin.pointerSessionId, mode: session.begin.target.mode, state, baseRevision: session.begin.baseRevision, chunkCount: session.chunkCount, receivedEntryCount: session.receivedEntryCount, uniqueEntryCount: session.values.size, bufferedBytes: session.bufferedBytes, }; } function parseControl( value: unknown, fields: ReadonlySet, withChunkCount: boolean, ): T { const input = record(value, "Paint stroke session control"); exact(input, fields, "Paint stroke session control"); if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); const parsed = { schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, pointerSessionId: pointerSessionId(input.pointerSessionId), baseRevision: integer(input.baseRevision, "baseRevision"), } as PaintStrokeSessionCancelIR & Partial; if (withChunkCount) parsed.expectedChunkCount = integer(input.expectedChunkCount, "expectedChunkCount"); return parsed as T; } export class PaintStrokeSessionStore { private readonly sessions = new Map(); get activeCount(): number { return this.sessions.size; } begin(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR { const input = record(value, "Paint stroke session begin"); exact(input, BEGIN_FIELDS, "Paint stroke session begin"); if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); const begin: PaintStrokeSessionBeginIR = { schemaVersion: PAINT_STROKE_SESSION_SCHEMA_VERSION, pointerSessionId: pointerSessionId(input.pointerSessionId), baseRevision: revision(input.baseRevision, currentRevision, "baseRevision"), target: parseTarget(input.target), }; if (this.sessions.has(begin.pointerSessionId)) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is already open"); if (this.sessions.size >= PAINT_STROKE_SESSION_BUDGET.maxActiveSessions) fail("PAINT_BUDGET_EXCEEDED", "Paint pointer session capacity is exhausted"); const session: BufferedSession = { begin, chunkCount: 0, receivedEntryCount: 0, bufferedBytes: 0, values: new Map() }; this.sessions.set(begin.pointerSessionId, session); return receipt(session, "OPEN"); } append(value: unknown, currentRevision: number): PaintStrokeSessionReceiptIR { const input = record(value, "Paint stroke chunk"); exact(input, CHUNK_FIELDS, "Paint stroke chunk"); if (input.schemaVersion !== PAINT_STROKE_SESSION_SCHEMA_VERSION) fail("PAINT_SCHEMA_INVALID", "Paint stroke session schema is unsupported"); const id = pointerSessionId(input.pointerSessionId); const session = this.sessions.get(id); if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); const baseRevision = revision(input.baseRevision, currentRevision, "baseRevision"); if (baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke chunk revision does not match its pointer session"); const chunkIndex = integer(input.chunkIndex, "chunkIndex"); if (chunkIndex !== session.chunkCount) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunks must be contiguous and ordered"); if (session.chunkCount >= PAINT_STROKE_SESSION_BUDGET.maxChunks) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the chunk budget"); if (!Array.isArray(input.indices) || input.indices.length === 0) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk must contain indices"); if (input.indices.length > PAINT_STROKE_SESSION_BUDGET.maxEntriesPerChunk) fail("PAINT_BUDGET_EXCEEDED", "Paint stroke chunk exceeds the entry budget"); const indices = input.indices.map((item, index) => integer(item, `indices[${index}]`)); if (new Set(indices).size !== indices.length) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk contains duplicate indices"); if (!Array.isArray(input.values)) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values must be an array"); const width = session.begin.target.mode === "VERTEX_COLOR" ? 4 : 1; if (input.values.length !== indices.length * width) fail("PAINT_SCHEMA_INVALID", "Paint stroke chunk values do not match its mode"); const values = input.values.map((item, index) => { if (typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1) fail("PAINT_SCHEMA_INVALID", `values[${index}] must be in [0,1]`); return item; }); const nextEntryCount = session.receivedEntryCount + indices.length; const nextBytes = session.bufferedBytes + indices.length * 4 + values.length * 4; if (nextEntryCount > PAINT_STROKE_SESSION_BUDGET.maxEntries || nextBytes > PAINT_STROKE_SESSION_BUDGET.maxBytes) { fail("PAINT_BUDGET_EXCEEDED", "Paint stroke exceeds the pointer session budget"); } indices.forEach((index, offset) => session.values.set(index, values.slice(offset * width, (offset + 1) * width))); session.chunkCount += 1; session.receivedEntryCount = nextEntryCount; session.bufferedBytes = nextBytes; return receipt(session, "OPEN"); } commit(value: unknown, currentRevision: number): { command: WebEngineEditCommand; receipt: PaintStrokeSessionReceiptIR } { const input = parseControl(value, COMMIT_FIELDS, true); const session = this.sessions.get(input.pointerSessionId); if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); try { revision(input.baseRevision, currentRevision, "baseRevision"); if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke commit revision does not match its pointer session"); if (input.expectedChunkCount !== session.chunkCount || session.chunkCount === 0 || session.values.size === 0) { fail("PAINT_SCHEMA_INVALID", "Paint stroke commit does not match its buffered chunks"); } const indices = [...session.values.keys()].sort((left, right) => left - right); const values = indices.flatMap((index) => session.values.get(index) ?? []); const target = session.begin.target; const command: WebEngineEditCommand = target.mode === "VERTEX_COLOR" ? { type: "setVertexColors", meshId: target.meshId, attributeName: target.attributeName, domain: target.domain, indices, colors: values } : { type: "setVertexWeights", objectId: target.objectId, vertexGroup: target.vertexGroup, indices, values, normalize: target.normalize, ...(target.limit === undefined ? {} : { limit: target.limit }), mirror: target.mirror, ...(target.mirrorAxis === undefined ? {} : { mirrorAxis: target.mirrorAxis }), ...(target.mirrorTolerance === undefined ? {} : { mirrorTolerance: target.mirrorTolerance }) }; return { command, receipt: receipt(session, "READY") }; } finally { this.sessions.delete(input.pointerSessionId); } } cancel(value: unknown): PaintStrokeSessionReceiptIR { const input = parseControl(value, CANCEL_FIELDS, false); const session = this.sessions.get(input.pointerSessionId); if (!session) fail("PAINT_SCHEMA_INVALID", "Paint pointer session is not open"); if (input.baseRevision !== session.begin.baseRevision) fail("REVISION_CONFLICT", "Paint stroke cancel revision does not match its pointer session"); this.sessions.delete(input.pointerSessionId); return receipt(session, "CANCELLED"); } clear(): void { this.sessions.clear(); } }