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 { 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"); }