Appearance
Spline
1. Identity
A spline is a cubic-Hermite curve that passes exactly through an ordered chain of points [n0, ...controls, n1]. Tangents at each interpolation point default to Catmull-Rom (cardinal spline, tension τ = 0.5 — interior m_i = ½ · (P_{i+1} − P_{i−1}), boundary m_0 = ½ · (P_1 − P_0)). Catmull-Rom is locally controlled — moving one point only affects the two adjacent segments — which matches the intuitive "pen tool" feel for user-clicked input. Per-point tangents can be explicitly overridden via the tangent DSL (tan / lock / parallel(seg) / perpendicular(seg)).
2. When to use it
- Stator pole-face contours, magnet pocket bulges, fillet-radius cheats where a single arc cannot match boundary curvature.
- Curve-fitting an imported point chain (probed measurement, scanned profile).
- Free-form region boundaries where multiple tangencies must be honoured but the user does not want to constrain every intermediate arc.
- NOT for analytical shapes (circles, single radii) — prefer Arc / Circle so the solver sees one degree of freedom rather than 2·N.
3. Inputs
- Tool button: sketch toolbar, pen-nib icon.
- Keyboard shortcut: not yet wired (no shortcut dispatched at
Canvas/index.tsx:2716). - MotorScript builtins:
Bare form (auto tangents at every point — natural cubic):
spline(p1, p2, ..., pN)— at least 3 points, first and last become endpoint nodes; interior args becomecontrols[].Paired form (per-point tangent override):
spline(p1, tan(dx, dy), p2, auto, p3, parallel(seg), p4, lock)— each point may be followed by a tangent spec. Tangent specs:tan(dx, dy)— explicit tangent vector (direction + magnitude); seeprimitives.ts:tan.auto— natural-cubic auto-derive (default; omit-equivalent).lock— endpoint-only; freezes the tangent to the segment/arc connected at that endpoint viatangentLockStart/tangentLockEnd. Throws if placed on a non-endpoint.parallel(seg)— direction parallel to segment; magnitude from auto-solver, sign matched to auto to avoid flips.perpendicular(seg)— direction perpendicular to segment (dot product zero); magnitude/sign as above.
The bare and paired forms can be mixed (
spline(p1, p2, tan(1,0), p3)); points without a following tangent spec default toauto. Seeprimitives.ts:spline.
- Command Palette: not yet wired.
- Context menu: not yet wired.
4. State machine
- Click 1 (first endpoint) — required: a point in world space (snap-eligible to existing nodes via selection-snapping).
- Preview after step 1: a single dot at the click position; cursor crosshair active.
- Cancel: Esc clears draft; tool re-selection clears draft; switching tabs clears draft.
- Click 2 (second point) — second control / endpoint candidate.
- Preview after step 2: a straight Hermite line preview between the two clicked points (n=2 collapses to the chord — see
solveNaturalCubicTangentsn===2 branch atspline.ts:76).
- Preview after step 2: a straight Hermite line preview between the two clicked points (n=2 collapses to the chord — see
- Click N (N ≥ 3) — adds another interior interpolation point to the draft.
- Preview after step N: the preview is drawn via the SAME Catmull-Rom Hermite math the commit uses (
solveNaturalCubicTangents+splineToCubicBezierPath) over the chain[click1, …, clickN, cursor]— the trailing cursor point creates the rubber-band effect. With Catmull-Rom's local-control property, dropping the cursor on right-click only changes the tangent at the LAST clicked point — the interior shape through earlier clicks is identical between preview and commit, so there's no shape jump at commit (😎.
- Preview after step N: the preview is drawn via the SAME Catmull-Rom Hermite math the commit uses (
- Finalize — Enter key or right-click commits. The committed spline uses the LAST click as
n1(end endpoint) and all interior clicks ascontrols[]. The cursor's trailing position is NOT included.
Cancel options at any step: Esc, switching tool, deleting active sketch, undo. Right-click before two points exist aborts rather than commits (less than 2 points is a no-op).
5. Committed state
After finalize:
- 2 endpoint
Nodeentries (n0,n1) — reused if a node already exists at the click position withinTOL.GEOMETRIC(seefindNodeAtatspline.ts:549). - N−2 interior
Nodeentries (one percontrols[i]), each carryingsplineCpRef: { splineId, cpIndex: i }. This is the CP materialisation lane atspline.ts:411-428— interior CPs are real, draggable graph nodes, NOT just embedded coords. Dragging one through the normalmoveNodepipeline auto-syncscontrols[]via the back-pointer. - 1
Splineentry with:id,n0,n1,controls[],cpNodeIds[](parallel tocontrols),tension: 0.5(legacy, ignored by current natural-cubic solver),tangents?: (TangentOverride | LegacyTangentVec | null)[](per-point overrides,null= auto),tangentLockStart?: false,tangentLockEnd?: false.TangentOverrideis a tagged union —{kind:'vec', dx, dy},{kind:'lock'}, or{kind:'segment', segmentId, mode:'parallel'|'perpendicular'}. Schema atscene-model.ts:220-286. - No auto-emitted geometric constraints.
- MotorScript statement emitted via
codegenSplineatcodegen.ts:131:sp1 = spline(p1, p2, ..., pN). Forward path only. Reverse-codegen has NO spline branch (reverse-codegen.tscontains zero occurrences of "spline"), so subsequent CP drags on the canvas are NOT re-serialised into the script — they survive only in scene state and are preserved across script re-runs byapplySplineCpPreservation(spline.ts:598,). See the reverse-codegen gap note under "Known bugs" and MotorScript.
6. Constraints / interactions
| Constraint | Behavior on this primitive |
|---|---|
| coincident (endpoint to node) | Standard — applies via n0 or n1 like any node. Endpoint CP inherits all eligibility from Node. |
| coincident (interior CP to node) | Allowed via the materialised CP node . CP node behaves like any node for the solver. |
| tangent (endpoint ↔ adjacent line/arc) | Not solver-enforced. Two soft equivalents: (1) tangent-lock — set tangentLockStart or tangentLockEnd true via Properties drop-down (or lock keyword in MotorScript at endpoint position); on the next reEvaluateSplineTangentLocks pass (spline.ts:1364, wired ) the first/last interior CP is rotated so its handle vector is parallel to the connected segment/arc tangent at that endpoint. Magnitude preserved. NOT serialised to slvs. Aliasing case (both locks ON, only 1 interior CP) — end-lock wins, start-lock skipped, console.warn fires . (2) parallel(seg) / perpendicular(seg) tangent binding — per-point override that fixes the tangent direction (not magnitude) to a target segment; re-evaluates on every scene change. Drag on a bound tangent is a no-op + throttled toast. |
| distance (between two CPs) | Allowed — CPs are real nodes; the solver sees the distance like any node pair. Move via solver writes through moveSplineControl semantics. |
| horizontal / vertical / parallel / perpendicular | N/A — there is no segment between two CPs to align. Apply on adjacent segments / arcs instead. |
| fillet | N/A — fillet expects two segments / arcs at a corner node; spline interior CPs cannot host a fillet. |
| crossing | Spline–segment, spline–arc, spline–spline crossings are detected via sampleSplineToPolyline adaptive sampling (spline.ts:303) but auto-split is deferred — see Crossing. |
| trim | Not yet wired. Splines are not trim-eligible. |
Soft-tangent-lock recompute on mutation: any drag that changes a node referenced by a spline endpoint (or by a primitive sharing that endpoint) should call reEvaluateSplineTangentLocks(scene). This is the G-05-3 hook landed in — without it, the lock is dormant on geometry that changes after the toggle.
7. Failure modes
- Fewer than 3 points —
splinebuiltin throws'spline requires at least 3 points'(primitives.ts:spline). Tool draft with ❤️ clicks: right-click / Enter is a silent no-op; draft persists. (Tightened from <2 in Phase C A-07 to guarantee at least one interior control between endpoints.) lockon a non-endpoint —spline(p1, lock, p2, p3)andspline(p1, p2, lock, p3)throw'lock can only be applied at the start or end point of a spline'. The error carries a script line number so the user can jump to it.parallel(seg)/perpendicular(seg)on an unknown segment — resolver matches the segment by endpoint nodes (n0/n1); if no segment in the scene has those endpoints, the override silently falls back toautofor that point.- Endpoint coincidence (
n0 === n1) —addSplinereturns the original scene unchanged (spline.ts:404). No spline added, no error toast. - Self-intersection — NOT a failure mode anymore (😎.
addSplinecommits the spline regardless of whether the curve self-crosses; the visible red ✕ markers from the canvas'ssplineSelfIntersectionslane provide the user-facing warning. ThehasSplineSelfIntersectiondetector is still exported for other consumers (region detection, region fills, solver projections) that need to guard themselves. - All control points coincident — degenerate; with Catmull-Rom tangents
m_i = ½ · (P_{i+1} − P_{i−1}), coincident neighbours producem_i = 0and the Hermite blend collapses cleanly. No explicit rejection. - Near-zero tangent override magnitude — treated as "clear to auto" via
MIN_TANGENT_MAG_SQatspline.ts:166; prevents the kink that a (0,0) override would otherwise produce. - NaN / ±Infinity tangent components — rejected at
setSplineTangentboundary (spline.ts:527) and atgetEffectiveTangents(spline.ts:192).
8. Figures
Preview during click (after click 3, cursor between p3 and prospective p4):
p1 •─╮
╲
╲___
╲•── p2
╲
╲___╳ cursor (rubber-band end)
p3•
Curve is the live natural-cubic-spline through {p1, p2, p3, cursor}.
Re-solves on every pointermove.Committed state (after right-click on p4, with CP nodes draggable):
n0═══◇ ◇ = materialised endpoint Node (n0 / n1)
╲ ● = materialised interior CP Node (splineCpRef set)
╲___ ── = sampled spline polyline (32 samples / Hermite arc)
╲● cp[0]
╲
╲___
● cp[1]
╲___
◇═══n1
User can grab any ● and drag — moveNode + splineCpRef back-pointer
keep spline.controls[i] in sync. Drag does NOT mutate the MotorScript
source (see Known bugs / reverse-codegen gap).Figure 1: Spline tool — 4 click positions and the live natural-cubic Hermite curve passing through all of them.
Figure 2: Committed spline — endpoint nodes n0 / n1 plus draggable interior CP nodes cp0 / cp1 (each carrying splineCpRef).
Figure 3: Tangent-lock behaviour — the spline's start endpoint coincides with a horizontal segment endpoint and tangentLockStart aligns cp0 along that segment's tangent direction.
Figure 4: after a 4-click spline commit, 4 extra phantom nodes (red, marked ✗) sit on top of the real CPs as orphans not referenced by spline.cpNodeIds[].
9. Known bugs
Original symptom: the preview used a hand-rolled cardinal-spline sampler in
CursorOverlay.tsx, while the commit used a natural-cubic Hermite solve. Different math → different curves at right-click time. First fix attempt : unified the preview onto natural-cubic Hermite. User feedback was that the natural-cubic shape was visually wrong (over-tense at the endpoints, swung too wide between irregularly-spaced points). Superseded the same day by:
Spline math is unified onto Catmull-Rom (cardinal spline τ = 0.5). Both the click-by-click preview AND the committed spline now use the same
solveNaturalCubicTangentshelper, whose body was rewritten to compute Catmull-Rom Hermite tangents: interiorm_i = ½ · (P_{i+1} − P_{i−1}), boundarym_0 = ½ · (P_1 − P_0)andm_{n−1} = ½ · (P_{n−1} − P_{n−2}). The conversion to SVG cubic Bézier viahermiteToBezieris unchanged, so anywhere a Hermite tangent was consumed downstream is automatically consistent. The function name is preserved for callsite back-compat (renaming would have rippled into the public DSL and 30+ tests). The pre-fix natural-cubic chord-length-parametrized implementation is recoverable from git history pre- if a scientific-fitting use case ever needs it. Catmull-Rom's local-control property —m_idepends only onP_{i−1}andP_{i+1}— means the preview chain[click1, …, clickN, cursor]and the commit chain[click1, …, clickN]agree on the interior tangents atclick2throughclick_{N−1}. Only the tangent at the last clicked point differs (preview uses cursor as neighbour; commit uses the boundary clamp). Visually this means the curve through the early clicks doesn't jump on right-click; only the trailing segment adjusts as the cursor is dropped. Locked in byspline.test.ts > preview ↔ commit math identity.
Previously
addSplinecalledhasSplineSelfIntersectionand refused to commit if the result was true (toast: "Spline self-intersects and was not added. Move a control point so the curve does not cross itself."). Users hit this most often during exploratory pen-tool drawing where the in-flight shape briefly self-crossed before they fixed it. Hard-reject was disproportionate. NowaddSplinecommits unconditionally. The canvas'ssplineSelfIntersectionslane already draws red ✕ markers at every detected crossing — that's the user-facing warning. ThehasSplineSelfIntersectiondetector is still exported for downstream consumers (region detection, region fills, solver projections) that genuinely cannot proceed on a non-simple curve; they should self-guard rather than push the failure upstream. Test guard:--spline-self-intersection-rejected.test.tsnow asserts the spline DOES commit and no toast fires.
Current (broken) behavior: the click handler at
Canvas/index.tsx:1625callssplineDraftRef.current.tryAddPoint(...)ANDpushDraftPoint(w). On commit,addSplineseparately materialises endpoint nodes (viafindNodeAtfallback) and interior CP nodes via (spline.ts:411-428). IfpushDraftPointalso persists nodes intoscene.geometry.nodes(rather than into an ephemeral draft buffer), those nodes survive commit and become orphans: visible, draggable, but not referenced byspline.cpNodeIds[]so dragging them leaves the spline unchanged. Correct behavior: draft state must live insplineDraftRefONLY; noscene.geometry.nodesmutation untiladdSplineruns at commit. Post-fix invariant: after committing an N-click spline on an empty scene,scene.geometry.nodes.length === Nexactly.
Reverse-codegen gap — Dragging an interior CP on the canvas updates
scene.geometry.splines[*].controls[i]and the corresponding CP node position, but does NOT rewrite thespline(...)call in the MotorScript source. On next script re-run,applySplineCpPreservation(spline.ts:598) heuristically re-applies the dragged positions by matching endpoint coords + CP count, but the match is ambiguous for closed loops or coincident-endpoint splines and falls back to scripted positions in that case. The drag is therefore non-durable across (a) Save-then-Load (script is the source of truth on cold load) and (b) any script edit that changes endpoint coords. See MotorScript for the broader parametric-edit invariant.
10. Class API
In the current model, splines are class instances rather than plain objects. Spline is the deepest primitive in the OOP layer — lists 9 sub-fields beyond the standard tag pair.
- Class file:
app/src/lib/fea2d/model/Spline.ts - Constructor:
new Spline(args: SplineSnapshot)— validatesn0 !== args.n1,Array.isArray(args.controls), and (whenargs.tangentsis non-null)args.tangents.length === args.controls.length + 2. The per-CPtangentsarray is promoted toTangent[]instances at construction (nullsnapshot entries becomeAutoTangentinstances so phase-6getEffectiveTangentscan dispatch polymorphically). - Snapshot type:
SplineSnapshot(app/src/lib/fea2d/model/snapshots/Spline.ts). - Lifecycle methods:
toJSON: SplineSnapshot— reverses theAutoTangent → nulllift to keep the v3 persistence-stability invariant.static fromJSON(snap): Spline— re-hydrates through the constructor.clone: Spline—withPatch-based copy; prototype preserved.equals(other: SceneEntity): boolean— class-and-id equality.
Geometry methods (points, sampleAt, samplePolyline, length, effectiveTangents, setControl, setTangentAt, …) are intentionally deferred to — they all need a Scene reference to resolve n0 / n1 endpoint coordinates. For now those operations stay on the free functions in app/src/lib/fea2d/spline.ts.
id, n0, n1 are readonly (re-endpointing means new spline id). cpNodeIds[] is readonly after construction (re-binding means a new spline). controls[], tangents, scalar tags are mutable.