export type SketchPoint = { x: number; y: number } export type SketchGeometry = | { id: string; type: 'point'; position: SketchPoint; construction?: boolean } | { id: string; type: 'line'; start: SketchPoint; end: SketchPoint; construction?: boolean } | { id: string; type: 'circle'; center: SketchPoint; radius: number; construction?: boolean } | { id: string; type: 'arc'; center: SketchPoint; radius: number; startAngle: number; endAngle: number; construction?: boolean } export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' } export type SketchConstraint = | { id: string; type: 'coincident'; first: SketchPointRef; second: SketchPointRef; driving?: boolean } | { id: string; type: 'horizontal' | 'vertical'; geometryId: string; driving?: boolean } | { id: string; type: 'distance' | 'distanceX' | 'distanceY'; first: SketchPointRef; second: SketchPointRef; value: number; driving?: boolean } | { id: string; type: 'radius'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'diameter'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'angle'; geometryId: string; value: number; driving?: boolean } | { id: string; type: 'equal'; firstGeometryId: string; secondGeometryId: string; driving?: boolean } | { id: string; type: 'symmetric'; first: SketchPointRef; second: SketchPointRef; center: SketchPointRef; driving?: boolean } | { id: string; type: 'tangent'; firstGeometryId: string; secondGeometryId: string; driving?: boolean } | { id: string; type: 'block'; geometryId: string; driving?: boolean } export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid' export type SketchDiagnostic = { code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED' constraintId?: string message: string } export type SketchSnapshot = { id: string geometry: SketchGeometry[] constraints: SketchConstraint[] solver: { status: SketchSolverStatus degreesOfFreedom: number residual: number iterations: number diagnostics: SketchDiagnostic[] } } export type SketchSolveOptions = { tolerance?: number maxIterations?: number } export type SketchSolveResult = { snapshot: SketchSnapshot status: SketchSolverStatus degreesOfFreedom: number residual: number iterations: number diagnostics: SketchDiagnostic[] } const cloneGeometry = (geometry: SketchGeometry): SketchGeometry => { if (geometry.type === 'line') return { ...geometry, start: { ...geometry.start }, end: { ...geometry.end } } if (geometry.type === 'circle') return { ...geometry, center: { ...geometry.center } } if (geometry.type === 'arc') return { ...geometry, center: { ...geometry.center } } return { ...geometry, position: { ...geometry.position } } } export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({ ...sketch, geometry: sketch.geometry.map(cloneGeometry), constraints: sketch.constraints.map((constraint) => ({ ...constraint })), solver: { ...sketch.solver, diagnostics: sketch.solver.diagnostics.map((diagnostic) => ({ ...diagnostic })) }, }) export const createSketch = (id: string, geometry: SketchGeometry[] = [], constraints: SketchConstraint[] = []): SketchSnapshot => ({ id, geometry: geometry.map(cloneGeometry), constraints: constraints.map((constraint) => ({ ...constraint })), solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] }, }) const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: string, diagnostics: SketchDiagnostic[]) => { const result = geometry.find((candidate) => candidate.id === id) if (!result) diagnostics.push({ code: 'UNKNOWN_GEOMETRY', constraintId, message: `Sketch geometry '${id}' does not exist.` }) return result } const pointFor = (geometry: SketchGeometry, point: SketchPointRef['point'], constraintId: string, diagnostics: SketchDiagnostic[]): SketchPoint | null => { if (geometry.type === 'point' && point === 'position') return geometry.position if (geometry.type === 'line' && (point === 'start' || point === 'end')) return point === 'start' ? geometry.start : geometry.end if ((geometry.type === 'circle' || geometry.type === 'arc') && point === 'center') return geometry.center diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` }) return null } const pointKey = (ref: SketchPointRef) => `${ref.geometryId}.${ref.point}` const distance = (left: SketchPoint, right: SketchPoint) => Math.hypot(left.x - right.x, left.y - right.y) const lineLength = (geometry: SketchGeometry) => geometry.type === 'line' ? distance(geometry.start, geometry.end) : geometry.type === 'circle' || geometry.type === 'arc' ? geometry.radius : 0 const lineCircleTangentResidual = (line: SketchGeometry, circle: SketchGeometry) => { if (line.type !== 'line' || (circle.type !== 'circle' && circle.type !== 'arc')) return Infinity const dx = line.end.x - line.start.x const dy = line.end.y - line.start.y const length = Math.hypot(dx, dy) if (length === 0) return Infinity return Math.abs(((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length) - circle.radius } const isBlocked = (geometryId: string, blocked: Set) => blocked.has(geometryId) const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set) => { if (isBlocked(geometry.id, blocked)) return if (geometry.type === 'point') { geometry.position = { ...next }; return } if (geometry.type === 'line') { if (point === 'start') geometry.start = { ...next }; else if (point === 'end') geometry.end = { ...next }; return } if (point === 'center') geometry.center = { ...next } } const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => { if ('value' in constraint && (!Number.isFinite(constraint.value) || constraint.value < 0)) diagnostics.push({ code: 'INVALID_VALUE', constraintId: constraint.id, message: `Constraint '${constraint.id}' requires a finite non-negative value.` }) } const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], diagnostics: SketchDiagnostic[]): number => { if (constraint.type === 'horizontal' || constraint.type === 'vertical') { const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) if (!candidate || candidate.type !== 'line') return Infinity return constraint.type === 'horizontal' ? Math.abs(candidate.start.y - candidate.end.y) : Math.abs(candidate.start.x - candidate.end.x) } if (constraint.type === 'radius') { const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius - constraint.value) : Infinity } if (constraint.type === 'diameter') { const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) return candidate && (candidate.type === 'circle' || candidate.type === 'arc') ? Math.abs(candidate.radius * 2 - constraint.value) : Infinity } if (constraint.type === 'angle') { const candidate = findGeometry(geometry, constraint.geometryId, constraint.id, diagnostics) if (!candidate || candidate.type !== 'line') return Infinity return Math.abs(Math.atan2(candidate.end.y - candidate.start.y, candidate.end.x - candidate.start.x) - constraint.value) } if (constraint.type === 'equal') { const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics) const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics) return first && second ? Math.abs(lineLength(first) - lineLength(second)) : Infinity } if (constraint.type === 'symmetric') { const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics) const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics) const centerGeometry = findGeometry(geometry, constraint.center.geometryId, constraint.id, diagnostics) if (!firstGeometry || !secondGeometry || !centerGeometry) return Infinity const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics) return first && second && center ? Math.hypot((first.x + second.x) / 2 - center.x, (first.y + second.y) / 2 - center.y) : Infinity } if (constraint.type === 'tangent') { const first = findGeometry(geometry, constraint.firstGeometryId, constraint.id, diagnostics) const second = findGeometry(geometry, constraint.secondGeometryId, constraint.id, diagnostics) if (!first || !second) return Infinity if (first.type === 'line' && (second.type === 'circle' || second.type === 'arc')) return Math.abs(lineCircleTangentResidual(first, second)) if (second.type === 'line' && (first.type === 'circle' || first.type === 'arc')) return Math.abs(lineCircleTangentResidual(second, first)) if ((first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc')) return Math.abs(distance(first.center, second.center) - first.radius - second.radius) return Infinity } if (constraint.type === 'block') return 0 if (constraint.type !== 'coincident' && constraint.type !== 'distance' && constraint.type !== 'distanceX' && constraint.type !== 'distanceY') return Infinity const firstGeometry = findGeometry(geometry, constraint.first.geometryId, constraint.id, diagnostics) const secondGeometry = findGeometry(geometry, constraint.second.geometryId, constraint.id, diagnostics) if (!firstGeometry || !secondGeometry) return Infinity const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) if (!first || !second) return Infinity if (constraint.type === 'coincident') return distance(first, second) if (constraint.type === 'distance') return Math.abs(distance(first, second) - constraint.value) if (constraint.type === 'distanceX') return Math.abs(Math.abs(second.x - first.x) - constraint.value) return Math.abs(Math.abs(second.y - first.y) - constraint.value) } export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions = {}): SketchSolveResult => { const tolerance = options.tolerance ?? 1e-7 const maxIterations = options.maxIterations ?? 64 const snapshot = cloneSketch(input) const diagnostics: SketchDiagnostic[] = [] const geometryById = new Map(snapshot.geometry.map((geometry) => [geometry.id, geometry])) const blocked = new Set(snapshot.constraints.filter((constraint) => constraint.type === 'block').map((constraint) => constraint.geometryId)) snapshot.constraints.forEach((constraint) => validateConstraintValues(constraint, diagnostics)) let residual = Infinity let iterations = 0 for (; iterations < maxIterations && residual > tolerance && diagnostics.length === 0; iterations += 1) { for (const constraint of snapshot.constraints) { if (constraint.type === 'block') continue if (constraint.type === 'horizontal' || constraint.type === 'vertical') { const candidate = geometryById.get(constraint.geometryId) if (!candidate || candidate.type !== 'line') { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!isBlocked(candidate.id, blocked)) { if (constraint.type === 'horizontal') candidate.end = { ...candidate.end, y: candidate.start.y } else candidate.end = { ...candidate.end, x: candidate.start.x } } } else if (constraint.type === 'radius') { const candidate = geometryById.get(constraint.geometryId) if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value } else if (constraint.type === 'diameter') { const candidate = geometryById.get(constraint.geometryId) if (!candidate || (candidate.type !== 'circle' && candidate.type !== 'arc')) { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!isBlocked(candidate.id, blocked)) candidate.radius = constraint.value / 2 } else if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') { const firstGeometry = geometryById.get(constraint.first.geometryId) const secondGeometry = geometryById.get(constraint.second.geometryId) if (!firstGeometry || !secondGeometry) { findGeometry(snapshot.geometry, !firstGeometry ? constraint.first.geometryId : constraint.second.geometryId, constraint.id, diagnostics); continue } const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) if (!first || !second) continue const firstBlocked = isBlocked(firstGeometry.id, blocked) const secondBlocked = isBlocked(secondGeometry.id, blocked) if (constraint.type === 'coincident') { if (!firstBlocked && !secondBlocked) { const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; adjustPoint(firstGeometry, constraint.first.point, midpoint, blocked); adjustPoint(secondGeometry, constraint.second.point, midpoint, blocked) } else if (!firstBlocked) adjustPoint(firstGeometry, constraint.first.point, second, blocked) else if (!secondBlocked) adjustPoint(secondGeometry, constraint.second.point, first, blocked) } else { const dx = second.x - first.x const dy = second.y - first.y const current = constraint.type === 'distance' ? Math.hypot(dx, dy) : constraint.type === 'distanceX' ? Math.abs(dx) : Math.abs(dy) const delta = constraint.value - current if (Math.abs(delta) > tolerance && !secondBlocked) { if (constraint.type === 'distance') { const length = Math.hypot(dx, dy) || 1 adjustPoint(secondGeometry, constraint.second.point, { x: second.x + dx / length * delta, y: second.y + dy / length * delta }, blocked) } else if (constraint.type === 'distanceX') adjustPoint(secondGeometry, constraint.second.point, { x: first.x + (dx < 0 ? -constraint.value : constraint.value), y: second.y }, blocked) else adjustPoint(secondGeometry, constraint.second.point, { x: second.x, y: first.y + (dy < 0 ? -constraint.value : constraint.value) }, blocked) } } } else if (constraint.type === 'symmetric') { const firstGeometry = geometryById.get(constraint.first.geometryId) const secondGeometry = geometryById.get(constraint.second.geometryId) const centerGeometry = geometryById.get(constraint.center.geometryId) if (!firstGeometry || !secondGeometry || !centerGeometry) { findGeometry(snapshot.geometry, !firstGeometry ? constraint.first.geometryId : !secondGeometry ? constraint.second.geometryId : constraint.center.geometryId, constraint.id, diagnostics); continue } const first = pointFor(firstGeometry, constraint.first.point, constraint.id, diagnostics) const second = pointFor(secondGeometry, constraint.second.point, constraint.id, diagnostics) const center = pointFor(centerGeometry, constraint.center.point, constraint.id, diagnostics) if (!first || !second || !center) continue const reflected = { x: 2 * center.x - first.x, y: 2 * center.y - first.y } if (!isBlocked(secondGeometry.id, blocked)) adjustPoint(secondGeometry, constraint.second.point, reflected, blocked) } else if (constraint.type === 'tangent') { const first = geometryById.get(constraint.firstGeometryId) const second = geometryById.get(constraint.secondGeometryId) if (!first || !second) { findGeometry(snapshot.geometry, !first ? constraint.firstGeometryId : constraint.secondGeometryId, constraint.id, diagnostics); continue } const line = first.type === 'line' ? first : second.type === 'line' ? second : null const circle = first.type === 'line' ? second : second.type === 'line' ? first : null if (line && circle && (circle.type === 'circle' || circle.type === 'arc') && !isBlocked(circle.id, blocked)) { const dx = line.end.x - line.start.x const dy = line.end.y - line.start.y const length = Math.hypot(dx, dy) || 1 const signed = ((circle.center.x - line.start.x) * dy - (circle.center.y - line.start.y) * dx) / length const delta = circle.radius - Math.abs(signed) const sign = signed < 0 ? -1 : 1 circle.center = { x: circle.center.x + dy / length * delta * sign, y: circle.center.y - dx / length * delta * sign } } else if (first && second && (first.type === 'circle' || first.type === 'arc') && (second.type === 'circle' || second.type === 'arc') && !isBlocked(second.id, blocked)) { const dx = second.center.x - first.center.x const dy = second.center.y - first.center.y const length = Math.hypot(dx, dy) || 1 const target = first.radius + second.radius second.center = { x: first.center.x + dx / length * target, y: first.center.y + dy / length * target } } } else if (constraint.type === 'equal') { const first = geometryById.get(constraint.firstGeometryId) const second = geometryById.get(constraint.secondGeometryId) if (!first || !second) { findGeometry(snapshot.geometry, !first ? constraint.firstGeometryId : constraint.secondGeometryId, constraint.id, diagnostics); continue } if (!isBlocked(second.id, blocked)) { if ((second.type === 'circle' || second.type === 'arc') && (first.type === 'circle' || first.type === 'arc')) second.radius = first.radius else if (second.type === 'line' && first.type === 'line') { const current = lineLength(second) || 1; const target = lineLength(first); const scale = target / current; second.end = { x: second.start.x + (second.end.x - second.start.x) * scale, y: second.start.y + (second.end.y - second.start.y) * scale } } } } else if (constraint.type === 'angle') { const candidate = geometryById.get(constraint.geometryId) if (!candidate || candidate.type !== 'line') { findGeometry(snapshot.geometry, constraint.geometryId, constraint.id, diagnostics); continue } if (!isBlocked(candidate.id, blocked)) { const length = lineLength(candidate); candidate.end = { x: candidate.start.x + Math.cos(constraint.value) * length, y: candidate.start.y + Math.sin(constraint.value) * length } } } } residual = Math.max(0, ...snapshot.constraints.map((constraint) => residualFor(constraint, snapshot.geometry, diagnostics))) } const variableCount = snapshot.geometry.reduce((count, geometry) => count + (geometry.type === 'point' ? 2 : geometry.type === 'line' ? 4 : geometry.type === 'circle' ? 3 : 5), 0) const rank = Math.min(variableCount, snapshot.constraints.filter((constraint) => constraint.type !== 'block' || !isBlocked(constraint.geometryId, blocked)).length + blocked.size * 2) const degreesOfFreedom = Math.max(0, variableCount - rank) const status: SketchSolverStatus = diagnostics.length > 0 ? 'invalid' : residual <= tolerance ? degreesOfFreedom === 0 ? 'solved' : 'under-constrained' : 'conflicting' if (status === 'conflicting') diagnostics.push({ code: 'SOLVER_NOT_CONVERGED', message: `Sketch solver residual ${residual} exceeded tolerance ${tolerance}.` }) snapshot.solver = { status, degreesOfFreedom, residual, iterations, diagnostics } return { snapshot, status, degreesOfFreedom, residual, iterations, diagnostics } } export interface SketchSolverAdapter { solve(snapshot: SketchSnapshot, options?: SketchSolveOptions): Promise } export class BasicSketchSolverAdapter implements SketchSolverAdapter { solve(snapshot: SketchSnapshot, options?: SketchSolveOptions) { return Promise.resolve(solveSketch(snapshot, options)) } }