P4: extend sketch constraints and expression functions

This commit is contained in:
2026-08-02 17:35:56 -04:00
parent 9c76f33f3d
commit 171fd670af
6 changed files with 142 additions and 8 deletions

View File

@@ -6,15 +6,18 @@ export type SketchGeometry =
| { 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' }
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'
@@ -80,7 +83,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') return geometry.position
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}'.` })
@@ -91,6 +94,15 @@ 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<string>) => blocked.has(geometryId)
const adjustPoint = (geometry: SketchGeometry, point: SketchPointRef['point'], next: SketchPoint, blocked: Set<string>) => {
@@ -114,6 +126,10 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d
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
@@ -124,6 +140,25 @@ const residualFor = (constraint: SketchConstraint, geometry: SketchGeometry[], d
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)
@@ -162,6 +197,10 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
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)
@@ -188,6 +227,38 @@ export const solveSketch = (input: SketchSnapshot, options: SketchSolveOptions =
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)

View File

@@ -63,7 +63,7 @@ const tokenize = (source: string): Token[] => {
while (index < source.length) {
const character = source[index]
if (/\s/.test(character)) { index += 1; continue }
if ('+-*/()'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue }
if ('+-*/(),'.includes(character)) { tokens.push({ kind: 'operator', text: character, position: index }); index += 1; continue }
if (character === '%') { tokens.push({ kind: 'identifier', text: character, position: index }); index += 1; continue }
const number = source.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)
if (number) { tokens.push({ kind: 'number', text: number[0], position: index }); index += number[0].length; continue }
@@ -95,6 +95,37 @@ const divide = (left: Quantity, right: Quantity): Quantity => {
throw new TypeError(`Cannot divide ${left.dimension} by ${right.dimension}.`)
}
const evaluateFunction = (name: string, args: Quantity[]): Quantity => {
const normalized = name.toLowerCase()
if (normalized === 'abs' || normalized === 'round') {
if (args.length !== 1) throw new TypeError(`${name} requires one argument.`)
return { value: normalized === 'abs' ? Math.abs(args[0].value) : Math.round(args[0].value), dimension: args[0].dimension }
}
if (normalized === 'sin' || normalized === 'cos' || normalized === 'tan') {
if (args.length !== 1 || (args[0].dimension !== 'angle' && args[0].dimension !== 'dimensionless')) throw new TypeError(`${name} requires an angle or dimensionless argument.`)
const radians = args[0].dimension === 'angle' ? args[0].value * Math.PI / 180 : args[0].value
return { value: Math[normalized](radians), dimension: 'dimensionless' }
}
if (normalized === 'atan2') {
if (args.length !== 2 || !sameDimension(args[0], args[1])) throw new TypeError('atan2 requires two values with the same dimension.')
return { value: Math.atan2(args[0].value, args[1].value) * 180 / Math.PI, dimension: 'angle' }
}
if (normalized === 'min' || normalized === 'max') {
if (args.length < 1 || args.some((argument) => !sameDimension(argument, args[0]))) throw new TypeError(`${name} requires at least one set of same-dimension values.`)
const values = args.map((argument) => argument.value)
return { value: normalized === 'min' ? Math.min(...values) : Math.max(...values), dimension: args[0].dimension }
}
if (normalized === 'clamp') {
if (args.length !== 3 || !sameDimension(args[0], args[1]) || !sameDimension(args[0], args[2])) throw new TypeError('clamp requires three values with the same dimension.')
return { value: Math.min(Math.max(args[0].value, args[1].value), args[2].value), dimension: args[0].dimension }
}
if (normalized === 'pow') {
if (args.length !== 2 || args[0].dimension !== 'dimensionless' || args[1].dimension !== 'dimensionless') throw new TypeError('pow requires two dimensionless values.')
return { value: Math.pow(args[0].value, args[1].value), dimension: 'dimensionless' }
}
throw new ReferenceError(`Unknown expression function: ${name}.`)
}
class QuantityParser {
private index = 0
readonly references = new Set<string>()
@@ -157,6 +188,14 @@ class QuantityParser {
}
if (token.kind === 'identifier') {
if (token.text.toLowerCase() === 'pi') return quantityFromNumber(Math.PI)
if (this.match('(')) {
const args: Quantity[] = []
if (!this.match(')')) {
do args.push(this.parseAddSub()); while (this.match(','))
if (!this.match(')')) throw new SyntaxError(`Missing ')' at position ${this.peek().position}.`)
}
return evaluateFunction(token.text, args)
}
const value = this.variables.get(token.text)
if (!value) throw new ReferenceError(`Unknown expression reference: ${token.text}.`)
this.references.add(token.text)