283 lines
15 KiB
TypeScript
283 lines
15 KiB
TypeScript
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<number, number[]>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) fail("PAINT_SCHEMA_INVALID", `${label} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, 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<T extends PaintStrokeSessionCommitIR | PaintStrokeSessionCancelIR>(
|
|
value: unknown,
|
|
fields: ReadonlySet<string>,
|
|
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<PaintStrokeSessionCommitIR>;
|
|
if (withChunkCount) parsed.expectedChunkCount = integer(input.expectedChunkCount, "expectedChunkCount");
|
|
return parsed as T;
|
|
}
|
|
|
|
export class PaintStrokeSessionStore {
|
|
private readonly sessions = new Map<string, BufferedSession>();
|
|
|
|
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<PaintStrokeSessionCommitIR>(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<PaintStrokeSessionCancelIR>(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();
|
|
}
|
|
}
|