Skip to content

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: r is wired to "rotate selection" only inside activeTool === 'select' (Canvas/index.tsx:2699); from any other tool it falls through.
  • MotorScript builtin: rotate(selection, cx, cy, angleDeg) — see executor/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):

  1. 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)".
  2. 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".
  3. Click 3 / New reference: defines angle2 = atan2(p2−pivot); the dispatched angle is (angle2 − angle1) normalised to (-π, π].
    • Preview after click 3: none — applyTransformWithCanvas dispatches and tool reverts to select.
  4. 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):

  1. Action: invoke "Rotate…" from context menu or palette → dialog opens pre-filled (cx, cy default (0, 0); angle default 90).
  2. Enter cx, cy, angle°: locale-aware numerics. All three must be finite.
  3. Zero-angle guard : ((ang % 360) + 360) % 360 === 0 triggers surfaceError('rotate:zero-angle') with friendly toast "Rotation angle is a multiple of 360° (no-op)"; dialog stays open.
  4. 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-353applyInPlace):

  • Nodes: every node id in the expanded selection closure has its (x, y) rewritten via rotatePoint(p, cx, cy, angleDeg) (transforms.ts:208-218), subject to applyAnchor and the computed-node guard.
  • Arcs: if both endpoints are in the selection, the cached centre is rotated directly (the rigid map applied verbatim) and startAngleRad re-derives from the new chord; sweep arcLength is preserved (the arc keeps its angular extent). If only one endpoint rotates, the host circle is re-derived from the new chord via arcGeometry — same path as moveNode.
  • 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

FeatureBehavior 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.
CrossingMust re-evaluate post-dispatch.
Rect featureThe 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 featureEqual-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 / splineExpansion 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.SOLVER angular distance → |angleDeg| < TOL.SOLVER): transformRotate returns 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, angle1 is 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 == angle1angleRad = 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 sel

Figure 1: Three-click flow. Pivot, then two rays defining the signed angle. Positive = CCW.

pivotselection (pending)

Figure 2 (click 1, pivot): the pivot is marked with a + glyph and the selection sits to the side awaiting clicks 2 and 3.

ref (click 2)

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.

motordevs studio — geometry editor specification