Skip to content

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 become controls[].

    • 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); see primitives.ts:tan.
      • auto — natural-cubic auto-derive (default; omit-equivalent).
      • lock — endpoint-only; freezes the tangent to the segment/arc connected at that endpoint via tangentLockStart/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 to auto. See primitives.ts:spline.

  • Command Palette: not yet wired.
  • Context menu: not yet wired.

4. State machine

  1. 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.
  2. 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 solveNaturalCubicTangents n===2 branch at spline.ts:76).
  3. 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 (😎.
  4. Finalize — Enter key or right-click commits. The committed spline uses the LAST click as n1 (end endpoint) and all interior clicks as controls[]. 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 Node entries (n0, n1) — reused if a node already exists at the click position within TOL.GEOMETRIC (see findNodeAt at spline.ts:549).
  • N−2 interior Node entries (one per controls[i]), each carrying splineCpRef: { splineId, cpIndex: i }. This is the CP materialisation lane at spline.ts:411-428 — interior CPs are real, draggable graph nodes, NOT just embedded coords. Dragging one through the normal moveNode pipeline auto-syncs controls[] via the back-pointer.
  • 1 Spline entry with: id, n0, n1, controls[], cpNodeIds[] (parallel to controls), tension: 0.5 (legacy, ignored by current natural-cubic solver), tangents?: (TangentOverride | LegacyTangentVec | null)[] (per-point overrides, null = auto), tangentLockStart?: false, tangentLockEnd?: false. TangentOverride is a tagged union — {kind:'vec', dx, dy}, {kind:'lock'}, or {kind:'segment', segmentId, mode:'parallel'|'perpendicular'}. Schema at scene-model.ts:220-286.
  • No auto-emitted geometric constraints.
  • MotorScript statement emitted via codegenSpline at codegen.ts:131: sp1 = spline(p1, p2, ..., pN). Forward path only. Reverse-codegen has NO spline branch (reverse-codegen.ts contains 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 by applySplineCpPreservation (spline.ts:598,). See the reverse-codegen gap note under "Known bugs" and MotorScript.

6. Constraints / interactions

ConstraintBehavior 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 / perpendicularN/A — there is no segment between two CPs to align. Apply on adjacent segments / arcs instead.
filletN/A — fillet expects two segments / arcs at a corner node; spline interior CPs cannot host a fillet.
crossingSpline–segment, spline–arc, spline–spline crossings are detected via sampleSplineToPolyline adaptive sampling (spline.ts:303) but auto-split is deferred — see Crossing.
trimNot 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 pointsspline builtin 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.)
  • lock on a non-endpointspline(p1, lock, p2, p3) and spline(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 to auto for that point.
  • Endpoint coincidence (n0 === n1)addSpline returns the original scene unchanged (spline.ts:404). No spline added, no error toast.
  • Self-intersectionNOT a failure mode anymore (😎. addSpline commits the spline regardless of whether the curve self-crosses; the visible red ✕ markers from the canvas's splineSelfIntersections lane provide the user-facing warning. The hasSplineSelfIntersection detector 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 produce m_i = 0 and the Hermite blend collapses cleanly. No explicit rejection.
  • Near-zero tangent override magnitude — treated as "clear to auto" via MIN_TANGENT_MAG_SQ at spline.ts:166; prevents the kink that a (0,0) override would otherwise produce.
  • NaN / ±Infinity tangent components — rejected at setSplineTangent boundary (spline.ts:527) and at getEffectiveTangents (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).
click 1click 2click 3click 4

Figure 1: Spline tool — 4 click positions and the live natural-cubic Hermite curve passing through all of them.

n0n1cp0cp1interior CPs draggable via splineCpRef back-pointer

Figure 2: Committed spline — endpoint nodes n0 / n1 plus draggable interior CP nodes cp0 / cp1 (each carrying splineCpRef).

segn0cp0 (locked)cp1n1tangentLockStart = true — cp0 aligned along segment tangent at n0

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.

phantom orphan nodes on top of CPsexpect: nodes.length === 4 actual: nodes.length === 8

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 solveNaturalCubicTangents helper, whose body was rewritten to compute Catmull-Rom Hermite tangents: interior m_i = ½ · (P_{i+1} − P_{i−1}), boundary m_0 = ½ · (P_1 − P_0) and m_{n−1} = ½ · (P_{n−1} − P_{n−2}). The conversion to SVG cubic Bézier via hermiteToBezier is 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_i depends only on P_{i−1} and P_{i+1} — means the preview chain [click1, …, clickN, cursor] and the commit chain [click1, …, clickN] agree on the interior tangents at click2 through click_{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 by spline.test.ts > preview ↔ commit math identity.

Previously addSpline called hasSplineSelfIntersection and 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. Now addSpline commits unconditionally. The canvas's splineSelfIntersections lane already draws red ✕ markers at every detected crossing — that's the user-facing warning. The hasSplineSelfIntersection detector 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.ts now asserts the spline DOES commit and no toast fires.

Current (broken) behavior: the click handler at Canvas/index.tsx:1625 calls splineDraftRef.current.tryAddPoint(...) AND pushDraftPoint(w). On commit, addSpline separately materialises endpoint nodes (via findNodeAt fallback) and interior CP nodes via (spline.ts:411-428). If pushDraftPoint also persists nodes into scene.geometry.nodes (rather than into an ephemeral draft buffer), those nodes survive commit and become orphans: visible, draggable, but not referenced by spline.cpNodeIds[] so dragging them leaves the spline unchanged. Correct behavior: draft state must live in splineDraftRef ONLY; no scene.geometry.nodes mutation until addSpline runs at commit. Post-fix invariant: after committing an N-click spline on an empty scene, scene.geometry.nodes.length === N exactly.

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 the spline(...) 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) — validates n0 !== args.n1, Array.isArray(args.controls), and (when args.tangents is non-null) args.tangents.length === args.controls.length + 2. The per-CP tangents array is promoted to Tangent[] instances at construction (null snapshot entries become AutoTangent instances so phase-6 getEffectiveTangents can dispatch polymorphically).
  • Snapshot type: SplineSnapshot (app/src/lib/fea2d/model/snapshots/Spline.ts).
  • Lifecycle methods:
    • toJSON: SplineSnapshot — reverses the AutoTangent → null lift to keep the v3 persistence-stability invariant.
    • static fromJSON(snap): Spline — re-hydrates through the constructor.
    • clone: SplinewithPatch-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.

motordevs studio — geometry editor specification