151 lines
6.2 KiB
TypeScript
151 lines
6.2 KiB
TypeScript
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
|
import type { ErrorCode } from "./error";
|
|
|
|
export const PAINT_PBVH_CAPABILITY_SCHEMA = 1 as const;
|
|
export const PAINT_PBVH_WASM_ENTRYPOINT = "_web_engine_apply_pbvh_stroke" as const;
|
|
|
|
export type PaintPBVHDomain = "SCULPT" | "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
|
|
|
|
const BRUSH_INVENTORY = {
|
|
SCULPT: [
|
|
"DRAW", "SMOOTH", "PINCH", "INFLATE", "GRAB", "LAYER", "CLAY", "NUDGE", "THUMB",
|
|
"SNAKE_HOOK", "ROTATE", "SIMPLIFY", "CREASE", "BLOB", "CLAY_STRIPS", "MASK",
|
|
"DRAW_SHARP", "ELASTIC_DEFORM", "POSE", "MULTIPLANE_SCRAPE", "SLIDE_RELAX",
|
|
"CLAY_THUMB", "CLOTH", "DRAW_FACE_SETS", "PAINT", "SMEAR", "BOUNDARY",
|
|
"DISPLACEMENT_ERASER", "DISPLACEMENT_SMEAR", "PLANE", "BLUR", "SCENE_PROJECT",
|
|
],
|
|
VERTEX_COLOR: ["DRAW", "BLUR", "AVERAGE", "SMEAR"],
|
|
WEIGHT: ["DRAW", "BLUR", "AVERAGE", "SMEAR"],
|
|
TEXTURE: ["DRAW", "SOFTEN", "SMEAR", "CLONE", "FILL", "MASK"],
|
|
} as const satisfies Record<PaintPBVHDomain, readonly string[]>;
|
|
|
|
export interface PaintPBVHBrushInventoryEntry {
|
|
domain: PaintPBVHDomain;
|
|
brush: string;
|
|
source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h";
|
|
}
|
|
|
|
export interface PaintPBVHCapabilityRequest {
|
|
schemaVersion: typeof PAINT_PBVH_CAPABILITY_SCHEMA;
|
|
operation: "PBVH_BRUSH";
|
|
domain: PaintPBVHDomain;
|
|
brush: string;
|
|
objectId: string;
|
|
meshId: string;
|
|
baseRevision: number;
|
|
}
|
|
|
|
export interface PaintPBVHCapabilityContext {
|
|
nativeEntrypointPresent: boolean;
|
|
sessionContextReady: boolean;
|
|
verifiedBrushes: ReadonlySet<string>;
|
|
currentRevision?: number;
|
|
currentObjectId?: string;
|
|
currentMeshId?: string;
|
|
}
|
|
|
|
export class PaintPBVHCapabilityError extends Error {
|
|
readonly code: ErrorCode;
|
|
readonly path?: string;
|
|
|
|
constructor(code: ErrorCode, message: string, path?: string) {
|
|
super(message);
|
|
this.name = "PaintPBVHCapabilityError";
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
function fail(code: ErrorCode, message: string, path?: string): never {
|
|
throw new PaintPBVHCapabilityError(code, message, path);
|
|
}
|
|
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function stableId(value: unknown, prefix: "object:" | "mesh:", path: string): string {
|
|
if (typeof value !== "string" || !value.startsWith(prefix) || value.length <= prefix.length || value.length > 512) {
|
|
fail("PAINT_SCHEMA_INVALID", `${path} must be a bounded ${prefix.slice(0, -1)} stable ID`, path);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function paintPBVHBrushInventory(): PaintPBVHBrushInventoryEntry[] {
|
|
return (Object.entries(BRUSH_INVENTORY) as Array<[PaintPBVHDomain, readonly string[]]>).flatMap(([domain, brushes]) =>
|
|
brushes.map((brush) => ({
|
|
domain,
|
|
brush,
|
|
source: "blender-5.2.0/source/blender/makesdna/DNA_brush_enums.h" as const,
|
|
})),
|
|
);
|
|
}
|
|
|
|
export function parsePaintPBVHCapabilityRequest(value: unknown): PaintPBVHCapabilityRequest {
|
|
if (!record(value)) fail("PAINT_SCHEMA_INVALID", "PBVH capability request must be an object");
|
|
const allowed = ["schemaVersion", "operation", "domain", "brush", "objectId", "meshId", "baseRevision"];
|
|
const extra = Object.keys(value).find((key) => !allowed.includes(key));
|
|
if (extra) fail("PAINT_SCHEMA_INVALID", `PBVH capability request contains unsupported field ${extra}`, extra);
|
|
if (value.schemaVersion !== PAINT_PBVH_CAPABILITY_SCHEMA) fail("PROTOCOL_MISMATCH", "Unsupported PBVH capability schema", "schemaVersion");
|
|
if (value.operation !== "PBVH_BRUSH") fail("PAINT_SCHEMA_INVALID", "operation must be PBVH_BRUSH", "operation");
|
|
if (typeof value.domain !== "string" || !(value.domain in BRUSH_INVENTORY)) {
|
|
fail("PAINT_SCHEMA_INVALID", "domain is not a PBVH paint domain", "domain");
|
|
}
|
|
const domain = value.domain as PaintPBVHDomain;
|
|
if (typeof value.brush !== "string" || !(BRUSH_INVENTORY[domain] as readonly string[]).includes(value.brush)) {
|
|
fail("PAINT_PBVH_BRUSH_UNVERIFIED", `Brush ${String(value.brush)} is not in the Blender 5.2 ${domain} inventory`, "brush");
|
|
}
|
|
if (!Number.isSafeInteger(value.baseRevision) || (value.baseRevision as number) < 0) {
|
|
fail("PAINT_SCHEMA_INVALID", "baseRevision must be a non-negative safe integer", "baseRevision");
|
|
}
|
|
return {
|
|
schemaVersion: PAINT_PBVH_CAPABILITY_SCHEMA,
|
|
operation: "PBVH_BRUSH",
|
|
domain,
|
|
brush: value.brush,
|
|
objectId: stableId(value.objectId, "object:", "objectId"),
|
|
meshId: stableId(value.meshId, "mesh:", "meshId"),
|
|
baseRevision: value.baseRevision as number,
|
|
};
|
|
}
|
|
|
|
export function gatePaintPBVHCapability(
|
|
value: unknown,
|
|
context: PaintPBVHCapabilityContext,
|
|
): CapabilityGateResult {
|
|
const request = parsePaintPBVHCapabilityRequest(value);
|
|
const capability = `PBVH_${request.domain}_${request.brush}`;
|
|
if (context.currentRevision !== undefined && request.baseRevision !== context.currentRevision) {
|
|
return blockedGate("N-017", capability, [
|
|
capabilityIssue("REVISION_CONFLICT", "PBVH capability request is stale", "baseRevision"),
|
|
]);
|
|
}
|
|
if ((context.currentObjectId !== undefined && request.objectId !== context.currentObjectId) ||
|
|
(context.currentMeshId !== undefined && request.meshId !== context.currentMeshId)) {
|
|
return blockedGate("N-017", capability, [
|
|
capabilityIssue("PAINT_SCHEMA_INVALID", "PBVH request does not target the current Mesh object", "objectId"),
|
|
]);
|
|
}
|
|
if (!context.nativeEntrypointPresent) {
|
|
return blockedGate("N-017", capability, [
|
|
capabilityIssue(
|
|
"PAINT_PBVH_UNAVAILABLE",
|
|
`${PAINT_PBVH_WASM_ENTRYPOINT} is not present in the WebEngine WASM build`,
|
|
"operation",
|
|
false,
|
|
),
|
|
]);
|
|
}
|
|
if (!context.sessionContextReady) {
|
|
return blockedGate("N-017", capability, [
|
|
capabilityIssue("PAINT_PBVH_CONTEXT_UNAVAILABLE", "The Blender PBVH paint session context is not initialized", "operation"),
|
|
]);
|
|
}
|
|
if (!context.verifiedBrushes.has(`${request.domain}:${request.brush}`)) {
|
|
return blockedGate("N-017", capability, [
|
|
capabilityIssue("PAINT_PBVH_BRUSH_UNVERIFIED", "This PBVH brush has no desktop/WASM golden", "brush"),
|
|
]);
|
|
}
|
|
return readyGate("N-017", capability);
|
|
}
|