Skip to content

Node

1. Identity

A Node is a 2D point with a stable string id; it is the only positional entity in the scene — every other primitive (segment, arc, circle, spline, fillet, crossing) is defined by node references, not by raw coordinates.

2. When to use it

  • Drop a standalone reference point the user wants to bind a variable or constraint to (e.g. an anchor for a parametric origin, a target for a future distance constraint).
  • Mark a snap-able location on the canvas before drawing the primitive that will consume it.
  • Reach for it only deliberately — most nodes are created implicitly as a side effect of drawing another primitive (see §5). Using the Node tool when you actually want a segment doubles the click count.

3. Inputs

  • Tool button: left toolbar, "Node" slot (point glyph). Sets activeTool === "node" (Canvas/index.tsx:1314).
  • Keyboard shortcut: N (Canvas/index.tsx:2756).
  • MotorScript builtin: point(x, y) returning a PointRef with an .anchor(axis?) member call (executor/builtins/primitives.ts:90).
  • Command Palette: not yet wired.
  • Context menu: not yet wired (no "Insert node here" entry on the canvas right-click).

4. State machine

The point-tool state machine. Other tools create nodes as a side effect — see those tools' own specs.

  1. Activate: click Node toolbar slot or press N. The cursor switches to crosshair; previous tool's ephemeral draft is cleared by the central activeTool-change effect (Canvas/index.tsx:431).
    • Preview: snap halo follows cursor; halo highlights existing-node / segment-midpoint / endpoint targets.
  2. Click 1 / Finalize: pointer-down at world position (x, y) calls addNode(scene, x, y) (commands.ts:1314commands.ts:455).
    • Preview after step 1: a new node renders at the snapped position; tool stays active for the next drop.
    • Cancel options: Esc resets to select; switching to any other tool clears the draft via the central effect.

There is no multi-click flow — every click is its own commit. Right-click is not a finalize gesture for this tool.

5. Committed state

After step 2, exactly one of the following happens inside addNode (commands.ts:455-502):

  • Dedup hit (findNodeAt within TOL_POINT, commands.ts:474): no mutation. Returns the existing scene unchanged.
  • Fresh node: a new Node { id, x, y, inGroup: 0, boundaryMarker: null } is appended to scene.geometry.nodes. If the position lies on an existing segment or arc, the evaluator detects the through-node at read time and emits split sub-primitives — the source-of-truth segment / arc record itself stays whole (no-eager-mutation; commands.ts:462-466).
  • Construction layer: when layer === 'construction' (HardHat mode), the node is tagged isConstruction: true (commands.ts:480-484). It survives edits, accepts properties, and is excluded from the mesher and region detection.
  • Dual-write: if a Solvespace context is attached, dualWriteAddNode mirrors the id into the WASM sketch (commands.ts:489-498). Failures are toasted but never block the motordevs scene write.

Reverse-codegen path: standalone nodes emit pN = point(x, y) (codegen.ts:49-52). An anchored standalone node emits pN = point(x, y).anchor('x' | 'y' | 'xy').

Implicit creation by other tools. Every drawing tool ends in addNode (or ensureNodeWithPromotion) per endpoint, so the user almost never has to drop nodes explicitly:

ToolNodes created
Line / Segment2 endpoints
Arc (2-pt + radius, 3-pt, tangent)2 endpoints (centre is stored on the arc record, not as a node)
Circle0 — closed; nodes only appear later if the circle is crossed
Spline2 endpoints + N interior CPs materialised as nodes with splineCpRef
Rectangle4 endpoints
Polygonn vertices
Filletup to 2 tangent nodes (virtualRef.kind === 'fillet-tangent'); the original sharp corner is kept as isConstruction: true
Crossing1 virtual node (virtualRef.kind === 'crossing-point')

6. Constraints / interactions

ConstraintBehavior on a node
coincident (point-point)Pulls two nodes to the same (x, y).
fixedPins a node; semantically equivalent to anchored: true.
onLine / onCircleForces the node to lie on the referenced primitive.
midpointConstrains the node to the midpoint of a segment.
symmetricConstrains two nodes to mirror across a third node or segment.
distance / distanceH / distanceVDimensional; references two nodes.

Anchor axis model (anchor.ts:32-47). A node carries two representations that the unified predicate isAnchoredOn(node, axis) collapses:

  • anchor: 'x' | 'y' | 'xy' | undefined — modern partial-axis lock set by the MotorScript .anchor(axis?) call and the D2 executor.
    • 'x': x is locked, y is free → node slides vertically only.
    • 'y': y is locked, x is free → node slides horizontally only.
    • 'xy': both axes locked.
  • anchored: true — legacy full lock (set by the Properties drop-down toggle). Equivalent to anchor: 'xy' and propagates a FIXED dragged constraint into the solver + disables Properties drop-down coordinate inputs.

Store-level guards (drag, moveNode, transformMove/Rotate/Mirror, slvs translate) must consult isAnchoredOn — never one representation in isolation; / were exactly this foot-gun.

virtualRef — promoted nodes owned by features. When a primitive's endpoint snaps to a feature-derived point, ensureNodeWithPromotion (commands.ts:544+) graduates the snapped point into scene.geometry.nodes with virtualRef populated:

  • { kind: 'fillet-tangent', filletId, side: 0 | 1 } — node's (x, y) is recomputed each time the fillet re-evaluates; user-attached properties (boundaryMarker, group, frame) survive every input change.
  • { kind: 'crossing-point', crossingId } — node tracks the live intersection of the two crossed primitives.

