import { cloneSketch, solveSketch, type SketchSnapshot, type SketchSolveOptions, type SketchSolveResult } from './sketcher' export const SKETCH_SOLVER_PROTOCOL_VERSION = 1 as const export type SketchSolverCompatibility = 'experimental' | 'freecad-1.1.1' export type SketchSolverCapabilities = { providerId: string providerVersion: string engine: 'typescript-basic' | 'planegcs-wasm' availability: 'available' | 'unavailable' compatibility: SketchSolverCompatibility supportedGeometry: string[] supportedConstraints: string[] reason?: string } export type SketchSolverRequest = { protocolVersion: typeof SKETCH_SOLVER_PROTOCOL_VERSION requestId: string documentId: string documentVersion: number generation: number snapshot: SketchSnapshot options?: SketchSolveOptions } export type SketchSolverResponse = { protocolVersion: typeof SKETCH_SOLVER_PROTOCOL_VERSION requestId: string documentId: string documentVersion: number generation: number provider: SketchSolverCapabilities result: SketchSolveResult } export interface SketchSolverProvider { capabilities(): SketchSolverCapabilities solve(request: SketchSolverRequest, signal: AbortSignal): Promise } export class SketchSolverUnavailableError extends Error { readonly code = 'SKETCH_SOLVER_UNAVAILABLE' constructor(readonly capabilities: SketchSolverCapabilities) { super(capabilities.reason || `Sketch solver provider '${capabilities.providerId}' is unavailable.`) this.name = 'SketchSolverUnavailableError' } } const assertRequest = (request: SketchSolverRequest) => { if (request.protocolVersion !== SKETCH_SOLVER_PROTOCOL_VERSION) throw new RangeError(`Unsupported sketch solver protocol version: ${request.protocolVersion}.`) if (!request.requestId || !request.documentId) throw new TypeError('Sketch solver request requires requestId and documentId.') if (!Number.isSafeInteger(request.documentVersion) || request.documentVersion < 0) throw new RangeError('Sketch solver documentVersion must be a non-negative integer.') if (!Number.isSafeInteger(request.generation) || request.generation < 1) throw new RangeError('Sketch solver generation must be a positive integer.') } const abortError = () => new DOMException('Sketch solve cancelled.', 'AbortError') export class BasicSketchSolverProvider implements SketchSolverProvider { capabilities(): SketchSolverCapabilities { return { providerId: 'web-cad.basic-sketch-solver', providerVersion: '1.0.0', engine: 'typescript-basic', availability: 'available', compatibility: 'experimental', supportedGeometry: ['point', 'line', 'circle', 'arc'], supportedConstraints: ['coincident', 'horizontal', 'vertical', 'distance', 'distanceX', 'distanceY', 'radius', 'diameter', 'angle', 'equal', 'symmetric', 'tangent', 'block'], } } async solve(request: SketchSolverRequest, signal: AbortSignal): Promise { assertRequest(request) if (signal.aborted) throw abortError() const result = solveSketch(cloneSketch(request.snapshot), request.options) if (signal.aborted) throw abortError() return { protocolVersion: request.protocolVersion, requestId: request.requestId, documentId: request.documentId, documentVersion: request.documentVersion, generation: request.generation, provider: this.capabilities(), result, } } } export class UnavailablePlanegcsProvider implements SketchSolverProvider { constructor(private readonly reason = 'No FreeCAD 1.1.1 planegcs WASM artifact is installed.') {} capabilities(): SketchSolverCapabilities { return { providerId: 'freecad.planegcs-wasm', providerVersion: 'unavailable', engine: 'planegcs-wasm', availability: 'unavailable', compatibility: 'experimental', supportedGeometry: [], supportedConstraints: [], reason: this.reason, } } solve(): Promise { return Promise.reject(new SketchSolverUnavailableError(this.capabilities())) } } export type SketchSolverExecution = { status: 'completed' | 'cancelled' | 'stale' generation: number response?: SketchSolverResponse } export class SketchSolverCoordinator { private active: { generation: number; controller: AbortController } | null = null private generation = 0 constructor(private readonly currentDocumentVersion: (documentId: string) => number | null) {} cancel() { this.active?.controller.abort() } async solve(provider: SketchSolverProvider, input: Omit): Promise { this.active?.controller.abort() const generation = ++this.generation const controller = new AbortController() this.active = { generation, controller } const request: SketchSolverRequest = { ...input, protocolVersion: SKETCH_SOLVER_PROTOCOL_VERSION, generation, snapshot: cloneSketch(input.snapshot) } try { const response = await provider.solve(request, controller.signal) if (controller.signal.aborted || this.active?.generation !== generation) return { status: 'cancelled', generation } if (this.currentDocumentVersion(input.documentId) !== input.documentVersion) return { status: 'stale', generation } return { status: 'completed', generation, response } } catch (error) { if (controller.signal.aborted || (error instanceof Error && error.name === 'AbortError')) return { status: 'cancelled', generation } throw error } finally { if (this.active?.generation === generation) this.active = null } } } export type SketchSolverReplayCase = { id: string request: Omit expected: { status: SketchSolveResult['status'] degreesOfFreedom: number maxResidual: number } } export type SketchSolverReplayResult = { id: string; passed: boolean; differences: string[] } export const runSketchSolverReplay = async (provider: SketchSolverProvider, cases: SketchSolverReplayCase[]): Promise => { const coordinator = new SketchSolverCoordinator(() => 1) const results: SketchSolverReplayResult[] = [] for (const fixture of cases) { const execution = await coordinator.solve(provider, fixture.request) const actual = execution.response?.result const differences: string[] = [] if (!actual) differences.push(`execution status was ${execution.status}`) else { if (actual.status !== fixture.expected.status) differences.push(`status: expected ${fixture.expected.status}, received ${actual.status}`) if (actual.degreesOfFreedom !== fixture.expected.degreesOfFreedom) differences.push(`degreesOfFreedom: expected ${fixture.expected.degreesOfFreedom}, received ${actual.degreesOfFreedom}`) if (actual.residual > fixture.expected.maxResidual) differences.push(`residual: expected <= ${fixture.expected.maxResidual}, received ${actual.residual}`) } results.push({ id: fixture.id, passed: differences.length === 0, differences }) } return results }