P4: extend sketch constraints and expression functions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user