Appearance
Move
1. Identity
A move is an in-place rigid translation of the current selection by a vector (dx, dy); the graph topology is preserved (same node ids, same edge endpoints) and only node coordinates change.
2. When to use it
- Reposition a sub-assembly (e.g. a slot tooth) without re-drawing it.
- Slide one or both endpoints of a segment by a known delta to match a parametric dimension.
- Translate a free-standing block label or construction node onto a snap target.
- Move a filleted corner — the fillet re-evaluates parametrically from its moved
sourceCornerId.
3. Inputs
- Tool button: Modify toolbar, "Move" slot.
- Keyboard shortcut: not yet wired (no
k === 'm'branch inCanvas/index.tsxshortcut dispatch around line 2716). - MotorScript builtin:
move(selection, dx, dy)— seeexecutor/builtins/transforms.ts. - Command Palette: "Modify: Move selection".
- Context menu: right-click on a selection → "Move…" opens TransformDialog in
mode='move'.
The tool is gated on selection.length > 0; activating it with an empty selection falls through to no-op (see Canvas/index.tsx:1296-1298).
4. State machine
Two parallel entry flows. The canvas-click flow is the default once the tool is active.
Canvas-click flow (planTransformAction in tools/transform-tool.ts:86-94, dispatch at Canvas/index.tsx:1296-1312):
- Click 1 / Reference point: pick any point in world space (snap-aware). This is the "from" handle.
- Preview after click 1: ghost outline of the selection follows the cursor; tool-hint strip shows "Click target point — selection moves by delta".
- Click 2 / Destination point: the translation vector is
clicks[1] − clicks[0].- Preview after click 2: none —
applyTransformWithCanvasdispatches and the tool reverts toselect.
- Preview after click 2: none —
- Finalize: automatic on the second click.
setTransformCanvasClicks([])clears the buffer,setActiveTool("select")exits.
- Cancel options: Esc (clears the click buffer via the activeTool reset effect at
Canvas/index.tsx:461-466), or switch to any non-transform tool.
Dialog-driven exact-value flow (TransformDialog.tsx mode='move', lines 79-83):
- Action: invoke "Move…" from context menu or palette → dialog opens pre-filled with
defaults.ax / defaults.ay(typically the last cursor delta or(10, 0)). - Enter Δx and Δy: locale-aware numeric inputs (
parseDialogNumeric). Both must be finite, else submit is a no-op. - Finalize: click Apply (or press Enter) →
onConfirm({ ax, ay })callstransformMove(scene, selection, dx, dy).
- Cancel options: Esc, click outside, or Cancel button →
onCancel.
5. Committed state
After finalize, transformMove returns a new SceneModelV2 patched via patchGeometry (transforms.ts:241-250, then applyInPlace at :462-545):
- Nodes: every node id in the expanded selection closure has its
(x, y)rewritten — subject toapplyAnchorand the computed-node guardisComputedNode(transforms.ts:90-92). - Arcs: arcs whose endpoints moved have their cached
cx / cy / radius / startAngleRadre-derived; if both endpoints moved under the same rigid map the centre is translated directly (no chord re-derivation drift on 180° arcs). - Splines: each interior CP in
controls[]is translated; endpoint CPs follow via the node map. - Circles:
(cx, cy)translated; radius invariant. - BlockLabels:
(x, y)translated. - MotorScript codegen: emits one
move(selectionRef, dx, dy)statement — seecodegen.tstransform codegen path. - Constraints: no new constraints emitted. Existing constraints (parallel / equal / distance) survive untouched since node ids are stable.
A bit-exact zero delta (|dx| < 1e-12 ∧ |dy| < 1e-12) is a no-op and returns the input scene unchanged (transforms.ts:248).
6. Constraints / interactions
| Feature | Behavior under move |
|---|---|
Anchored node (anchor:'xy' or legacy anchored:true) | Refuses outright — applyAnchor returns null, node skipped (transforms.ts:120). Confirmed by ; both representations honoured. |
Axis-anchored node (anchor:'x') | Only y translates; x clamped to original (transforms.ts:121-122). Same for 'y'. |
Computed node (virtualRef set, or bare crossing point) | Skipped — isComputedNode guard at transforms.ts:476. Re-evaluated downstream from its parents. |
Fillet (selected directly, or sourceCornerId in selection) | Source corner translates; applyFillet re-evaluates the tangent arc + isConstruction parents at the new corner location. |
| Crossing | Auto-promotion / drift on partial moves is **** territory — reEvaluateCrossings must run after the move to re-intersect parents. |
| Rect / Polygon feature | All four (or N) backing nodes must be in the selection closure for the feature to translate coherently; partial selection breaks the shape lock (the feature record stays, but the parallel/perpendicular/equal/distance constraints will solver-error on next dispatch). |
| Selected segment / arc / spline | Expansion (transforms.ts:140-198) pulls in endpoint nodes + interior CPs automatically — see Selection. |
| Constraints (horizontal, vertical, distance, parallel, etc.) | Survive — node ids are stable across in-place transforms. Distance constraints stay satisfied because the rigid translation preserves all pairwise distances. |
7. Failure modes
- Empty selection: tool button refuses activation; if forced, canvas-click dispatch never fires (gated at
Canvas/index.tsx:1298). No toast — the Modify toolbar buttons are visually disabled in selection-empty state. - All-anchored selection (every node has
anchor:'xy'or legacyanchored:true):transformMoveruns,applyInPlacewalks every node and skips them all → returned scene is identity-equal to input. No toast. Treated as a silent no-op (mirrorsmoveNodesemantics). - Zero-vector dialog input (
dx = dy = 0): canvas dispatch is a bit-exact no-op (transforms.ts:248). Dialog doesn't reject — Apply closes the dialog with no scene change. N.B. unlike Rotate'ssurfaceError('rotate:zero-angle')toast (TransformDialog.tsx:107-113), Move has no equivalent zero-delta guard at the dialog layer. - Partial rect/polygon selection: feature record's constraint set becomes geometrically inconsistent on the next solve — surfaces as a red constraint pill in the palette. Not a Move-specific failure; the violation is logged against the offending parallel/equal/distance constraint.
- Computed-node-only selection (e.g. only a crossing virtualNode picked): every node is skipped → silent no-op.
8. Figures
selection ghost committed
┌────────┐ click 1 (ref) ┌────────┐
│ A──B │ ────────●──────────→ ● │ A──B │ → A'──B'
│ / │ click 2 │ / │ /
│ C │ (target) │ C │ C'
└────────┘ └────────┘Figure 1: Two-click flow. Click 1 sets the reference; click 2 sets the destination; the translation vector is (click2 − click1).
Figure 2 (before): selection of 3 nodes and 2 segments at the original position (dashed), with the translation vector Δ drawn as a construction arrow from the reference node.
Figure 3 (after): same shape translated by Δx=80, Δy=−40 (solid blue); arrow from original reference node A to its new position A'.
9. Known bugs
No move-specific entries beyond the cross-cutting crossing issue as of .
10. Class API
In the current model, the selection-aware pipeline still lives as the free function transformMove(scene, dx, dy, selection) in app/src/lib/fea2d/transforms.ts — the transforms.ts dissolution into class methods is deferred to . The class layer provides two cheaper handles for callers that already hold a node instance:
Node.withMove(dx, dy): Node(app/src/lib/fea2d/model/Node.ts) — returns a new prototype-preserving copy shifted by(dx, dy). Use this when you want a fresh node record for an immutable patch path.- In-place mutation: write
n.x += dx; n.y += dydirectly on the instance. The drag pipeline uses this hot path;Scene.bumpRevisioninvalidates phase-7 derived caches.
Scene exposes index lookups (scene.node(id), scene.segment(id), etc.) so a move op can pull the actual instances cheaply without a linear scan. The anchored / virtualRef / splineCpRef guards from Node's constructor invariants and the snapshot semantics described in §5 carry forward unchanged.