If the upstream feature is deleted, freezeOrphanedVirtuals strips virtualRef, freezes (x, y) at the last computed value, and sets wasOrphanedVirtual: true so the lifecycle ring renders red until the user explicitly modifies the node.

splineCpRef . When set, this node is the materialised representative of an interior spline control point. Spline.controls[] remains the source of truth; the node's (x, y) is kept in sync so the normal drag pipeline propagates to the spline. The node is not a graph endpoint — it is purely a drag handle and does not participate in topology. Deleting the spline removes its CP nodes automatically.

isConstruction. Construction nodes (the drafting-aid layer) are preserved through edits, available for variable / constraint binding, but ignored by the mesher and excluded from region detection. The fillet command keeps the original sharp corner as a construction node so any user-assigned property survives the operation.

frameId (optional, default null = global). Assigns the node to a local coordinate system. The node's stored (x, y) is interpreted in the frame's local axes; world position is the chain of frame transforms up to root. See Frame for the frame primitive itself and how to attach a node to a frame.

7. Failure modes

  • Coincident drop: clicking inside TOL_POINT of an existing node is a no-op (commands.ts:474). No toast — silent dedup is the contract.
  • Non-finite coordinates: point(NaN, _) / point(Infinity, _) throws at the executor boundary so the scene never accumulates a poisoned node (primitives.ts:94). The MotorScript console surfaces the error; no node is created.
  • Drop on construction layer while mesher is running: no interaction — isConstruction nodes never reach the mesher.
  • Drop on a feature-derived point (fillet tangent, crossing virtual): silently promoted to a virtualRef node via ensureNodeWithPromotion. No toast. The Properties drop-down indicates the node's lifecycle via its ring colour.
  • Drop on a deleted-feature's frozen virtual node: behaves as a free node; wasOrphanedVirtual clears on the first explicit edit.
  • Empty / orphan after delete of upstream feature: node is not deleted — it freezes in place with wasOrphanedVirtual: true (red ring) until the user touches it.

8. Figures

Acursor near A → green snap halo locks on (click would dedup)

Figure 1: Node-tool snap halo locking onto an existing node A within TOL_POINT — clicking results in a silent dedup no-op rather than a new node.

freeanchor: undefinedanchor: 'x'slides on y onlyanchor: 'xy'both axes lockedanchor states: free → partial-x → full-xy

Figure 2: Anchor states — unanchored (blue), anchor:'x' (orange with x-axis tick), anchor:'xy' (orange with both axes locked).

isConstructionvirtualRefvirtualReffillet tangent nodes are feature-owned (linked glyph)

Figure 3: A fillet corner — the two tangent nodes carry virtualRef:{kind:'fillet-tangent'} (linked-dot glyph) and the original sharp corner survives as a faded construction node.

  cursor over empty canvas, point-tool active:

        ·    ·    ·          (background grid dots)
            ╭──╮
        ·   │ ◯│   ·         ◯ = snap halo following cursor
            ╰──╯
        ·    ·    ·

  cursor near existing node A, snap halo locks on:

        ·    ·    ·
              ╭──╮
        ·   ·│●A│  ·          ● = existing node, halo size pulses
              ╰──╯
        ·    ·    ·

9. Known bugs

Related specs: Segment, Arc, Circle, Spline, Rectangle, Polygon, Fillet, Crossing, Frame.

10. Class API

In the current model, nodes are class instances rather than plain objects. The model class lives at app/src/lib/fea2d/model/Node.ts; the persisted JSON shape stays the plain-object NodeSnapshot at app/src/lib/fea2d/model/snapshots/Node.ts.

  • Class file: app/src/lib/fea2d/model/Node.ts
  • Constructor: new Node(snap: NodeSnapshot) — takes a snapshot, validates Number.isFinite(x|y) and the virtualRef ⊕ splineCpRef mutual-exclusion invariant, then copies fields onto the instance (defaults applied for optional snapshot fields).
  • Snapshot type: NodeSnapshot (app/src/lib/fea2d/model/snapshots/Node.ts).
  • Lifecycle methods:
    • toJSON: NodeSnapshot — emits only required fields plus optional fields that depart from their defaults (matches the v3 persistence-stability invariant).
    • static fromJSON(snap: NodeSnapshot): Node — re-hydrates from a snapshot, silently dropping any stray properties (row 6).
    • clone: NodewithPatch(this, {}) structural copy; same id, prototype preserved.
    • equals(other: SceneEntity): boolean — class-and-id equality .
  • Geometry methods:
    • distanceTo(other: Node): number — Euclidean distance in node-local coordinates. Consolidates the in-tree Math.hypot(a.x - b.x, a.y - b.y) copies.
    • withMove(dx, dy): Node — returns a new prototype-preserving copy shifted by (dx, dy). For in-place drag, write n.x += dx; n.y += dy directly.

Scene is the owning container — scene.node(id) → Node resolves the instance; scene.nodes iterates the Map index. Cross-references from other primitives (segment n0 / n1, fillet tangentNodeIds, etc.) are stored as NodeId only ; resolve to the instance via scene.node(id) at the call site.

motordevs studio — geometry editor specification