Files
workinf_Blender_Wasm/web/protocol/paint-stroke-session.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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();
}
}