Skip to content

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 (fixed on 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.tsx lists all 18 constraints via the registry in constraintIcons.ts:68-89.
  • Keyboard shortcuts — wired in Canvas/index.tsx:2580-2675 (active when activeTool === "select" and selection is non-empty). Shortcut letters live in constraintIcons.ts:208-227. Kinds with no global shortcut: symmetric, onLine, onCircle (registry returns '').
KindShortcutWired in Canvas?
horizontalHyes (Canvas/index.tsx:2583)
verticalVyes (Canvas/index.tsx:2594) — note ambiguity with Select tool shortcut V (gated by activeTool === "select" && selection.length > 0, Canvas/index.tsx:2580)
parallelPyes (Canvas/index.tsx:2605)
perpendicular⇧Pyes (Canvas/index.tsx:2605-2607, Shift branch)
tangentTyes (Canvas/index.tsx:2654)
coincidentCyes (Canvas/index.tsx:2641)
equalEyes (Canvas/index.tsx:2621)
midpointMyes (Canvas/index.tsx:2665)
symmetricnot wired (palette only)
onLinenot wired (palette only)
onCirclenot wired (palette only)
fixedXyes (Canvas/index.tsx:2648)

4. Dispatch path

User-facing surfaces (palette / shortcut / Cmd+K) all funnel through:

  1. applyConstraintToSelection(kind, value?) in store/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 calls addConstraint.
  2. 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 via commands.addConstraint and triggers a solver re-run.
  3. 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-315 performs the post-solve per-kind violation check used to populate failedConstraintIds.

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.

KindentityAentityBentityCentityD
horizontaln0n1
verticaln0n1
paralleln0An1An0Bn1B
perpendicularn0An1An0Bn1B
coincidentnodeAnodeB
fixednode
equal (seg)n0An1An0Bn1B
equal (arc/circ)arcA / circAarcB / circB— (sentinel)
midpointpointlineN0lineN1
onLinepointlineN0lineN1
symmetricptAptBaxN0axN1
tangent (line+circ)lineN0lineN1circleId
tangent (line+arc)lineN0lineN1arcId
tangent (arc+arc/circ)arcIdAarcIdB
onCirclepointIdcircleId

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: coincident with same-id is rejected at constraints.ts:141; tangent(line, line) is rejected at constraints.ts:130-133.
  • VirtualRef refusal: addConstraint refuses to constrain a node whose virtualRef is 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 horizontal to a segment that already has vertical (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-residuals row exceeds TOL.CONVERGENCE lands in solverStatus.failedConstraintIds and renders a red pill (ts-solver.ts:886-890). UI derives the failed boolean from this set — there is no Constraint.failed field.
  • 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

beforeHafter

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): voidexecutor/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

beforeVafter

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): voidconstraints.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 MoveVerticalconstraintIcons.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

beforeafter

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): voidconstraints.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 ArrowLeftRightconstraintIcons.ts:107. Auto-emit: no. Known bugs: None known.

perpendicular

beforeafter

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): voidconstraints.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

beforeTafter

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): voidconstraints.ts:101-134. Explicit dispatch picks the layout; mixed line+line throws. Solver math (TS path): line + arc / circle uses linear residual f = |num|/L − r where num = dx·(y0−cy) − (x0−cx)·dy and L = |(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 at ts-solver.ts:508-527 picks external vs internal tangency by initial gap, emits residual without Jacobian. Post-solve at ts-solver.ts:1045-1072. Solver math (WASM path): C_ARC_LINE_TANGENT for line + arc / circle (slvs-translate.ts:752-803); when the referenced circle has been decomposed into arcs by wakeUpCirclesCrossingSegment, falls back to the first arc with decomposedFromCircleId === entityC. C_CURVE_CURVE_TANGENT for arc + arc / circle (slvs-translate.ts:804-808). Palette: row 8 (CanvasToolbar.tsx:672); icon TangentIcon (bespoke SVG — circle + grazing line + tangent point) — CanvasToolbar.tsx:165-171. Auto-emit: yes — the arcTangent 2-click arc tool emits a tangent Constraint with constraintKind: '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

beforeafter

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): voidconstraints.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_COINCIDENTslvs-translate.ts:634-639. Palette: row 6 (CanvasToolbar.tsx:670); icon CoincidentIcon (bespoke SVG — two overlapping circles) — CanvasToolbar.tsx:181-186. Registry default GitMergeconstraintIcons.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

before=after

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): voidconstraints.ts:146-160. Solver math (TS path): segment branch — residual f = |A|² − |B|² (squared lengths), Jacobian ts-solver.ts:389-424. Arc / circle branch — direct comparison of working radii, trivial-zero row ts-solver.ts:374-388. Post-solve at ts-solver.ts:998-1012 (uses non-squared lengths for the segment form). Solver math (WASM path): C_EQUAL_LENGTH_LINES for segments (requires LINE entities) or C_EQUAL_RADIUS for arcs / circles — slvs-translate.ts:646-681. Palette: row 5 (CanvasToolbar.tsx:669); icon Equal (Lucide) — constraintIcons.ts:111. Auto-emit: yes — polygon primitive emits equal constraints across its sides; rectangle emits paired equals; see modify/copy-and-array.md and the polygon primitive spec. Known bugs: None known.

midpoint

beforeMafter

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): voidconstraints.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 AlignCenterconstraintIcons.ts:112. Auto-emit: no (user-driven only). The trim/fillet tools do not emit midpoint constraints. Known bugs: None known.

symmetric

beforeafter

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): voidconstraints.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: GitForkconstraintIcons.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

beforeafter

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): voidconstraints.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: MinusconstraintIcons.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

beforeafter

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): voidconstraints.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_CIRCLEslvs-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: CircleconstraintIcons.ts:115. Auto-emit: no (no caller in current builtins). Known bugs: None known.

fixed

fixed node — surrounding geometry free

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): voidconstraints.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.tsConstraint 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 narrows entityA / entityB / entityC / entityD to 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 the kind discriminator. The base's static fromJSON(snap) dispatches through a subclass registry populated at module-init by registerConstraintCtor.
  • 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 (default referencesEntity walks this list), solver translation handle ordering, and the inspector's "depends on" listing.
    • isDimensional: boolean — returns false for 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 every switch (constraint.kind) block previously scattered across slvs-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.

motordevs studio — geometry editor specification