Files
workinf_Blender_Wasm/web/protocol/grease-pencil-marquee.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

226 lines
8.8 KiB
TypeScript

export const GREASE_PENCIL_MARQUEE_SCHEMA_VERSION = 1 as const;
export const GREASE_PENCIL_MARQUEE_BUDGET = {
maxCandidates: 1_000_000,
maxIdBytes: 256,
} as const;
export interface GreasePencilDrawingScopeIR {
dataId: string;
layerId: string;
frame: number;
drawingId: string;
}
export interface GreasePencilStablePointRefIR extends GreasePencilDrawingScopeIR {
strokeId: string;
pointId: string;
strokeIndex: number;
pointIndex: number;
}
export interface GreasePencilMarqueeCandidateIR extends GreasePencilStablePointRefIR {
viewportPosition: [number, number];
}
export interface GreasePencilMarqueeBoxIR {
left: number;
top: number;
right: number;
bottom: number;
}
export interface GreasePencilMarqueeRequestIR {
schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION;
baseRevision: number;
baseSelectionRevision: number;
drawing: GreasePencilDrawingScopeIR;
box: GreasePencilMarqueeBoxIR;
candidates: GreasePencilMarqueeCandidateIR[];
}
export interface GreasePencilMarqueeResultIR {
schemaVersion: typeof GREASE_PENCIL_MARQUEE_SCHEMA_VERSION;
baseRevision: number;
baseSelectionRevision: number;
drawing: GreasePencilDrawingScopeIR;
selectedStrokeIds: string[];
selectedPoints: GreasePencilStablePointRefIR[];
}
export type GreasePencilMarqueeErrorCode =
| "GREASE_PENCIL_SELECTION_INVALID"
| "GREASE_PENCIL_SELECTION_SCOPE_INVALID"
| "GREASE_PENCIL_BUDGET_EXCEEDED"
| "REVISION_CONFLICT";
export class GreasePencilMarqueeValidationError extends Error {
constructor(readonly code: GreasePencilMarqueeErrorCode, message: string) {
super(`${code}: ${message}`);
this.name = "GreasePencilMarqueeValidationError";
}
}
const DRAWING_FIELDS = new Set(["dataId", "layerId", "frame", "drawingId"]);
const BOX_FIELDS = new Set(["left", "top", "right", "bottom"]);
const CANDIDATE_FIELDS = new Set([
"dataId", "layerId", "frame", "drawingId", "strokeId", "pointId", "strokeIndex",
"pointIndex", "viewportPosition",
]);
const REQUEST_FIELDS = new Set(["schemaVersion", "baseRevision", "baseSelectionRevision", "drawing", "box", "candidates"]);
const encoder = new TextEncoder();
function fail(code: GreasePencilMarqueeErrorCode, message: string): never {
throw new GreasePencilMarqueeValidationError(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 exactFields(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 stableId(value: unknown, prefix: string, path: string): string {
if (typeof value !== "string" || !value.startsWith(prefix) ||
encoder.encode(value).byteLength > GREASE_PENCIL_MARQUEE_BUDGET.maxIdBytes) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} is not a bounded ${prefix} identity`);
}
return value;
}
function safeInteger(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 finite(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > 8) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path} must be finite and bounded`);
}
return value;
}
function drawingScope(value: unknown, path: string): GreasePencilDrawingScopeIR {
const drawing = record(value, path);
exactFields(drawing, DRAWING_FIELDS, path);
return {
dataId: stableId(drawing.dataId, "grease-pencil:", `${path}.dataId`),
layerId: stableId(drawing.layerId, "grease-pencil-layer:", `${path}.layerId`),
frame: safeInteger(drawing.frame, `${path}.frame`, true),
drawingId: stableId(drawing.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 box(value: unknown): GreasePencilMarqueeBoxIR {
const candidate = record(value, "request.box");
exactFields(candidate, BOX_FIELDS, "request.box");
const result = {
left: finite(candidate.left, "request.box.left"),
top: finite(candidate.top, "request.box.top"),
right: finite(candidate.right, "request.box.right"),
bottom: finite(candidate.bottom, "request.box.bottom"),
};
if (result.left < 0 || result.top < 0 || result.right > 1 || result.bottom > 1 ||
result.left >= result.right || result.top >= result.bottom) {
fail("GREASE_PENCIL_SELECTION_INVALID", "request.box must be a non-empty normalized viewport rectangle");
}
return result;
}
function candidate(value: unknown, index: number, drawing: GreasePencilDrawingScopeIR): GreasePencilMarqueeCandidateIR {
const path = `request.candidates[${index}]`;
const point = record(value, path);
exactFields(point, CANDIDATE_FIELDS, path);
const scope = drawingScope({
dataId: point.dataId,
layerId: point.layerId,
frame: point.frame,
drawingId: point.drawingId,
}, path);
if (!sameDrawing(scope, drawing)) {
fail("GREASE_PENCIL_SELECTION_SCOPE_INVALID", `${path} does not belong to the current drawing`);
}
if (!Array.isArray(point.viewportPosition) || point.viewportPosition.length !== 2) {
fail("GREASE_PENCIL_SELECTION_INVALID", `${path}.viewportPosition must contain two numbers`);
}
return {
...scope,
strokeId: stableId(point.strokeId, "grease-pencil-stroke:", `${path}.strokeId`),
pointId: stableId(point.pointId, "grease-pencil-point:", `${path}.pointId`),
strokeIndex: safeInteger(point.strokeIndex, `${path}.strokeIndex`),
pointIndex: safeInteger(point.pointIndex, `${path}.pointIndex`),
viewportPosition: [
finite(point.viewportPosition[0], `${path}.viewportPosition[0]`),
finite(point.viewportPosition[1], `${path}.viewportPosition[1]`),
],
};
}
export function selectGreasePencilMarquee(
requestValue: unknown,
currentRevision: number,
): GreasePencilMarqueeResultIR {
const request = record(requestValue, "request");
exactFields(request, REQUEST_FIELDS, "request");
if (request.schemaVersion !== GREASE_PENCIL_MARQUEE_SCHEMA_VERSION) {
fail("GREASE_PENCIL_SELECTION_INVALID", "request.schemaVersion is unsupported");
}
const baseRevision = safeInteger(request.baseRevision, "request.baseRevision");
const baseSelectionRevision = safeInteger(request.baseSelectionRevision, "request.baseSelectionRevision");
if (baseRevision !== currentRevision) {
fail("REVISION_CONFLICT", "Grease Pencil marquee request is stale");
}
const drawing = drawingScope(request.drawing, "request.drawing");
const selectionBox = box(request.box);
if (!Array.isArray(request.candidates)) {
fail("GREASE_PENCIL_SELECTION_INVALID", "request.candidates must be an array");
}
if (request.candidates.length > GREASE_PENCIL_MARQUEE_BUDGET.maxCandidates) {
fail("GREASE_PENCIL_BUDGET_EXCEEDED", "Grease Pencil marquee candidate budget exceeded");
}
const candidates = request.candidates.map((value, index) => candidate(value, index, drawing));
const pointIds = new Set<string>();
const strokeIndices = new Map<string, number>();
for (const point of candidates) {
if (pointIds.has(point.pointId)) {
fail("GREASE_PENCIL_SELECTION_INVALID", `duplicate point identity ${point.pointId}`);
}
pointIds.add(point.pointId);
const priorStrokeIndex = strokeIndices.get(point.strokeId);
if (priorStrokeIndex !== undefined && priorStrokeIndex !== point.strokeIndex) {
fail("GREASE_PENCIL_SELECTION_INVALID", `stroke identity ${point.strokeId} maps to multiple indices`);
}
strokeIndices.set(point.strokeId, point.strokeIndex);
}
const selectedPoints = candidates
.filter((point) => point.viewportPosition[0] >= selectionBox.left &&
point.viewportPosition[0] <= selectionBox.right &&
point.viewportPosition[1] >= selectionBox.top &&
point.viewportPosition[1] <= selectionBox.bottom)
.map(({ viewportPosition: _viewportPosition, ...point }) => point)
.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex || left.pointId.localeCompare(right.pointId));
return {
schemaVersion: GREASE_PENCIL_MARQUEE_SCHEMA_VERSION,
baseRevision,
baseSelectionRevision,
drawing,
selectedStrokeIds: [...new Set(selectedPoints.map((point) => point.strokeId))].sort(),
selectedPoints,
};
}