Files
2026-08-12 04:47:48 -04:00

125 lines
7.4 KiB
TypeScript

import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const SCULPT_PROTOCOL_SCHEMA = 1 as const;
export type SculptBrush = "DRAW" | "INFLATE" | "GRAB" | "SMOOTH";
export interface SculptStrokeSampleIR {
position: [number, number, number];
normal: [number, number, number];
radius: number;
strength: number;
pressure: number;
time: number;
}
export interface SculptStrokeIR {
schemaVersion: typeof SCULPT_PROTOCOL_SCHEMA;
meshId: string;
brush: SculptBrush;
samples: SculptStrokeSampleIR[];
symmetry: [boolean, boolean, boolean];
mirrorObjectSpace: boolean;
}
export interface SculptMeshAttributesIR {
schemaVersion: typeof SCULPT_PROTOCOL_SCHEMA;
meshId: string;
vertexCount: number;
faceCount: number;
mask: number[];
faceSets: number[];
activeFaceSet: number;
revision: number;
}
export interface SculptCapabilityContext {
meshId: string;
singleUser: boolean;
linkedLibrary: boolean;
hasShapeKeys: boolean;
hasTopologyChangingModifier: boolean;
vertexCount: number;
faceCount: number;
}
export class SculptValidationError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "SculptValidationError";
this.code = code;
this.path = path;
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function tuple(value: unknown, length: number, path: string): asserts value is number[] {
if (!Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== "number" || !Number.isFinite(item))) {
throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `${path} must contain ${length} finite numbers`, path);
}
}
function booleanTuple(value: unknown, path: string): asserts value is [boolean, boolean, boolean] {
if (!Array.isArray(value) || value.length !== 3 || value.some((item) => typeof item !== "boolean")) {
throw new SculptValidationError("TASK_VALIDATION_FAILED", `${path} must contain three booleans`, path);
}
}
export function parseSculptStroke(value: unknown, maxSamples = 2048, maxPathLength = 10000): SculptStrokeIR {
if (!record(value) || value.schemaVersion !== SCULPT_PROTOCOL_SCHEMA) {
throw new SculptValidationError("PROTOCOL_MISMATCH", "Unsupported SculptStroke schema");
}
if (typeof value.meshId !== "string" || value.meshId.length === 0) throw new SculptValidationError("INVALID_ARGUMENT", "meshId is required", "meshId");
if (!["DRAW", "INFLATE", "GRAB", "SMOOTH"].includes(value.brush as string)) throw new SculptValidationError("CAPABILITY_MISSING", "Sculpt brush is not in the deterministic subset", "brush");
if (!Array.isArray(value.samples) || value.samples.length === 0 || value.samples.length > maxSamples) {
throw new SculptValidationError("SCULPT_STROKE_BUDGET_EXCEEDED", `samples must contain 1..${maxSamples} points`, "samples");
}
let pathLength = 0;
let previousPosition: number[] | undefined;
for (const [index, sample] of value.samples.entries()) {
if (!record(sample)) throw new SculptValidationError("TASK_VALIDATION_FAILED", `samples[${index}] must be an object`, `samples[${index}]`);
tuple(sample.position, 3, `samples[${index}].position`);
tuple(sample.normal, 3, `samples[${index}].normal`);
for (const field of ["radius", "strength", "pressure", "time"] as const) {
if (typeof sample[field] !== "number" || !Number.isFinite(sample[field])) throw new SculptValidationError("TASK_VALIDATION_FAILED", `samples[${index}].${field} must be finite`, `samples[${index}].${field}`);
}
const radius = sample.radius as number;
const pressure = sample.pressure as number;
const strength = sample.strength as number;
if (radius <= 0 || pressure < 0 || pressure > 1 || Math.abs(strength) > 1) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `samples[${index}] is outside brush bounds`, `samples[${index}]`);
const position = sample.position as number[];
if (previousPosition) pathLength += Math.hypot(position[0] - previousPosition[0], position[1] - previousPosition[1], position[2] - previousPosition[2]);
if (pathLength > maxPathLength) throw new SculptValidationError("SCULPT_STROKE_BUDGET_EXCEEDED", `stroke path exceeds the ${maxPathLength} unit budget`, "samples");
previousPosition = position;
}
booleanTuple(value.symmetry, "symmetry");
if (typeof value.mirrorObjectSpace !== "boolean") throw new SculptValidationError("TASK_VALIDATION_FAILED", "mirrorObjectSpace must be boolean", "mirrorObjectSpace");
return value as unknown as SculptStrokeIR;
}
export function parseSculptMeshAttributes(value: unknown): SculptMeshAttributesIR {
if (!record(value) || value.schemaVersion !== SCULPT_PROTOCOL_SCHEMA) throw new SculptValidationError("PROTOCOL_MISMATCH", "Unsupported SculptMeshAttributes schema");
for (const field of ["meshId"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new SculptValidationError("INVALID_ARGUMENT", `${field} is required`, field);
for (const field of ["vertexCount", "faceCount", "revision"] as const) if (typeof value[field] !== "number" || !Number.isSafeInteger(value[field]) || value[field] < 0) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", `${field} must be a non-negative integer`, field);
if (!Array.isArray(value.mask) || value.mask.length !== value.vertexCount || value.mask.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0 || item > 1)) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "mask must match vertexCount and stay in [0,1]", "mask");
if (!Array.isArray(value.faceSets) || value.faceSets.length !== value.faceCount || value.faceSets.some((item) => typeof item !== "number" || !Number.isSafeInteger(item) || item < 0)) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "faceSets must match faceCount", "faceSets");
if (typeof value.activeFaceSet !== "number" || !Number.isSafeInteger(value.activeFaceSet) || value.activeFaceSet < -1) throw new SculptValidationError("SCULPT_ATTRIBUTE_INVALID", "activeFaceSet is invalid", "activeFaceSet");
return value as unknown as SculptMeshAttributesIR;
}
export function gateSculptCapability(context: SculptCapabilityContext): CapabilityGateResult {
const issues = [];
if (!context.singleUser) issues.push(capabilityIssue("SCULPT_MESH_NOT_SINGLE_USER", "Sculpt requires a single-user Mesh", "singleUser"));
if (context.linkedLibrary) issues.push(capabilityIssue("LINKED_DATA_MUTATION_BLOCKED", "Linked-library Mesh is read-only", "linkedLibrary"));
if (context.hasShapeKeys) issues.push(capabilityIssue("SCULPT_TOPOLOGY_UNSUPPORTED", "Shape-key Mesh is outside the initial Sculpt subset", "hasShapeKeys"));
if (context.hasTopologyChangingModifier) issues.push(capabilityIssue("SCULPT_TOPOLOGY_UNSUPPORTED", "Topology-changing modifier must be applied or disabled before Sculpt", "hasTopologyChangingModifier"));
if (!Number.isSafeInteger(context.vertexCount) || context.vertexCount <= 0 || !Number.isSafeInteger(context.faceCount) || context.faceCount <= 0) issues.push(capabilityIssue("SCULPT_ATTRIBUTE_INVALID", "Sculpt requires a non-empty Mesh", "meshId"));
return issues.length > 0 ? blockedGate("N-011", "SCULPT_STROKE", issues) : readyGate("N-011", "SCULPT_STROKE");
}