Appearance
Rotate
1. Identity
A rotate is an in-place rigid rotation of the current selection around a pivot (cx, cy) by a signed angle (positive = CCW); the graph topology is preserved and only node coordinates plus cached arc geometry change.
2. When to use it
- Index a slot pattern by one pole pitch around the stator centre.
- Re-orient a feature that was drawn axis-aligned and now needs to sit at an arbitrary angle.
- Pivot a sub-assembly around one of its own nodes (click that node as the pivot for snap-locked accuracy).
- Rotate a filleted corner — the fillet re-evaluates parametrically at the rotated
sourceCornerId.
3. Inputs
- Tool button: Modify toolbar, "Rotate" slot.
- Keyboard shortcut:
ris wired to "rotate selection" only insideactiveTool === 'select'(Canvas/index.tsx:2699); from any other tool it falls through. - MotorScript builtin:
rotate(selection, cx, cy, angleDeg)— seeexecutor/builtins/transforms.ts. - Command Palette: "Modify: Rotate selection".
- Context menu: right-click on a selection → "Rotate…" opens TransformDialog in
mode='rotate'.
The tool is gated on selection.length > 0; the same activeTool === 'rotate' branch at Canvas/index.tsx:1296-1312 handles dispatch.
4. State machine
Two parallel entry flows. The canvas-click flow needs three clicks (not two) — pivot, reference, new-reference — because the angle is inferred from two rays, not from raw cursor delta.
Canvas-click flow (planTransformAction in tools/transform-tool.ts:106-122):
- Click 1 / Pivot: pick the rotation centre (snap-aware). Stored as
cx, cy.- Preview after click 1: pivot marker rendered; tool-hint shows "Click reference point (angle start)".
- Click 2 / Reference point: defines the start ray
angle1 = atan2(p1−pivot).- Preview after click 2: rubber-band line from pivot to cursor; ghost outline of selection rotating with the cursor; tool-hint shows "Click new reference point — angle inferred".
- Click 3 / New reference: defines
angle2 = atan2(p2−pivot); the dispatched angle is(angle2 − angle1)normalised to(-π, π].- Preview after click 3: none —
applyTransformWithCanvasdispatches and tool reverts toselect.
- Preview after click 3: none —
- Finalize: automatic on the third click.
- Cancel options: Esc (clears the click buffer via the activeTool reset effect at
Canvas/index.tsx:461-466); switching tool aborts.
Dialog-driven exact-value flow (TransformDialog.tsx mode='rotate', lines 99-116):
- Action: invoke "Rotate…" from context menu or palette → dialog opens pre-filled (
cx, cydefault(0, 0);angledefault90). - Enter cx, cy, angle°: locale-aware numerics. All three must be finite.
- Zero-angle guard :
((ang % 360) + 360) % 360 === 0triggerssurfaceError('rotate:zero-angle')with friendly toast "Rotation angle is a multiple of 360° (no-op)"; dialog stays open. - Finalize: Apply →
transformRotate(scene, selection, cx, cy, angleDeg).
- Cancel options: Esc, click outside, Cancel button.
5. Committed state
After finalize, transformRotate returns a new SceneModelV2 (transforms.ts:344-353 → applyInPlace):
- Nodes: every node id in the expanded selection closure has its
(x, y)rewritten viarotatePoint(p, cx, cy, angleDeg)(transforms.ts:208-218), subject toapplyAnchorand the computed-node guard. - Arcs: if both endpoints are in the selection, the cached centre is rotated directly (the rigid map applied verbatim) and
startAngleRadre-derives from the new chord; sweeparcLengthis preserved (the arc keeps its angular extent). If only one endpoint rotates, the host circle is re-derived from the new chord viaarcGeometry— same path asmoveNode. - Splines: each interior CP in
controls[]is rotated; endpoint CPs follow the node map. - Circles: centre rotates; radius invariant.
- BlockLabels:
(x, y)rotated. Note: label orientation/text is not rotated — only the anchor point. - MotorScript codegen: emits one
rotate(selectionRef, cx, cy, angleDeg)statement. - Constraints: stable — node ids unchanged. Angle-bearing constraints (parallel, perpendicular, equal, distance) all survive because rotation is rigid.
A sub-tolerance angle (|angleDeg| < TOL.SOLVER) is a no-op (transforms.ts:351).
6. Constraints / interactions
| Feature | Behavior under rotate |
|---|---|
Anchored node (anchor:'xy' or legacy anchored:true) | Refuses outright — applyAnchor returns null. : both representations respected. |
Axis-anchored node (anchor:'x' / 'y') | Geometrically inconsistent under rotation (rotation cannot preserve a world-axis lock except at multiples of 180°). applyAnchor clamps the rotated component back to the original — the node slides along its locked axis, no longer on the true rotation circle. Documented limitation, mirrors mirror+anchor caveat in transforms.ts:402-405. |
Computed node (virtualRef, crossing virtualNode) | Skipped — re-derived from parents post-dispatch. |
Fillet (selected, or sourceCornerId in selection) | Corner rotates; applyFillet re-evaluates with the rotated tangent geometry. |
| Crossing | Must re-evaluate post-dispatch. |
| Rect feature | The parallel/parallel/perpendicular constraints are angle-free (they constrain relative orientation, not absolute), so a wholly-selected rect rotates coherently. Partial selection breaks the shape — constraints will solver-error since the cloned segments are no longer parallel/perpendicular in pairs. |
| Polygon feature | Equal-length + centre-distance constraints are rotation-invariant; coherent under whole-feature rotation. centreNodeId must be in the selection closure or the polygon will rotate around its rim while the centre stays put. |
| Selected segment / arc / spline | Expansion pulls in endpoints + interior CPs; arcs preserve sweep — see Selection. |
| Constraints (horizontal, vertical) | These are absolute-orientation constraints; rotating a horizontally-constrained segment by 30° will solver-error on the next dispatch (the horizontal constraint pulls y1=y0 back). Surfaces as a red constraint pill. Documented as expected behaviour, not a bug. |
7. Failure modes
- Empty selection: Modify toolbar buttons disabled; canvas-click branch gated at
Canvas/index.tsx:1298. Silent no-op. - All-anchored selection: every node skipped → silent no-op (no toast).
- Sub-tolerance angle (canvas: clicks 2 and 3 within
TOL.SOLVERangular distance →|angleDeg| < TOL.SOLVER):transformRotatereturns input scene unchanged (transforms.ts:351). - Zero / 360° dialog angle: dialog rejects with
surfaceError('rotate:zero-angle')toast; stays open. This is the only zero-guard with a user-visible toast (Move has none — divergence noted). - Coincident clicks 1 and 2 (pivot == reference):
atan2(0, 0) = 0,angle1is meaningless; click 3 then defines an absolute angle from the pivot. This is not rejected — produces a usable rotation. - Coincident clicks 2 and 3:
angle2 == angle1→angleRad = 0→ sub-tolerance no-op. Tool exits without dispatching anything visible; user perception is "the third click did nothing". - Horizontal/vertical constraint on rotated segment: not a rotate failure per se — surfaces post-dispatch when the solver runs. Red pill on the offending constraint. Cross-ref `` for the underlying solver-error behaviour.
8. Figures
click 1 (pivot) click 2 (ref) click 3 (new ref)
● ● ●
│ ╱ ╱ ↻
│ ╱ angle inferred ╱
sel ●─sel rotated selFigure 1: Three-click flow. Pivot, then two rays defining the signed angle. Positive = CCW.
Figure 2 (click 1, pivot): the pivot is marked with a + glyph and the selection sits to the side awaiting clicks 2 and 3.
Figure 3 (click 2, reference ray): construction line from pivot to the reference click defines the start ray; selection remains in source state.
Figure 4 (click 3, result): both reference rays drawn; selection rotated by the signed angle θ between ray 1 and ray 2.
9. Known bugs
No rotate-specific entries beyond the cross-cutting solver and crossing issues as of .
10. Class API
In the current model, the selection-aware rotate pipeline still lives as the free function transformRotate(scene, cx, cy, angleRad, selection) in app/src/lib/fea2d/transforms.ts — the transforms.ts dissolution into class methods is deferred to .
Cached arc geometry (cx, cy, startAngleRad) on ArcSegment (app/src/lib/fea2d/model/ArcSegment.ts) is public mutable specifically so the rotate path can update it in place after computing the rotated centre and start-angle; the radius and arcLength fields are unchanged by a rigid rotation. Scene.bumpRevision invalidates phase-7 derived caches once the op completes.
The anchored / virtualRef / splineCpRef guards from Node's constructor invariants carry forward unchanged.