diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 6beabc96..d2e99e0b 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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 { diff --git a/packages/nodes/src/column/floorplan-affordances.ts b/packages/nodes/src/column/floorplan-affordances.ts index ec95b4e2..dda7792b 100644 --- a/packages/nodes/src/column/floorplan-affordances.ts +++ b/packages/nodes/src/column/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type FloorplanAffordance, useScene, } from '@pascal-app/core' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' // Floor minimums — mirror the 3D handles in `column/definition.ts` so a // drag can't push a value past what the renderer accepts. @@ -146,11 +147,13 @@ export const columnRotateAffordance: FloorplanAffordance = { return { affectedIds: [columnId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) const newRotation = initialRotation - delta lastRotation = newRotation useScene.getState().updateNode(columnId, { rotation: newRotation }) diff --git a/packages/nodes/src/elevator/floorplan-affordances.ts b/packages/nodes/src/elevator/floorplan-affordances.ts index 253edb71..2e2b0e9a 100644 --- a/packages/nodes/src/elevator/floorplan-affordances.ts +++ b/packages/nodes/src/elevator/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type FloorplanAffordance, useScene, } from '@pascal-app/core' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' const MIN_ELEVATOR_DIM = 0.6 @@ -83,11 +84,13 @@ export const elevatorRotateAffordance: FloorplanAffordance = { return { affectedIds: [elevatorId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) const newRotation = initialRotation - delta lastRotation = newRotation useScene.getState().updateNode(elevatorId, { rotation: newRotation }) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index ad0b50ae..355cab79 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -267,6 +267,11 @@ export const roofSegmentDefinition: NodeDefinition = { schema: RoofSegmentNode, category: 'structure', surfaceRole: 'roof', + // Mirrors the parent roof: a body-move resolves the no-angle `polygon` + // snap context (grid / lines / off), so dragging a segment shows the + // snapping chip and honours the active mode like every other structural + // move. Resize / rotate run through their own reshaping scope. + snapProfile: 'structural', defaults: () => { const stub = RoofSegmentNodeSchema.parse({ diff --git a/packages/nodes/src/roof-segment/floorplan-affordances.ts b/packages/nodes/src/roof-segment/floorplan-affordances.ts index 32212306..4c2a5e0f 100644 --- a/packages/nodes/src/roof-segment/floorplan-affordances.ts +++ b/packages/nodes/src/roof-segment/floorplan-affordances.ts @@ -9,6 +9,7 @@ import { } from '@pascal-app/core' import { getSegmentGridStep } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' const MIN_ROOF_DIM = 1 @@ -59,7 +60,7 @@ function resolveSegmentFrame( * the math survives any parent-roof rotation. */ export const roofSegmentResizeAffordance: FloorplanAffordance = { - start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) { + start({ node, payload, nodes, initialPlanPoint }) { const { axis, side } = payload as RoofSegmentResizePayload const segmentId = node.id as AnyNodeId const initialValue = axis === 'x' ? node.width : node.depth @@ -79,12 +80,15 @@ export const roofSegmentResizeAffordance: FloorplanAffordance = return { affectedIds: [segmentId], - apply({ planPoint, modifiers }) { + apply({ planPoint }) { const currentLocal = projectLocalAxis(planPoint[0], planPoint[1]) const delta = (currentLocal - initialLocal) * side const rawValue = initialValue + 2 * delta - const snappedValue = - !modifiers.shiftKey && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue + // Mode-aware grid step (0 outside grid mode, so `lines` / `off` resize + // freely — the "smooth" behaviour that used to need a held Shift). The + // reshaping scope opened by the dispatcher resolves the `polygon` set. + const step = getSegmentGridStep() + const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue const newValue = Math.max(MIN_ROOF_DIM, snappedValue) lastValue = newValue useScene @@ -121,11 +125,13 @@ export const roofSegmentRotateAffordance: FloorplanAffordance = return { affectedIds: [segmentId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) lastRotation = initialRotation - delta useScene.getState().updateNode(segmentId, { rotation: lastRotation }) }, @@ -168,9 +174,12 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget = ({ no return { affectedIds: [segmentId], - apply({ planPoint, modifiers }) { + apply({ planPoint }) { + // Mode-aware: `getSegmentGridStep()` is 0 outside grid mode (so `lines` / + // `off` move freely), and the `moving` scope resolves the `polygon` set + // via the kind's `snapProfile` — no held-Shift bypass. const step = getSegmentGridStep() - const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) + const snap = (value: number) => snapScalar(value, step) const worldPoint = resolveCursor(planPoint, { snap }) const dx = worldPoint[0] - roofPosX const dz = worldPoint[1] - roofPosZ diff --git a/packages/nodes/src/shared/rotate-affordance.ts b/packages/nodes/src/shared/rotate-affordance.ts new file mode 100644 index 00000000..fc9e12a7 --- /dev/null +++ b/packages/nodes/src/shared/rotate-affordance.ts @@ -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 +} diff --git a/packages/nodes/src/shelf/floorplan-affordances.ts b/packages/nodes/src/shelf/floorplan-affordances.ts index c5ab40f3..28857129 100644 --- a/packages/nodes/src/shelf/floorplan-affordances.ts +++ b/packages/nodes/src/shelf/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type ShelfNode, useScene, } from '@pascal-app/core' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' // Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a // value past what the renderer / geometry builder accepts. @@ -82,11 +83,13 @@ export const shelfRotateAffordance: FloorplanAffordance = { return { affectedIds: [shelfId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) const newRotationY = initialRotationY - delta lastRotation = [r[0], newRotationY, r[2]] useScene.getState().updateNode(shelfId, { rotation: lastRotation }) diff --git a/packages/nodes/src/spawn/floorplan-affordances.ts b/packages/nodes/src/spawn/floorplan-affordances.ts index b8482fb2..12c1348c 100644 --- a/packages/nodes/src/spawn/floorplan-affordances.ts +++ b/packages/nodes/src/spawn/floorplan-affordances.ts @@ -4,6 +4,7 @@ import { type SpawnNode, useScene, } from '@pascal-app/core' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' export const spawnRotateAffordance: FloorplanAffordance = { start({ node, initialPlanPoint }) { @@ -16,11 +17,13 @@ export const spawnRotateAffordance: FloorplanAffordance = { return { affectedIds: [spawnId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) lastRotation = initialRotation - delta useScene.getState().updateNode(spawnId, { rotation: lastRotation }) }, diff --git a/packages/nodes/src/stair/floorplan-affordances.ts b/packages/nodes/src/stair/floorplan-affordances.ts index 2cc70e24..f4dbe9de 100644 --- a/packages/nodes/src/stair/floorplan-affordances.ts +++ b/packages/nodes/src/stair/floorplan-affordances.ts @@ -6,6 +6,7 @@ import { type StairSegmentNode, useScene, } from '@pascal-app/core' +import { rotateAffordanceDelta } from '../shared/rotate-affordance' // Minimums + max sweep mirror the 3D handles in // `packages/editor/src/components/editor/stair-segment-handles.tsx` so a 2D @@ -232,12 +233,13 @@ export const stairRotateAffordance: FloorplanAffordance = { return { affectedIds: [stairId], - apply({ planPoint }) { - const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx) - let delta = currentAngle - initialAngle - // Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture. - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI + apply({ planPoint, modifiers }) { + const delta = rotateAffordanceDelta({ + center: [cx, cz], + initialAngle, + planPoint, + free: modifiers.shiftKey, + }) const newRotation = initialRotation - delta lastRotation = newRotation useScene.getState().updateNode(stairId, { rotation: newRotation })