feat: define versioned sketch solver protocol
This commit is contained in:
174
src/facade/sketchSolverProtocol.ts
Normal file
174
src/facade/sketchSolverProtocol.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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<SketchSolverResponse>
|
||||
}
|
||||
|
||||
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<SketchSolverResponse> {
|
||||
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<SketchSolverResponse> {
|
||||
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<SketchSolverRequest, 'protocolVersion' | 'generation'>): Promise<SketchSolverExecution> {
|
||||
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<SketchSolverRequest, 'protocolVersion' | 'generation'>
|
||||
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<SketchSolverReplayResult[]> => {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user