Appearance
Geometric constraints
Geometric constraints are valueless relations that the solver enforces between scene entities (nodes, segments, arcs, circles). They are stored as Constraint records (app/src/lib/fea2d/constraint-schema.ts:111) alongside SceneModel and resolved by the dual-path solver: the TypeScript Newton-Raphson solver (ts-solver.ts) and the Solvespace WASM solver (slvs-translate.ts). The 12 kinds enumerated in GeometricConstraintKind (constraint-schema.ts:13-25) are: horizontal, vertical, parallel, perpendicular, tangent, coincident, equal, midpoint, symmetric, onLine, onCircle, fixed. Dimensional constraints (which carry a driving value) are specified in dimensional.md.
1. Identity
A geometric constraint is a record { id, kind, entityA…D? } (constraint-schema.ts:111-153) that pins a geometric relation between entities without a driving value. Geometric constraints ignore value, valVarRef, and labelOffset.
2. When to use it
- Lock drafting intent before introducing dimensions (e.g., make a slot horizontal first, then dimension its length).
- Express manufacturing symmetry (mirror about a stator slot centreline).
- Anchor reference frames (
fixedon a single node to remove rigid-body DOF). - Enforce tangency between a coil end-turn arc and a slot wall line.
3. Inputs (file-level)
- Constraint palette — the Lock-iconed flyout in the geometry toolbar (
CanvasToolbar.tsx:639-857). Selection-aware enabling per kind (see selection / snapping / palette). - MotorScript builtins — every kind has a builtin in
executor/builtins/constraints.ts; signatures are listed per-kind below. See MotorScript for executor scoping. - Cmd+K (Command Palette) —
CommandPalette.tsxlists all 18 constraints via the registry inconstraintIcons.ts:68-89. - Keyboard shortcuts — wired in
Canvas/index.tsx:2580-2675(active whenactiveTool === "select"and selection is non-empty). Shortcut letters live inconstraintIcons.ts:208-227. Kinds with no global shortcut:symmetric,onLine,onCircle(registry returns'').
| Kind | Shortcut | Wired in Canvas? |
|---|---|---|
| horizontal | H | yes (Canvas/index.tsx:2583) |
| vertical | V | yes (Canvas/index.tsx:2594) — note ambiguity with Select tool shortcut V (gated by activeTool === "select" && selection.length > 0, Canvas/index.tsx:2580) |
| parallel | P | yes (Canvas/index.tsx:2605) |
| perpendicular | ⇧P | yes (Canvas/index.tsx:2605-2607, Shift branch) |
| tangent | T | yes (Canvas/index.tsx:2654) |
| coincident | C | yes (Canvas/index.tsx:2641) |
| equal | E | yes (Canvas/index.tsx:2621) |
| midpoint | M | yes (Canvas/index.tsx:2665) |
| symmetric | — | not wired (palette only) |
| onLine | — | not wired (palette only) |
| onCircle | — | not wired (palette only) |
| fixed | X | yes (Canvas/index.tsx:2648) |
4. Dispatch path
User-facing surfaces (palette / shortcut / Cmd+K) all funnel through:
applyConstraintToSelection(kind, value?)instore/constraints.ts:201— builds entity references from the current selection, runs pre-check guards (anchor-redundancy, duplicates, opposite-kind conflicts:store/constraints.ts:451-553), and either appends to the MotorScript source or callsaddConstraint.addConstraint(kind, refs)—store/constraints.ts:358-565— refuses virtualRef nodes (store/constraints.ts:376-390), validates finite/positive values for dimensional kinds, then commits viacommands.addConstraintand triggers a solver re-run.- The solver worker dispatches to the TS path (
ts-solver.ts:227-802) and/or WASM path (slvs-translate.ts:571-921).validate-residuals.ts:63-315performs the post-solve per-kind violation check used to populatefailedConstraintIds.
5. Entity layout cheatsheet
Mirror of the canonical comment block in constraint-schema.ts:91-109. All IDs are scene-graph IDs (node IDs, arc IDs, circle IDs) — never logical line UIDs minted at parse time.
| Kind | entityA | entityB | entityC | entityD |
|---|---|---|---|---|
| horizontal | n0 | n1 | — | — |
| vertical | n0 | n1 | — | — |
| parallel | n0A | n1A | n0B | n1B |
| perpendicular | n0A | n1A | n0B | n1B |
| coincident | nodeA | nodeB | — | — |
| fixed | node | — | — | — |
| equal (seg) | n0A | n1A | n0B | n1B |
| equal (arc/circ) | arcA / circA | arcB / circB | — (sentinel) | — |
| midpoint | point | lineN0 | lineN1 | — |
| onLine | point | lineN0 | lineN1 | — |
| symmetric | ptA | ptB | axN0 | axN1 |
| tangent (line+circ) | lineN0 | lineN1 | circleId | — |
| tangent (line+arc) | lineN0 | lineN1 | arcId | — |
| tangent (arc+arc/circ) | arcIdA | arcIdB | — | — |
| onCircle | pointId | circleId | — | — |
6. Failure modes (file-level)
- Degenerate selection: builtins throw a domain-specific error so the executor surfaces a line-tagged error rather than a raw
TypeError. Examples:coincidentwith same-id is rejected atconstraints.ts:141;tangent(line, line)is rejected atconstraints.ts:130-133. - VirtualRef refusal:
addConstraintrefuses to constrain a node whosevirtualRefis set (e.g. a crossing's virtual intersection point).store/constraints.ts:376-390. - Anchor redundancy: a constraint whose every referenced node is already xy-anchored is refused as redundant.
store/constraints.ts:451-457. - Opposite-kind conflicts: applying
horizontalto a segment that already hasvertical(and vice versa) is refused before adding.store/constraints.ts:506-509. - Duplicate suppression: an identical (kind, entities, value) constraint is refused.
store/constraints.ts:533. - Solver divergence: post-solve, any constraint whose
validate-residualsrow exceedsTOL.CONVERGENCElands insolverStatus.failedConstraintIdsand renders a red pill (ts-solver.ts:886-890). UI derives thefailedboolean from this set — there is noConstraint.failedfield. - WASM entity-slot requirements: certain kinds require a specific entity layout in their slot encoding (e.g. midpoint / onLine pass a LINE entity in
entityA, not two point entities); per-kind sections below call out the layout.
Per-kind specs
horizontal
Figure: horizontal constraint — before (dashed) vs after (solid).
Identity: A point pair has equal y-coordinates (i.e., the segment between them is parallel to the world x-axis). Applies to: one segment (n0, n1) OR two nodes. Encoded as entityA=n0, entityB=n1 (constraint-schema.ts:95). MotorScript: horizontal(line: LineRef): void — executor/builtins/constraints.ts:67-74. Solver math (TS path): residual f = y0 − y1. ts-solver.ts:242-252. Jacobian rows: ∂f/∂y0 = 1, ∂f/∂y1 = −1. Post-solve residual at ts-solver.ts:956-957. Solver math (WASM path): C_HORIZONTAL in point-pair form — slvs-translate.ts:572-578. Avoids needing to look up the Line2D entity. Palette: row 1 of the Geometric group (CanvasToolbar.tsx:665); icon MoveHorizontal (Lucide) — registry constraintIcons.ts:105. Auto-emit: no. Known bugs: None known.
vertical
Figure: vertical constraint — before (dashed) vs after (solid).
Identity: A point pair has equal x-coordinates (segment parallel to world y-axis). Applies to: one segment or two nodes. entityA=n0, entityB=n1. MotorScript: vertical(line: LineRef): void — constraints.ts:75-80. Solver math (TS path): residual f = x0 − x1. ts-solver.ts:255-265. Jacobian: ∂f/∂x0 = 1, ∂f/∂x1 = −1. Post-solve residual ts-solver.ts:958-959. Solver math (WASM path): C_VERTICAL in point-pair form — slvs-translate.ts:579-584. Palette: row 2 (CanvasToolbar.tsx:666); icon MoveVertical — constraintIcons.ts:106. Auto-emit: no. Known bugs: the keyboard shortcut V is shared with the Select-tool activation; the gate at Canvas/index.tsx:2580 resolves precedence but means V is silently a no-op when select is active with no selection (no toast).
parallel
Figure: parallel constraint — before (dashed) vs after (solid).
Identity: Two segments have collinear direction vectors. Applies to: two segments. entityA=n0A, entityB=n1A, entityC=n0B, entityD=n1B (constraint-schema.ts:96). MotorScript: parallel(a: LineRef, b: LineRef): void — constraints.ts:81-90. Solver math (TS path): residual f = dxa·dyb − dya·dxb (z-component of cross). ts-solver.ts:427-443. Post-solve residual is length-normalised to sin(misalignment) (ts-solver.ts:980-989). Solver math (WASM path): C_PARALLEL with LINE entities resolved via findSegmentByEndpoints (point entities cause a WASM abort in EntityBase::VectorGetExprsInWorkplane). slvs-translate.ts:585-609. Palette: row 3 (CanvasToolbar.tsx:667); icon ParallelIcon (bespoke SVG, two parallel lines) — CanvasToolbar.tsx:150-155. Registry default ArrowLeftRight — constraintIcons.ts:107. Auto-emit: no. Known bugs: None known.
perpendicular
Figure: perpendicular constraint — before (dashed) vs after (solid).
Identity: Two segments have orthogonal direction vectors. Applies to: two segments. Same encoding as parallel. MotorScript: perpendicular(a: LineRef, b: LineRef): void — constraints.ts:91-100. Solver math (TS path): residual f = dxa·dxb + dya·dyb (dot product). ts-solver.ts:446-462. Post-solve length-normalised dot at ts-solver.ts:990-997. Solver math (WASM path): C_PERPENDICULAR with LINE entities — slvs-translate.ts:610-633. Same point-entity abort behaviour as parallel. Palette: row 4 (CanvasToolbar.tsx:668); icon PerpendicularIcon (bespoke SVG with right-angle tick) — CanvasToolbar.tsx:157-163. Auto-emit: no. Known bugs: None known.
tangent
Figure: tangent constraint — before (dashed) vs after (solid).
Identity: A line is tangent to an arc/circle (perpendicular distance = radius), or two arcs/circles meet at a single point (centre distance = r1±r2). Applies to: three forms:
- line + circle:
entityA=lineN0,entityB=lineN1,entityC=circleId. - line + arc: same layout,
entityC=arcId. - arc + arc/circle:
entityA=arcIdA,entityB=arcIdB,entityC=undefined. In the TS path this is failure-only — it emits a residual row with zero gradient so the post-solve pass surfaces violations, but Newton cannot drive the centres (arc / circle centres are not node DOFs in the TS solver). The WASM path enforces it properly. MotorScript:tangent(a: LineRef|ArcRef|CircleRef, b: LineRef|ArcRef|CircleRef): void—constraints.ts:101-134. Explicit dispatch picks the layout; mixed line+line throws. Solver math (TS path): line + arc / circle uses linear residualf = |num|/L − rwherenum = dx·(y0−cy) − (x0−cx)·dyandL = |(x1−x0, y1−y0)|.ts-solver.ts:528-574. The linear form (rather than quadratic) avoids Newton overshoot on large initial gaps. Arc + arc branch atts-solver.ts:508-527picks external vs internal tangency by initial gap, emits residual without Jacobian. Post-solve atts-solver.ts:1045-1072. Solver math (WASM path):C_ARC_LINE_TANGENTfor line + arc / circle (slvs-translate.ts:752-803); when the referenced circle has been decomposed into arcs bywakeUpCirclesCrossingSegment, falls back to the first arc withdecomposedFromCircleId === entityC.C_CURVE_CURVE_TANGENTfor arc + arc / circle (slvs-translate.ts:804-808). Palette: row 8 (CanvasToolbar.tsx:672); iconTangentIcon(bespoke SVG — circle + grazing line + tangent point) —CanvasToolbar.tsx:165-171. Auto-emit: yes — thearcTangent2-click arc tool emits atangentConstraint withconstraintKind: 'tangent'against the source segment / arc (tools/arc-tool.ts:87). The fillet tool's tangent locks at parent edges are separate. Known bugs: TS arc + arc path is intentionally failure-only (centres are not DOFs in the TS solver). Use the WASM path for full enforcement.
coincident
Figure: coincident constraint — before (dashed) vs after (solid).
Identity: Two nodes occupy the same point in space. Applies to: two distinct nodes. entityA=nodeA, entityB=nodeB (constraint-schema.ts:97). MotorScript: coincident(a: PointRef, b: PointRef): void — constraints.ts:135-145. Same-id is rejected with a domain error (would inflate DOF count via a trivially-satisfied row). Solver math (TS path): two residual rows x0 − x1 = 0 and y0 − y1 = 0 (ts-solver.ts:282-300). Post-solve uses combined hypot ts-solver.ts:967-969. Solver math (WASM path): C_POINTS_COINCIDENT — slvs-translate.ts:634-639. Palette: row 6 (CanvasToolbar.tsx:670); icon CoincidentIcon (bespoke SVG — two overlapping circles) — CanvasToolbar.tsx:181-186. Registry default GitMerge — constraintIcons.ts:110. Auto-emit: yes — node merging during draw / crossing materialisation can emit a coincident constraint when two nodes resolve to the same world position. See store/constraints.ts apply path and the crossing primitive spec. Known bugs: None known.
equal
Figure: equal constraint — before (dashed) vs after (solid).
Identity: Two segments have equal length, or two arcs/circles have equal radius. Applies to: two segments OR two arcs / circles. Dispatch is determined by whether entityC is set (ts-solver.ts:374; mirrored in slvs-translate.ts:650).
- Segments:
entityA=n0A,entityB=n1A,entityC=n0B,entityD=n1B. - Arcs / Circles:
entityA=arcA|circA,entityB=arcB|circB,entityC=undefined. MotorScript:equal(a: LineRef|ArcRef|CircleRef, b: LineRef|ArcRef|CircleRef): void—constraints.ts:146-160. Solver math (TS path): segment branch — residualf = |A|² − |B|²(squared lengths), Jacobiants-solver.ts:389-424. Arc / circle branch — direct comparison of working radii, trivial-zero rowts-solver.ts:374-388. Post-solve atts-solver.ts:998-1012(uses non-squared lengths for the segment form). Solver math (WASM path):C_EQUAL_LENGTH_LINESfor segments (requires LINE entities) orC_EQUAL_RADIUSfor arcs / circles —slvs-translate.ts:646-681. Palette: row 5 (CanvasToolbar.tsx:669); iconEqual(Lucide) —constraintIcons.ts:111. Auto-emit: yes —polygonprimitive emitsequalconstraints across its sides;rectangleemits paired equals; see modify/copy-and-array.md and the polygon primitive spec. Known bugs: None known.
midpoint
Figure: midpoint constraint — before (dashed) vs after (solid).
Identity: A point is the midpoint of a segment. Applies to: 1 node + 1 segment. entityA=point, entityB=lineN0, entityC=lineN1 (constraint-schema.ts:101). MotorScript: midpoint(p: PointRef, line: LineRef): void — constraints.ts:161-170. Solver math (TS path): two residual rows xp − (x0+x1)/2 = 0 and yp − (y0+y1)/2 = 0 (ts-solver.ts:465-480). Post-solve hypot at ts-solver.ts:1034-1037. Solver math (WASM path): C_AT_MIDPOINT with LINE entity in slot entityA. slvs-translate.ts:682-707. Palette: row 9 (CanvasToolbar.tsx:673); icon MidpointIcon (bespoke SVG — line with tick + dot) — CanvasToolbar.tsx:173-179. Registry default AlignCenter — constraintIcons.ts:112. Auto-emit: no (user-driven only). The trim/fillet tools do not emit midpoint constraints. Known bugs: None known.
symmetric
Figure: symmetric constraint — before (dashed) vs after (solid).
Identity: Two points are mirror images across a third segment (the axis). Applies to: 2 nodes + 1 segment (axis). entityA=ptA, entityB=ptB, entityC=axN0, entityD=axN1 (constraint-schema.ts:103). MotorScript: symmetric(a: PointRef, b: PointRef, axis: LineRef): void — constraints.ts:171-183. Solver math (TS path): two residual rows — (1) midpoint of (pA,pB) lies on axis, (2) (pB−pA) is perpendicular to axis direction. ts-solver.ts:577-604. Post-solve combined residual at ts-solver.ts:1073-1082. Solver math (WASM path): C_SYMMETRIC_LINE with a single LINE entity for the axis. slvs-translate.ts:844-868. Palette: not in palette (the ConstraintPalette lists 9 geometric kinds; symmetric is omitted, CanvasToolbar.tsx:664-674). Reachable via Cmd+K (CommandPalette.tsx via ALL_CONSTRAINT_KINDS) or MotorScript only. Registry icon: GitFork — constraintIcons.ts:113. Auto-emit: yes — the mirror modify-op may emit symmetric pairs depending on settings. See modify/mirror.md. Known bugs: None known. (Surface gap: not exposed in toolbar palette — flagged as a UX hole, not a bug.)
onLine
Figure: onLine constraint — before (dashed) vs after (solid).
Identity: A point lies on the infinite line through two other points. Applies to: 1 node + 1 segment. entityA=point, entityB=lineN0, entityC=lineN1 (constraint-schema.ts:102). MotorScript: onLine(p: PointRef, line: LineRef): void — constraints.ts:184-193. Solver math (TS path): residual f = dx·(yp − y0) − dy·(xp − x0) (signed perpendicular distance × L). ts-solver.ts:483-496. Post-solve normalised to perpendicular distance at ts-solver.ts:1038-1044. Solver math (WASM path): C_PT_ON_LINE with the segment's LINE entity in slot entityA. slvs-translate.ts:812-835. Palette: not in palette (omitted from ConstraintPalette list). Cmd+K / MotorScript only. Registry icon: Minus — constraintIcons.ts:114. Auto-emit: yes — node-on-segment merge during draw (when a click lands on an existing segment, the new node may be emitted with an onLine constraint rather than a hard split). Cross-ref the node primitive spec. Known bugs: None known.
onCircle
Figure: onCircle constraint — before (dashed) vs after (solid).
Identity: A point lies on the circumference of a circle. Applies to: 1 node + 1 circle. entityA=pointId, entityB=circleId. MotorScript: onCircle(p: PointRef, circle: CircleRef): void — constraints.ts:194-203. Solver math (TS path): residual f = √((px−cx)² + (py−cy)²) − r. ts-solver.ts:771-788. Post-solve at ts-solver.ts:974-979. Note the circle centre/radius are NOT node DOFs — only the point can move to satisfy the equation. Solver math (WASM path): C_PT_ON_CIRCLE — slvs-translate.ts:836-843. The workplane (wp) must be passed (not E_NONE); otherwise the constraint is silently dropped. Palette: not in palette. Cmd+K / MotorScript only. Registry icon: Circle — constraintIcons.ts:115. Auto-emit: no (no caller in current builtins). Known bugs: None known.
fixed
Figure: fixed constraint — pinned node (lock glyph) with surrounding geometry free.
Identity: A node is pinned at its current position (zero translational DOF for that node). Applies to: one node. entityA=nodeId (constraint-schema.ts:98). MotorScript: fixed(p: PointRef): void — constraints.ts:204-210. Solver math (TS path): two residual rows (cx − orig.x) = 0 and (cy − orig.y) = 0 where orig is the node's position at solver entry. ts-solver.ts:268-279. Post-solve at ts-solver.ts:970-973. Equivalent in effect to a node anchor === 'xy' lock, but applied as a constraint record rather than a node attribute. Solver math (WASM path): slvs.dragged(g, pt, wp) — pins the point and removes its DOF. slvs-translate.ts:640-645. Palette: row 7 (CanvasToolbar.tsx:671); icon Lock (Lucide) — constraintIcons.ts:116. Auto-emit: yes — the auxiliary axes for distanceH / distanceV allocate pinned origin / tip points via slvs.dragged (slvs-translate.ts:891, 901); these are internal anchors, not user-visible fixed constraints, but follow the same DOF mechanic. Known bugs: None known. Related caveat: fixed interacts with store/constraints.ts:451 anchor-redundancy guard — a fixed on an already xy-anchored node is refused as redundant.
Class API
Every constraint is an instance of a concrete subclass of the abstract Constraint base — there is no plain-object Constraint shape backed by a switch (c.kind) dispatch surface.
- Abstract base:
app/src/lib/fea2d/model/Constraint.ts—Constraint extends SceneEntity. Declares the polymorphic surface (six abstract methods) that every concrete subclass implements. - Concrete subclasses (12 geometric): each in
app/src/lib/fea2d/model/constraints/:CoincidentConstraint.ts,HorizontalConstraint.ts,VerticalConstraint.ts,ParallelConstraint.ts,PerpendicularConstraint.ts,TangentConstraint.ts,EqualConstraint.ts,SymmetricConstraint.ts,MidpointConstraint.ts,OnLineConstraint.ts,OnCircleConstraint.ts,FixedConstraint.ts.
- Constructor (typical):
new <Kind>Constraint(id: ConstraintId, entityA: <Ref>, entityB?: <Ref>, …, valVarRef = null, labelOffset = null). Each subclass narrowsentityA/entityB/entityC/entityDto the branded id types matching the entity-layout cheatsheet above. - Snapshot type:
ConstraintSnapshot(app/src/lib/fea2d/model/snapshots/Constraint.ts) — the persisted plain-object shape; carries thekinddiscriminator. The base'sstatic fromJSON(snap)dispatches through a subclass registry populated at module-init byregisterConstraintCtor. - Polymorphic surface (the kill of
switch (c.kind)) — every subclass implements:entityIds: EntityRef[]— all entity ids the constraint depends on, in encoding order. Drives cascade-delete (defaultreferencesEntitywalks this list), solver translation handle ordering, and the inspector's "depends on" listing.isDimensional: boolean— returnsfalsefor all 12 geometric kinds.toSlvs(ctx: SlvsTranslateCtx): SlvsConstraint[]— emits one or more Solvespace constraint records via the loosely-typed context (workplane,group,handleFor(id),mintSlvsConstraintId, optional helpers). This single seam replaces everyswitch (constraint.kind)block previously scattered acrossslvs-translate.ts.describe(scene): string— short human-readable label for inspector / dim-annotation rendering.evaluate(scene): ConstraintResidual— TS-side residual + gradient + satisfied-within-tolerance flag.toJSON: ConstraintSnapshot,clone: <Kind>Constraint(narrowed return type).
kind is readonly; changing a constraint's kind means delete + re-add through Scene. valVarRef, labelOffset, and any subclass-specific scalar value are mutable so inspector edits write back in place.
The math / algorithm bodies described in §6 are unchanged by the class-based dispatch surface — every formula previously written inline in a case 'horizontal': arm lives in HorizontalConstraint.evaluate(scene), with behaviour verified by per-kind tests.