Appearance
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 aPointRefwith 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.
- Activate: click Node toolbar slot or press
N. The cursor switches to crosshair; previous tool's ephemeral draft is cleared by the centralactiveTool-change effect (Canvas/index.tsx:431).- Preview: snap halo follows cursor; halo highlights existing-node / segment-midpoint / endpoint targets.
- Click 1 / Finalize: pointer-down at world position
(x, y)callsaddNode(scene, x, y)(commands.ts:1314→commands.ts:455).- Preview after step 1: a new node renders at the snapped position; tool stays active for the next drop.
- Cancel options:
Escresets toselect; 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 (
findNodeAtwithinTOL_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 toscene.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 taggedisConstruction: 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,
dualWriteAddNodemirrors 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:
| Tool | Nodes created |
|---|---|
| Line / Segment | 2 endpoints |
| Arc (2-pt + radius, 3-pt, tangent) | 2 endpoints (centre is stored on the arc record, not as a node) |
| Circle | 0 — closed; nodes only appear later if the circle is crossed |
| Spline | 2 endpoints + N interior CPs materialised as nodes with splineCpRef |
| Rectangle | 4 endpoints |
| Polygon | n vertices |
| Fillet | up to 2 tangent nodes (virtualRef.kind === 'fillet-tangent'); the original sharp corner is kept as isConstruction: true |
| Crossing | 1 virtual node (virtualRef.kind === 'crossing-point') |
6. Constraints / interactions
| Constraint | Behavior on a node |
|---|---|
coincident (point-point) | Pulls two nodes to the same (x, y). |
fixed | Pins a node; semantically equivalent to anchored: true. |
onLine / onCircle | Forces the node to lie on the referenced primitive. |
midpoint | Constrains the node to the midpoint of a segment. |
symmetric | Constrains two nodes to mirror across a third node or segment. |
distance / distanceH / distanceV | Dimensional; 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 toanchor: '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_POINTof 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 —
isConstructionnodes never reach the mesher. - Drop on a feature-derived point (fillet tangent, crossing virtual): silently promoted to a
virtualRefnode viaensureNodeWithPromotion. 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;
wasOrphanedVirtualclears 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
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.
Figure 2: Anchor states — unanchored (blue), anchor:'x' (orange with x-axis tick), anchor:'xy' (orange with both axes locked).
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, validatesNumber.isFinite(x|y)and thevirtualRef ⊕ splineCpRefmutual-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: Node—withPatch(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-treeMath.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, writen.x += dx; n.y += dydirectly.
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.