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
@@ -33,10 +33,12 @@ import {
useRef,
useState,
} from 'react'
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
import {
canDirectRotateNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
import {
@@ -153,6 +155,20 @@ function affordanceReshapeScope(
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
return endpointReshapeScope(nodeId, endpoint)
}
// Roof-segment width/depth resize — a no-angle dimension edit, so the
// no-angle 'polygon' snap set (grid / lines / off) via a boundary scope.
// Matched exactly so a still-legacy `*-resize` affordance on another kind
// doesn't get a chip its snap math can't honour yet.
if (affordance === 'roof-segment-resize') {
return boundaryReshapeScope(nodeId)
}
// 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn /
// stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label-
// matched, so the contextual HUD shows the "Shift = rotate freely" hint over
// the drag. The affordance applies the 15° angle step itself.
if (affordance.includes('rotate')) {
return { kind: 'handle-drag', nodeId, handle: ROTATE_HANDLE_DRAG_LABEL }
}
return null
}
@@ -968,13 +984,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
)
useEffect(() => {
// Tear down the reshaping scope this drag opened (if any), matched by node
// id so a concurrent scope from another path is never ended by mistake.
// Tear down the scope this drag opened (if any) — a reshaping scope for an
// edit affordance, or a handle-drag scope for a rotate-arrow — matched by
// node id so a concurrent scope from another path is never ended by mistake.
const endReshapeScope = (drag: ActiveDrag) => {
if (drag.reshapeScopeNodeId) {
useInteractionScope
.getState()
.endIf((s) => s.kind === 'reshaping' && s.nodeId === drag.reshapeScopeNodeId)
.endIf(
(s) =>
(s.kind === 'reshaping' || s.kind === 'handle-drag') &&
s.nodeId === drag.reshapeScopeNodeId,
)
}
}
@@ -1006,6 +1027,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
let delta = current - rot.initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
// Match the affordance's 15° angle step (Shift = free) so the wedge +
// degree chip read the committed rotation, not the raw pointer bearing.
delta = snapDirectRotationDelta(delta, event.shiftKey)
if (Math.abs(delta) < 0.0087) {
setRotationOverlay(null)
} else {