feat: model advanced sketch curves

This commit is contained in:
2026-08-02 23:52:06 -04:00
parent e0b4817ec0
commit 6786261f16
5 changed files with 49 additions and 12 deletions

View File

@@ -5,6 +5,8 @@ export type SketchGeometry =
| { 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 }
| { id: string; type: 'ellipse'; center: SketchPoint; majorRadius: number; minorRadius: number; rotation: number; construction?: boolean }
| { id: string; type: 'bspline'; degree: number; controlPoints: SketchPoint[]; weights?: number[]; knots?: number[]; periodic?: boolean; construction?: boolean }
export type SketchPointRef = { geometryId: string; point: 'start' | 'end' | 'center' | 'position' }
@@ -23,7 +25,8 @@ export type SketchConstraint =
export type SketchSolverStatus = 'solved' | 'under-constrained' | 'conflicting' | 'invalid'
export type SketchDiagnostic = {
code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED'
code: 'UNKNOWN_GEOMETRY' | 'UNKNOWN_POINT' | 'INVALID_VALUE' | 'UNSUPPORTED_GEOMETRY' | 'CONSTRAINT_CONFLICT' | 'SOLVER_NOT_CONVERGED'
geometryId?: string
constraintId?: string
message: string
}
@@ -57,22 +60,28 @@ export type SketchSolveResult = {
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 } }
if (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') return { ...geometry, center: { ...geometry.center } }
if (geometry.type === 'bspline') return { ...geometry, controlPoints: geometry.controlPoints.map((point) => ({ ...point })), weights: geometry.weights ? [...geometry.weights] : undefined, knots: geometry.knots ? [...geometry.knots] : undefined }
return { ...geometry, position: { ...geometry.position } }
}
const cloneConstraint = (constraint: SketchConstraint): SketchConstraint => {
if (constraint.type === 'coincident' || constraint.type === 'distance' || constraint.type === 'distanceX' || constraint.type === 'distanceY') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second } }
if (constraint.type === 'symmetric') return { ...constraint, first: { ...constraint.first }, second: { ...constraint.second }, center: { ...constraint.center } }
return { ...constraint }
}
export const cloneSketch = (sketch: SketchSnapshot): SketchSnapshot => ({
...sketch,
geometry: sketch.geometry.map(cloneGeometry),
constraints: sketch.constraints.map((constraint) => ({ ...constraint })),
constraints: sketch.constraints.map(cloneConstraint),
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 })),
constraints: constraints.map(cloneConstraint),
solver: { status: geometry.length === 0 ? 'solved' : 'under-constrained', degreesOfFreedom: 0, residual: 0, iterations: 0, diagnostics: [] },
})
@@ -85,7 +94,7 @@ const findGeometry = (geometry: SketchGeometry[], id: string, constraintId: stri
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
if ((geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse') && point === 'center') return geometry.center
diagnostics.push({ code: 'UNKNOWN_POINT', constraintId, message: `Point '${point}' is not valid for ${geometry.type} '${geometry.id}'.` })
return null
}
@@ -109,7 +118,7 @@ const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], n
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 }
if (point === 'center' && (geometry.type === 'circle' || geometry.type === 'arc' || geometry.type === 'ellipse')) geometry.center = { ...next }
}
const validateConstraintValues = (constraint: SketchConstraint, diagnostics: SketchDiagnostic[]) => {
@@ -178,6 +187,9 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
const maxIterations = options.maxIterations ?? 64
const snapshot = cloneSketch(input)
const diagnostics: SketchDiagnostic[] = []
for (const geometry of snapshot.geometry) {
if (geometry.type === 'ellipse' || geometry.type === 'bspline') diagnostics.push({ code: 'UNSUPPORTED_GEOMETRY', geometryId: geometry.id, message: `The typescript-basic solver does not solve ${geometry.type} geometry '${geometry.id}'.` })
}
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))
@@ -275,7 +287,13 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
}
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 variableCount = snapshot.geometry.reduce((count, geometry) => {
if (geometry.type === 'point') return count + 2
if (geometry.type === 'line') return count + 4
if (geometry.type === 'circle') return count + 3
if (geometry.type === 'arc' || geometry.type === 'ellipse') return count + 5
return count + geometry.controlPoints.length * 2 + (geometry.weights?.length ?? 0)
}, 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'