feat(nodes): mode-aware roof-segment edit + 2D rotation parity with 3D

Roof-segment edit/move ignored the active snap mode and showed no chip:
- add snapProfile:'structural' so a body-move resolves the no-angle polygon
  context (grid/lines/off) like every other structural move;
- resize uses getSegmentGridStep() (0 outside grid mode = the "smooth" resize
  that used to need a held Shift), dropping the captured gridSnapStep + Shift;
- move drops its Shift bypass;
- the affordance dispatcher opens a boundary reshape scope for the resize so
  the snapping chip shows and the context resolves.

2D rotation handles now match the 3D gizmo across all six rotate affordances
(column / elevator / roof-segment / shelf / spawn / stair): a shared
rotateAffordanceDelta snaps to the 15° step unless Shift (free), the
dispatcher opens the same ROTATE_HANDLE_DRAG_LABEL handle-drag scope the 3D
gizmo uses so the contextual HUD shows the "Shift = rotate freely" hint, and
the live degree readout snaps to match the committed rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 16:12:26 -04:00
co-authored by Claude Opus 4.8
parent 7512fccdaf
commit 84cdf4519d
9 changed files with 116 additions and 40 deletions
@@ -0,0 +1,24 @@
import { DEFAULT_ANGLE_STEP } from '@pascal-app/core'
/**
* Shared rotation delta for the 2D corner rotate-arrow affordances (column /
* elevator / roof-segment / shelf / spawn / stair — all structurally
* identical). Measures the pointer's angular sweep from the grab bearing
* around the node center, wrapped to [-π, π] so a drag crossing ±π keeps its
* sign, then snaps it to the 15° increment unless `free` (the held Shift the
* contextual HUD advertises). The 2D twin of the 3D gizmo's
* `snapDirectRotationDelta`, so rotating a node reads the same in both views.
*/
export function rotateAffordanceDelta(args: {
center: readonly [number, number]
initialAngle: number
planPoint: readonly [number, number]
free: boolean
}): number {
const { center, initialAngle, planPoint, free } = args
const currentAngle = Math.atan2(planPoint[1] - center[1], planPoint[0] - center[0])
let delta = currentAngle - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
}