Files
workinf_Blender_Wasm/web/protocol/grease-pencil-selection.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

213 lines
9.3 KiB
TypeScript

import type { GreasePencilDrawingScopeIR, GreasePencilStablePointRefIR } from "./grease-pencil-marquee";
export const GREASE_PENCIL_SELECTION_SCHEMA_VERSION = 1 as const;
export const GREASE_PENCIL_SELECTION_BUDGET = { maxPoints: 1_000_000, maxIdBytes: 256 } as const;
export type GreasePencilSelectionSource = "CANVAS_2D" | "VIEWPORT_3D";
export type GreasePencilSelectionOperation = "REPLACE" | "ADD" | "TOGGLE" | "CLEAR";
export interface GreasePencilSelectionStateIR {
schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION;
revision: number;
drawing: GreasePencilDrawingScopeIR;
selectedPoints: GreasePencilStablePointRefIR[];
lastSource: GreasePencilSelectionSource | null;
}
export interface GreasePencilSelectionEditIR {
schemaVersion: typeof GREASE_PENCIL_SELECTION_SCHEMA_VERSION;
baseSelectionRevision: number;
source: GreasePencilSelectionSource;
operation: GreasePencilSelectionOperation;
points: GreasePencilStablePointRefIR[];
}
export type GreasePencilSelectionErrorCode =
| "GREASE_PENCIL_SELECTION_INVALID"
| "GREASE_PENCIL_SELECTION_SCOPE_INVALID"
| "GREASE_PENCIL_BUDGET_EXCEEDED"
| "REVISION_CONFLICT";
export class GreasePencilSelectionValidationError extends Error {
constructor(readonly code: GreasePencilSelectionErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "GreasePencilSelectionValidationError";
}
}
const encoder = new TextEncoder();
const SCOPE_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]);
const POINT_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex", "pointIndex"]);
const STATE_FIELDS = new Set(["schemaVersion", "revision", "drawing", "selectedPoints", "lastSource"]);
const EDIT_FIELDS = new Set(["schemaVersion", "baseSelectionRevision", "source", "operation", "points"]);
function fail(code: GreasePencilSelectionErrorCode, message: string): never {
throw new GreasePencilSelectionValidationError(code, message);
}
function record(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an object`);
}
return value as Record<string, unknown>;
}
function exact(value: Record<string, unknown>, fields: ReadonlySet<string>, path: string): void {
if (Object.keys(value).some((field) => !fields.has(field))) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains undeclared fields`);
}
}
function integer(value: unknown, path: string, allowNegative = false): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || (!allowNegative && value < 0) ||
value < -1_000_000 || value > 1_000_000) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is outside the supported integer range`);
}
return value;
}
function id(value: unknown, prefix: string, path: string): string {
if (typeof value !== "string" || !value.startsWith(prefix) ||
encoder.encode(value).byteLength > GREASE_PENCIL_SELECTION_BUDGET.maxIdBytes) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`);
}
return value;
}
function scope(value: unknown, path: string): GreasePencilDrawingScopeIR {
const candidate = record(value, path);
exact(candidate, SCOPE_FIELDS, path);
return {
dataId: id(candidate.dataId, "grease-pencil:", `${path}.dataId`),
layerId: id(candidate.layerId, "grease-pencil-layer:", `${path}.layerId`),
frame: integer(candidate.frame, `${path}.frame`, true),
drawingId: id(candidate.drawingId, "grease-pencil-drawing:", `${path}.drawingId`),
};
}
function sameDrawing(left: GreasePencilDrawingScopeIR, right: GreasePencilDrawingScopeIR): boolean {
return left.dataId === right.dataId && left.layerId === right.layerId &&
left.frame === right.frame && left.drawingId === right.drawingId;
}
function point(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR {
const candidate = record(value, path);
exact(candidate, POINT_FIELDS, path);
const pointScope = scope({
dataId: candidate.dataId,
layerId: candidate.layerId,
frame: candidate.frame,
drawingId: candidate.drawingId,
}, path);
if (!sameDrawing(pointScope, drawing)) {
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`);
}
return {
...pointScope,
strokeId: id(candidate.strokeId, "grease-pencil-stroke:", `${path}.strokeId`),
pointId: id(candidate.pointId, "grease-pencil-point:", `${path}.pointId`),
strokeIndex: integer(candidate.strokeIndex, `${path}.strokeIndex`),
pointIndex: integer(candidate.pointIndex, `${path}.pointIndex`),
};
}
function points(value: unknown, path: string, drawing: GreasePencilDrawingScopeIR): GreasePencilStablePointRefIR[] {
if (!Array.isArray(value)) fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be an array`);
if (value.length > GREASE_PENCIL_SELECTION_BUDGET.maxPoints) {
fail("GREASE_PENCIL_BUDGET_EXCEEDED", `${path} exceeds the selection point budget`);
}
const parsed = value.map((item, index) => point(item, `${path}[${index}]`, drawing));
if (new Set(parsed.map((item) => item.pointId)).size !== parsed.length) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} contains duplicate point identities`);
}
const strokeIndices = new Map<string, number>();
const pointIndices = new Map<string, string>();
for (const item of parsed) {
const priorStrokeIndex = strokeIndices.get(item.strokeId);
if (priorStrokeIndex !== undefined && priorStrokeIndex !== item.strokeIndex) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one stroke identity to multiple indices`);
}
strokeIndices.set(item.strokeId, item.strokeIndex);
const indexKey = `${item.strokeIndex}:${item.pointIndex}`;
const priorPointId = pointIndices.get(indexKey);
if (priorPointId !== undefined && priorPointId !== item.pointId) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} maps one point index to multiple identities`);
}
pointIndices.set(indexKey, item.pointId);
}
return parsed.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId));
}
export function parseGreasePencilSelectionState(value: unknown): GreasePencilSelectionStateIR {
const state = record(value, "state");
exact(state, STATE_FIELDS, "state");
if (state.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION) {
fail("GREASE_PENCIL_SELECTION_INVALID", "state.schemaVersion is unsupported");
}
const drawing = scope(state.drawing, "state.drawing");
if (state.lastSource !== null && state.lastSource !== "CANVAS_2D" && state.lastSource !== "VIEWPORT_3D") {
fail("GREASE_PENCIL_SELECTION_INVALID", "state.lastSource is invalid");
}
return {
schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION,
revision: integer(state.revision, "state.revision"),
drawing,
selectedPoints: points(state.selectedPoints, "state.selectedPoints", drawing),
lastSource: state.lastSource,
};
}
export function createGreasePencilSelectionState(drawing: GreasePencilDrawingScopeIR): GreasePencilSelectionStateIR {
return parseGreasePencilSelectionState({
schemaVersion: GREASE_PENCIL_SELECTION_SCHEMA_VERSION,
revision: 0,
drawing,
selectedPoints: [],
lastSource: null,
});
}
export function applyGreasePencilSelectionEdit(
stateValue: unknown,
editValue: unknown,
currentDrawing: GreasePencilDrawingScopeIR,
): GreasePencilSelectionStateIR {
const state = parseGreasePencilSelectionState(stateValue);
const expectedDrawing = scope(currentDrawing, "currentDrawing");
if (!sameDrawing(state.drawing, expectedDrawing)) {
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", "selection state is not bound to the current drawing");
}
const edit = record(editValue, "edit");
exact(edit, EDIT_FIELDS, "edit");
if (edit.schemaVersion !== GREASE_PENCIL_SELECTION_SCHEMA_VERSION ||
(edit.source !== "CANVAS_2D" && edit.source !== "VIEWPORT_3D") ||
!["REPLACE", "ADD", "TOGGLE", "CLEAR"].includes(String(edit.operation))) {
fail("GREASE_PENCIL_SELECTION_INVALID", "selection edit schema is invalid");
}
const baseSelectionRevision = integer(edit.baseSelectionRevision, "edit.baseSelectionRevision");
if (baseSelectionRevision !== state.revision) {
fail("REVISION_CONFLICT", "Grease Pencil selection edit is stale");
}
const editedPoints = points(edit.points, "edit.points", expectedDrawing);
if (edit.operation === "CLEAR" && editedPoints.length !== 0) {
fail("GREASE_PENCIL_SELECTION_INVALID", "CLEAR cannot include points");
}
const selected = new Map(state.selectedPoints.map((item) => [item.pointId, item]));
if (edit.operation === "REPLACE" || edit.operation === "CLEAR") selected.clear();
if (edit.operation === "REPLACE" || edit.operation === "ADD") {
for (const item of editedPoints) selected.set(item.pointId, item);
}
else if (edit.operation === "TOGGLE") {
for (const item of editedPoints) {
if (selected.has(item.pointId)) selected.delete(item.pointId);
else selected.set(item.pointId, item);
}
}
return parseGreasePencilSelectionState({
...state,
revision: state.revision + 1,
selectedPoints: [...selected.values()],
lastSource: edit.source,
});
}