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:
co-authored by
Claude Opus 4.8
parent
7512fccdaf
commit
84cdf4519d
@@ -33,10 +33,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
||||||
import {
|
import {
|
||||||
canDirectRotateNode,
|
canDirectRotateNode,
|
||||||
resolveDirectRotationDragDelta,
|
resolveDirectRotationDragDelta,
|
||||||
resolveDirectRotationPatch,
|
resolveDirectRotationPatch,
|
||||||
|
snapDirectRotationDelta,
|
||||||
} from '../../../lib/direct-manipulation'
|
} from '../../../lib/direct-manipulation'
|
||||||
import { createEditorApi } from '../../../lib/editor-api'
|
import { createEditorApi } from '../../../lib/editor-api'
|
||||||
import {
|
import {
|
||||||
@@ -153,6 +155,20 @@ function affordanceReshapeScope(
|
|||||||
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end'
|
||||||
return endpointReshapeScope(nodeId, endpoint)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,13 +984,18 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Tear down the reshaping scope this drag opened (if any), matched by node
|
// Tear down the scope this drag opened (if any) — a reshaping scope for an
|
||||||
// id so a concurrent scope from another path is never ended by mistake.
|
// 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) => {
|
const endReshapeScope = (drag: ActiveDrag) => {
|
||||||
if (drag.reshapeScopeNodeId) {
|
if (drag.reshapeScopeNodeId) {
|
||||||
useInteractionScope
|
useInteractionScope
|
||||||
.getState()
|
.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
|
let delta = current - rot.initialAngle
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||||
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) {
|
if (Math.abs(delta) < 0.0087) {
|
||||||
setRotationOverlay(null)
|
setRotationOverlay(null)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type FloorplanAffordance,
|
type FloorplanAffordance,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||||
|
|
||||||
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
|
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
|
||||||
// drag can't push a value past what the renderer accepts.
|
// drag can't push a value past what the renderer accepts.
|
||||||
@@ -146,11 +147,13 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [columnId],
|
affectedIds: [columnId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
initialAngle,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
planPoint,
|
||||||
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
const newRotation = initialRotation - delta
|
const newRotation = initialRotation - delta
|
||||||
lastRotation = newRotation
|
lastRotation = newRotation
|
||||||
useScene.getState().updateNode(columnId, { rotation: newRotation })
|
useScene.getState().updateNode(columnId, { rotation: newRotation })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type FloorplanAffordance,
|
type FloorplanAffordance,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||||
|
|
||||||
const MIN_ELEVATOR_DIM = 0.6
|
const MIN_ELEVATOR_DIM = 0.6
|
||||||
|
|
||||||
@@ -83,11 +84,13 @@ export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [elevatorId],
|
affectedIds: [elevatorId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
initialAngle,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
planPoint,
|
||||||
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
const newRotation = initialRotation - delta
|
const newRotation = initialRotation - delta
|
||||||
lastRotation = newRotation
|
lastRotation = newRotation
|
||||||
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
|
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
|
||||||
|
|||||||
@@ -267,6 +267,11 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
|||||||
schema: RoofSegmentNode,
|
schema: RoofSegmentNode,
|
||||||
category: 'structure',
|
category: 'structure',
|
||||||
surfaceRole: 'roof',
|
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: () => {
|
defaults: () => {
|
||||||
const stub = RoofSegmentNodeSchema.parse({
|
const stub = RoofSegmentNodeSchema.parse({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||||
|
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||||
|
|
||||||
const MIN_ROOF_DIM = 1
|
const MIN_ROOF_DIM = 1
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ function resolveSegmentFrame(
|
|||||||
* the math survives any parent-roof rotation.
|
* the math survives any parent-roof rotation.
|
||||||
*/
|
*/
|
||||||
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
|
export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> = {
|
||||||
start({ node, payload, nodes, initialPlanPoint, gridSnapStep }) {
|
start({ node, payload, nodes, initialPlanPoint }) {
|
||||||
const { axis, side } = payload as RoofSegmentResizePayload
|
const { axis, side } = payload as RoofSegmentResizePayload
|
||||||
const segmentId = node.id as AnyNodeId
|
const segmentId = node.id as AnyNodeId
|
||||||
const initialValue = axis === 'x' ? node.width : node.depth
|
const initialValue = axis === 'x' ? node.width : node.depth
|
||||||
@@ -79,12 +80,15 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [segmentId],
|
affectedIds: [segmentId],
|
||||||
apply({ planPoint, modifiers }) {
|
apply({ planPoint }) {
|
||||||
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
|
const currentLocal = projectLocalAxis(planPoint[0], planPoint[1])
|
||||||
const delta = (currentLocal - initialLocal) * side
|
const delta = (currentLocal - initialLocal) * side
|
||||||
const rawValue = initialValue + 2 * delta
|
const rawValue = initialValue + 2 * delta
|
||||||
const snappedValue =
|
// Mode-aware grid step (0 outside grid mode, so `lines` / `off` resize
|
||||||
!modifiers.shiftKey && gridSnapStep > 0 ? snapScalar(rawValue, gridSnapStep) : rawValue
|
// 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)
|
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
|
||||||
lastValue = newValue
|
lastValue = newValue
|
||||||
useScene
|
useScene
|
||||||
@@ -121,11 +125,13 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [segmentId],
|
affectedIds: [segmentId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
initialAngle,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
planPoint,
|
||||||
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
lastRotation = initialRotation - delta
|
lastRotation = initialRotation - delta
|
||||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||||
},
|
},
|
||||||
@@ -168,9 +174,12 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [segmentId],
|
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 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 worldPoint = resolveCursor(planPoint, { snap })
|
||||||
const dx = worldPoint[0] - roofPosX
|
const dx = worldPoint[0] - roofPosX
|
||||||
const dz = worldPoint[1] - roofPosZ
|
const dz = worldPoint[1] - roofPosZ
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type ShelfNode,
|
type ShelfNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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
|
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
|
||||||
// value past what the renderer / geometry builder accepts.
|
// value past what the renderer / geometry builder accepts.
|
||||||
@@ -82,11 +83,13 @@ export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [shelfId],
|
affectedIds: [shelfId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
initialAngle,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
planPoint,
|
||||||
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
const newRotationY = initialRotationY - delta
|
const newRotationY = initialRotationY - delta
|
||||||
lastRotation = [r[0], newRotationY, r[2]]
|
lastRotation = [r[0], newRotationY, r[2]]
|
||||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type SpawnNode,
|
type SpawnNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||||
|
|
||||||
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||||
start({ node, initialPlanPoint }) {
|
start({ node, initialPlanPoint }) {
|
||||||
@@ -16,11 +17,13 @@ export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [spawnId],
|
affectedIds: [spawnId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
initialAngle,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
planPoint,
|
||||||
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
lastRotation = initialRotation - delta
|
lastRotation = initialRotation - delta
|
||||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||||
|
|
||||||
// Minimums + max sweep mirror the 3D handles in
|
// Minimums + max sweep mirror the 3D handles in
|
||||||
// `packages/editor/src/components/editor/stair-segment-handles.tsx` so a 2D
|
// `packages/editor/src/components/editor/stair-segment-handles.tsx` so a 2D
|
||||||
@@ -232,12 +233,13 @@ export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
affectedIds: [stairId],
|
affectedIds: [stairId],
|
||||||
apply({ planPoint }) {
|
apply({ planPoint, modifiers }) {
|
||||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
const delta = rotateAffordanceDelta({
|
||||||
let delta = currentAngle - initialAngle
|
center: [cx, cz],
|
||||||
// Wrap to [-π, π] so a drag crossing ±π doesn't flip sign mid-gesture.
|
initialAngle,
|
||||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
planPoint,
|
||||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
free: modifiers.shiftKey,
|
||||||
|
})
|
||||||
const newRotation = initialRotation - delta
|
const newRotation = initialRotation - delta
|
||||||
lastRotation = newRotation
|
lastRotation = newRotation
|
||||||
useScene.getState().updateNode(stairId, { rotation: newRotation })
|
useScene.getState().updateNode(stairId, { rotation: newRotation })
|
||||||
|
|||||||
Reference in New Issue
Block a user