refactor(editor): delete curving + endpoint reshaping flags

Migrate `curvingWall`, `curvingFence`, `movingWallEndpoint`, `movingFenceEndpoint`
(and their setters) off `useEditor` onto the authoritative interaction scope.
The `reshaping` scope variant gains an `endpoint` discriminator; existence
checks read `useIsCurveReshape()` / `useEndpointReshape()`, and the few sites
that need the node (affordance-tool mounts, wall-vs-fence type checks) read it
from `useReshapingNode()` — a frozen drag-start snapshot, mirroring the old
flags so the tools' own per-frame writes don't feed back. `MovingWallEndpoint`
/ `MovingFenceEndpoint` move to the kind-owned tools that consume them.

`editor-api` is simpler: endpoint engagement is kind-agnostic, and the
`engageMove` reshape clears are gone (the scope is single-owner).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-23 12:18:50 -04:00
co-authored by Claude Opus 4.8
parent 2f2c3ef8f9
commit cfc1fb1672
20 changed files with 263 additions and 262 deletions
@@ -2,6 +2,7 @@
import { memo, type MouseEvent as ReactMouseEvent } from 'react'
import useEditor from '../../store/use-editor'
import { useEndpointReshape, useIsCurveReshape } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
type SvgPoint = {
@@ -49,11 +50,10 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
}: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
if (!isFloorplanHovered || movingNode || endpointReshape || isCurveReshape) {
return null
}
@@ -43,6 +43,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useEditor from '../../../store/use-editor'
import { useEndpointReshape } from '../../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -256,7 +257,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const editorTool = useEditor((s) => s.tool)
const structureLayer = useEditor((s) => s.structureLayer)
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const endpointReshape = useEndpointReshape()
const isOpeningPlacementActive =
(editorPhase === 'structure' &&
editorMode === 'build' &&
@@ -267,7 +268,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
floorplanSelectionTool === 'marquee' &&
structureLayer !== 'zones' &&
!movingNode &&
!movingFenceEndpoint
!endpointReshape
// While the floor plan is not on screen (pure 3D view) it must not react to
// the per-pointer drag publishes below — re-rendering this layer + its
// hundreds of geometry children every move is what tanks 3D-drag framerate
@@ -22,7 +22,7 @@ import {
} from 'three'
import { EDITOR_LAYER } from '../../lib/constants'
import useEditor from '../../store/use-editor'
import { useActiveHandleDrag } from '../../store/use-interaction-scope'
import { useActiveHandleDrag, useEndpointReshape } from '../../store/use-interaction-scope'
const currentTarget = new Vector3()
const tempBox = new Box3()
@@ -613,17 +613,11 @@ export const CustomCameraControls = () => {
const mode = useEditor((s) => s.mode)
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
const movingNode = useEditor((s) => s.movingNode)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const endpointReshape = useEndpointReshape()
const activeHandleDrag = useActiveHandleDrag()
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
const isInteracting = Boolean(
tool ||
movingNode ||
movingWallEndpoint ||
movingFenceEndpoint ||
activeHandleDrag ||
isBoxSelectActive,
tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
)
const touches = useMemo(() => {
const twoFingerAction =
@@ -36,12 +36,16 @@ import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { holeEditScope } from '../../lib/interaction/scope'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import useEditor from '../../store/use-editor'
import useInteractionScope, { useActiveHandleDrag } from '../../store/use-interaction-scope'
import useInteractionScope, {
useActiveHandleDrag,
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu'
@@ -211,12 +215,9 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection)
const unit = useViewer((s) => s.unit)
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
@@ -400,15 +401,15 @@ export function FloatingActionMenu() {
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else if (node.type === 'fence') {
setCurvingFence(node)
useInteractionScope.getState().begin(curveReshapeScope(node.id))
} else {
return
}
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
[canCurveSelectedWall, node, setSelection],
)
const handleMove = useCallback(
(e: React.MouseEvent) => {
@@ -656,9 +657,8 @@ export function FloatingActionMenu() {
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence ||
endpointReshape ||
isCurveReshape ||
menuStepBack
)
return null
@@ -94,7 +94,12 @@ import useEditor, {
isMagneticSnapActive,
selectSiteFloorplanContext,
} from '../../store/use-editor'
import useInteractionScope, { useActiveHandleDrag } from '../../store/use-interaction-scope'
import useInteractionScope, {
useActiveHandleDrag,
useEndpointReshape,
useIsCurveReshape,
useReshapingNode,
} from '../../store/use-interaction-scope'
import usePlacementPreview from '../../store/use-placement-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
@@ -4548,16 +4553,14 @@ export function FloorplanPanel({
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const isCurveReshape = useIsCurveReshape()
const endpointReshape = useEndpointReshape()
const reshapingNode = useReshapingNode()
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const activeHandleDrag = useActiveHandleDrag()
const setPhase = useEditor((state) => state.setPhase)
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
const setMovingNode = useEditor((state) => state.setMovingNode)
const setCurvingWall = useEditor((state) => state.setCurvingWall)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const structureLayer = useEditor((state) => state.structureLayer)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setTool = useEditor((state) => state.setTool)
@@ -5354,9 +5357,9 @@ export function FloorplanPanel({
const isWallMoveActive = movingNode?.type === 'wall'
const isSpawnMoveActive = movingNode?.type === 'spawn'
const isElevatorMoveActive = movingNode?.type === 'elevator'
const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isFenceEndpointMoveActive = movingFenceEndpoint !== null
const isWallCurveActive = isCurveReshape && reshapingNode?.type === 'wall'
const isFenceCurveActive = isCurveReshape && reshapingNode?.type === 'fence'
const isFenceEndpointMoveActive = endpointReshape !== null && reshapingNode?.type === 'fence'
const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5539,14 +5542,14 @@ export function FloorplanPanel({
mode === 'select' &&
floorplanSelectionTool === 'marquee' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
structureLayer !== 'zones'
const isScreenSelectionToolActive =
mode === 'select' &&
floorplanSelectionTool === 'click' &&
(phase === 'structure' || phase === 'furnish') &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
!referenceScaleDraft &&
!pendingReferenceScale
const isDeleteMode = mode === 'delete' && !movingNode
@@ -5554,7 +5557,7 @@ export function FloorplanPanel({
mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
structureLayer !== 'zones'
const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry
const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry
@@ -5567,7 +5570,7 @@ export function FloorplanPanel({
mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
structureLayer === 'zones'
const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones
const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones'
@@ -5578,7 +5581,7 @@ export function FloorplanPanel({
(mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
isFloorplanStructureContextActive) ||
isDeleteMode
const canSelectFloorplanElevators = canSelectFloorplanStairs
@@ -5587,21 +5590,21 @@ export function FloorplanPanel({
(mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
isFloorplanItemContextActive) ||
isDeleteMode
const canFocusFloorplanStairs =
mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
isFloorplanStructureContextActive
const canFocusFloorplanSpawns = canFocusFloorplanStairs
const canFocusFloorplanItems =
mode === 'select' &&
floorplanSelectionTool === 'click' &&
!movingNode &&
!movingFenceEndpoint &&
!isFenceEndpointMoveActive &&
isFloorplanItemContextActive
const visibleSitePolygon = displaySitePolygon
const canUseSiteBoundaryVertexHandles =
@@ -6280,9 +6283,8 @@ export function FloorplanPanel({
const transientFloorplanFit =
cursorPoint != null ||
movingNode != null ||
movingFenceEndpoint != null ||
curvingWall != null ||
curvingFence != null ||
endpointReshape != null ||
isCurveReshape ||
siteVertexDragState != null ||
isPolygonDraftBuildActive
@@ -6292,13 +6294,12 @@ export function FloorplanPanel({
)
}
}, [
curvingFence,
curvingWall,
cursorPoint,
endpointReshape,
fittedViewport,
isCurveReshape,
isPolygonDraftBuildActive,
levelId,
movingFenceEndpoint,
movingNode,
siteVertexDragState,
stopFloorplanViewAnimation,
@@ -48,7 +48,10 @@ import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor'
import useInteractionScope from '../../store/use-interaction-scope'
import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
@@ -183,10 +186,8 @@ export function NodeArrowHandles() {
// resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
// on the legacy wall handles (`WallMoveSideHandles`).
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) =>
@@ -221,10 +222,8 @@ export function NodeArrowHandles() {
// draw stray selection rays. The active handle-drag scope (resize/rotate)
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
!endpointReshape &&
!isCurveReshape
if (!shouldRender || !node || !descriptors) return null
// Key by the selected node id so switching selection REMOUNTS the rig.
@@ -70,7 +70,10 @@ import {
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
import useInteractionScope, { getEditingHole } from '../../store/use-interaction-scope'
import useInteractionScope, {
getEditingHole,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { swallowNextClick } from './node-arrow-handles'
@@ -857,8 +860,7 @@ export const SelectionManager = () => {
const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
const isCurveReshape = useIsCurveReshape()
useEffect(() => {
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -871,7 +873,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'material-paint') return
if (movingNode || curvingWall) return
if (movingNode || isCurveReshape) return
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
@@ -1236,7 +1238,7 @@ export const SelectionManager = () => {
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
}, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -1270,7 +1272,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event)
@@ -1375,11 +1377,11 @@ export const SelectionManager = () => {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
}
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 2 || !isCommandModifier(event)) return
@@ -1482,11 +1484,11 @@ export const SelectionManager = () => {
return () => {
window.removeEventListener('pointerdown', onPointerDown, true)
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
@@ -1700,12 +1702,12 @@ export const SelectionManager = () => {
})
emitter.off('grid:click', onGridClick)
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall || curvingFence) return
if (movingNode || isCurveReshape) return
const onEnter = (event: NodeEvent) => {
// A host-driven drag (handle resize/rotate, box-select) sets
@@ -1846,7 +1848,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
})
}
}, [curvingFence, curvingWall, mode, movingNode])
}, [isCurveReshape, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
@@ -32,9 +32,13 @@ import {
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { endpointReshapeScope } from '../../lib/interaction/scope'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import useInteractionScope from '../../store/use-interaction-scope'
import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import {
createArrowHitAreaGeometry,
@@ -120,10 +124,8 @@ export function WallMoveSideHandles() {
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
// Fence side-move / height / corner-pickers now flow through the
@@ -141,10 +143,8 @@ export function WallMoveSideHandles() {
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
!endpointReshape &&
!isCurveReshape
if (!shouldRender || !selectedNode) return null
@@ -334,7 +334,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = 'grabbing'
useEditor.getState().setMovingWallEndpoint({ wall, endpoint })
useInteractionScope.getState().begin(endpointReshapeScope(wall.id, endpoint))
}
return (
@@ -612,10 +612,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
// Keep the wall selected so it stays the active item once the move
// commits; the `!movingNode` guard on the handles hides them mid-drag.
}
@@ -703,10 +701,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(fence)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'endpoint')
useInteractionScope.getState().endIf((s) => s.kind === 'reshaping' && s.reshape === 'curve')
// Keep the fence selected so it stays active once the move commits.
}
@@ -19,7 +19,7 @@ import {
} from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import useInteractionScope, { useIsCurveReshape } from '../../../store/use-interaction-scope'
import { snapToHalf } from '../../tools/item/placement-math'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -97,7 +97,7 @@ export const CeilingSelectionAffordanceSystem = () => {
const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const isCurveReshape = useIsCurveReshape()
const currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene(
@@ -118,7 +118,7 @@ export const CeilingSelectionAffordanceSystem = () => {
mode === 'select' &&
structureLayer === 'elements' &&
!movingNode &&
!curvingWall &&
!isCurveReshape &&
currentLevelId !== null
if (!shouldRender) return null
@@ -197,8 +197,10 @@ const CeilingSelectionAffordance = ({
const selectCeilingForEdit = useCallback(() => {
const editor = useEditor.getState()
editor.setMovingNode(null)
editor.setMovingWallEndpoint(null)
editor.setCurvingWall(null)
useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
editor.setMode('select')
useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
@@ -484,8 +486,10 @@ const CornerBracket = ({
e.stopPropagation()
useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null)
useInteractionScope
.getState()
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
useEditor.getState().setMode('select')
@@ -2,14 +2,21 @@ import {
type AnyNodeId,
type BuildingNode,
type CeilingNode,
type FenceNode,
nodeRegistry,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { useEditingHole } from '../../store/use-interaction-scope'
import {
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useReshapingNode,
} from '../../store/use-interaction-scope'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
@@ -58,10 +65,20 @@ export const ToolManager: React.FC = () => {
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const endpointReshape = useEndpointReshape()
const isCurveReshape = useIsCurveReshape()
const reshapingNode = useReshapingNode()
// The endpoint affordance tool's `target` is kind-specific
// (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node +
// the scope's endpoint. Memoised so it stays referentially stable across the
// scene-write re-renders during the drag — otherwise a fresh object each frame
// re-fires the tool's setup effect (endpoint drag would loop / freeze).
const endpointTarget = useMemo(() => {
if (!(endpointReshape && reshapingNode)) return null
return reshapingNode.type === 'fence'
? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint }
: { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint }
}, [endpointReshape, reshapingNode])
const editingHole = useEditingHole()
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
@@ -229,45 +246,26 @@ export const ToolManager: React.FC = () => {
</Suspense>
) : null
})()}
{movingWallEndpoint &&
{endpointTarget &&
reshapingNode &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingWallEndpoint.wall.type,
reshapingNode.type,
'move-endpoint',
)
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingWallEndpoint} />
<RegistryAffordance target={endpointTarget} />
</Suspense>
) : null
})()}
{movingFenceEndpoint &&
{isCurveReshape &&
reshapingNode &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
movingFenceEndpoint.fence.type,
'move-endpoint',
)
const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance target={movingFenceEndpoint} />
</Suspense>
) : null
})()}
{curvingWall &&
(() => {
const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
return Registry ? (
<Suspense fallback={null}>
<Registry node={curvingWall} />
</Suspense>
) : null
})()}
{curvingFence &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
return RegistryAffordance ? (
<Suspense fallback={null}>
<RegistryAffordance node={curvingFence} />
<RegistryAffordance node={reshapingNode} />
</Suspense>
) : null
})()}
+5 -3
View File
@@ -245,7 +245,7 @@ export {
getFloorplanWallThickness,
} from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { holeEditScope } from './lib/interaction/scope'
export { curveReshapeScope, endpointReshapeScope, holeEditScope } from './lib/interaction/scope'
export {
buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch,
@@ -320,8 +320,6 @@ export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type {
FloorplanSelectionTool,
MovingFenceEndpoint,
MovingWallEndpoint,
SplitOrientation,
Tool,
ToolDefaults,
@@ -332,8 +330,12 @@ export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from '.
export {
default as useInteractionScope,
getEditingHole,
getIsCurveReshape,
useActiveHandleDrag,
useEditingHole,
useEndpointReshape,
useIsCurveReshape,
useReshapingNode,
} from './store/use-interaction-scope'
export {
default as useOpeningGuides,
+13 -35
View File
@@ -1,33 +1,16 @@
import type { AnyNode, EditorApi, FenceNode, WallNode } from '@pascal-app/core'
import type { AnyNode, EditorApi } from '@pascal-app/core'
import useEditor from '../store/use-editor'
type EditorState = ReturnType<typeof useEditor.getState>
type EndpointEngager = (node: AnyNode, endpoint: 'start' | 'end', editor: EditorState) => void
import useInteractionScope from '../store/use-interaction-scope'
import { endpointReshapeScope } from './interaction/scope'
/**
* Per-kind endpoint-move engagement. Kinds whose 2D endpoint drag
* needs its own store field (wall ↔ `movingWallEndpoint`, fence ↔
* `movingFenceEndpoint`) register their bridge here. The dispatcher
* is a table lookup rather than an `if (type === 'wall')` chain so
* adding a new endpoint-draggable kind is a one-line entry instead
* of a new branch. Each entry casts the generic `AnyNode` to its
* concrete kind — the lookup key already guarantees the type.
*/
const endpointEngagers: Record<string, EndpointEngager> = {
wall: (node, endpoint, editor) =>
editor.setMovingWallEndpoint({ wall: node as WallNode, endpoint }),
fence: (node, endpoint, editor) =>
editor.setMovingFenceEndpoint({ fence: node as FenceNode, endpoint }),
}
/**
* Concrete {@link EditorApi} backed by `useEditor`. Descriptors call into
* editor state through this interface; the editor owns the actual setter
* names so core stays decoupled.
* Concrete {@link EditorApi} backed by `useEditor` + the interaction scope.
* Descriptors call into editor state through this interface; the editor owns
* the actual store wiring so core stays decoupled.
*
* `engageMove` clears any in-progress endpoint drag or curve gesture so
* the move tool takes over cleanly — mirrors the legacy bookkeeping that
* lived inside `WallMoveArrowHandle.activateWallMove` / `FenceMoveArrowHandle`.
* `engageMove` no longer clears any in-progress endpoint drag or curve gesture:
* `setMovingNode` begins the `moving` scope, and the scope is single-owner, so
* it atomically replaces any prior reshape — there is no separate flag to reset.
*/
export function createEditorApi(): EditorApi {
return {
@@ -39,10 +22,6 @@ export function createEditorApi(): EditorApi {
// cast lets registry-driven move kinds through without forcing a
// schema-level type widening.
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
},
engageMoveDrag(node: AnyNode) {
const editor = useEditor.getState()
@@ -50,13 +29,12 @@ export function createEditorApi(): EditorApi {
// it at setup and wires its commit-on-release listener.
editor.setPlacementDragMode(true)
editor.setMovingNode(node as Parameters<typeof editor.setMovingNode>[0])
editor.setMovingWallEndpoint(null)
editor.setMovingFenceEndpoint(null)
editor.setCurvingWall(null)
editor.setCurvingFence(null)
},
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
endpointEngagers[node.type]?.(node, endpoint, useEditor.getState())
// Endpoint reshape is kind-agnostic: the scope carries the node id + which
// endpoint, and consumers recover the node from the scene. Adding a new
// endpoint-draggable kind needs no entry here.
useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint))
},
}
}
+45 -2
View File
@@ -35,8 +35,15 @@ export type InteractionScope =
| { kind: 'handle-drag'; nodeId: string; handle: string }
// Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…).
| { kind: 'drafting'; tool: string }
// Reshaping a selected node's geometry (see ReshapeKind).
| { kind: 'reshaping'; nodeId: string; reshape: ReshapeKind; holeIndex?: number }
// Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set
// only for `reshape: 'hole'`; `endpoint` only for `reshape: 'endpoint'`.
| {
kind: 'reshaping'
nodeId: string
reshape: ReshapeKind
holeIndex?: number
endpoint?: 'start' | 'end'
}
// Marquee selection drag.
| { kind: 'box-select' }
// Material paint application.
@@ -111,3 +118,39 @@ export function holeEditScope(target: {
holeIndex: target.holeIndex,
}
}
// True while the selected node's geometry is being curved (legacy
// `curvingWall` / `curvingFence` — now one scope; the wall-vs-fence kind is
// recovered from the reshaped node's type, looked up from the scene by nodeId).
export function isCurveReshape(scope: InteractionScope): boolean {
return scope.kind === 'reshaping' && scope.reshape === 'curve'
}
// The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node
// itself (consumers fetch the node from the scene by `nodeId`; it is stable for
// the duration of the drag).
export function endpointReshapeInfo(
scope: InteractionScope,
): { nodeId: string; endpoint: 'start' | 'end' } | null {
return scope.kind === 'reshaping' && scope.reshape === 'endpoint' && scope.endpoint !== undefined
? { nodeId: scope.nodeId, endpoint: scope.endpoint }
: null
}
// The id of the node being reshaped (any reshape kind), for the scene lookup
// that recovers the full node payload a few consumers still need.
export function reshapingNodeId(scope: InteractionScope): string | null {
return scope.kind === 'reshaping' ? scope.nodeId : null
}
// Builders so producers don't re-spell the discriminator at every call site.
export function curveReshapeScope(nodeId: string): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'curve' }
}
export function endpointReshapeScope(
nodeId: string,
endpoint: 'start' | 'end',
): ActiveInteractionScope {
return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint }
}
-72
View File
@@ -168,16 +168,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
*/
export type ToolDefaults = Record<string, unknown>
export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
}
export type MovingFenceEndpoint = {
fence: FenceNode
endpoint: 'start' | 'end'
}
export type MaterialTargetRole =
| WallSurfaceSide
| StairSurfaceMaterialRole
@@ -293,10 +283,6 @@ type EditorState = {
*/
movingNodeOrigin: '2d' | '3d' | null
setMovingNodeOrigin: (origin: '2d' | '3d' | null) => void
movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
/**
* World axis the R/T keyboard rotation turns around, for kinds with
* full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
@@ -305,10 +291,6 @@ type EditorState = {
*/
rotationAxis: 'x' | 'y' | 'z'
cycleRotationAxis: () => 'x' | 'y' | 'z'
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null
@@ -852,34 +834,6 @@ const useEditor = create<EditorState>()(
},
movingNodeOrigin: null as '2d' | '3d' | null,
setMovingNodeOrigin: (origin) => set({ movingNodeOrigin: origin }),
movingWallEndpoint: null,
setMovingWallEndpoint: (value) => {
const scope = useInteractionScope.getState()
if (value) scope.begin({ kind: 'reshaping', nodeId: value.wall.id, reshape: 'endpoint' })
else {
const prev = get().movingWallEndpoint
if (prev)
scope.endIf(
(s) =>
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.wall.id,
)
}
set({ movingWallEndpoint: value })
},
movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => {
const scope = useInteractionScope.getState()
if (value) scope.begin({ kind: 'reshaping', nodeId: value.fence.id, reshape: 'endpoint' })
else {
const prev = get().movingFenceEndpoint
if (prev)
scope.endIf(
(s) =>
s.kind === 'reshaping' && s.reshape === 'endpoint' && s.nodeId === prev.fence.id,
)
}
set({ movingFenceEndpoint: value })
},
rotationAxis: 'y',
cycleRotationAxis: () => {
const order = ['y', 'x', 'z'] as const
@@ -887,32 +841,6 @@ const useEditor = create<EditorState>()(
set({ rotationAxis: next })
return next
},
curvingWall: null,
setCurvingWall: (wall) => {
const scope = useInteractionScope.getState()
if (wall) scope.begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'curve' })
else {
const prev = get().curvingWall
if (prev)
scope.endIf(
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
)
}
set({ curvingWall: wall })
},
curvingFence: null,
setCurvingFence: (fence) => {
const scope = useInteractionScope.getState()
if (fence) scope.begin({ kind: 'reshaping', nodeId: fence.id, reshape: 'curve' })
else {
const prev = get().curvingFence
if (prev)
scope.endIf(
(s) => s.kind === 'reshaping' && s.reshape === 'curve' && s.nodeId === prev.id,
)
}
set({ curvingFence: fence })
},
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
@@ -1,13 +1,18 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { useRef } from 'react'
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
import {
type ActiveInteractionScope,
editingHoleInfo,
endpointReshapeInfo,
handleDragInfo,
IDLE_SCOPE,
type InteractionScope,
isCurveReshape,
reshapingNodeId,
} from '../lib/interaction/scope'
// The authoritative interaction state machine. A single owner holds exactly one
@@ -69,4 +74,39 @@ export const useEditingHole = (): { nodeId: string; holeIndex: number } | null =
export const getEditingHole = (): { nodeId: string; holeIndex: number } | null =>
editingHoleInfo(useInteractionScope.getState().scope)
export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionScope.getState().scope)
// Replaces the legacy `curvingWall` / `curvingFence` existence flags. The
// wall-vs-fence distinction (both now map to one `reshaping/'curve'` scope) is
// recovered by reading the reshaped node's type from `useReshapingNode`.
export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope))
// Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads,
// minus the node (fetch it from `useReshapingNode`).
export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null =>
useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope)))
// The node currently being reshaped (curve / endpoint / hole), looked up live
// from the scene by the scope's `nodeId`. During a reshape the scene node holds
// the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so
// consumers that need the full node (affordance-tool mounts, wall-vs-fence type
// checks) read it here instead of from a parallel flag.
export const useReshapingNode = (): AnyNode | null => {
const nodeId = useInteractionScope((s) => reshapingNodeId(s.scope))
// Snapshot the node ONCE when the reshape begins (keyed on nodeId), like the
// legacy `curvingWall` / `movingWallEndpoint.wall` flags did. The affordance
// tools write the node live during the drag; subscribing to the live scene
// node would feed those writes straight back into the tool — the curve resets
// on pointer-stop, the endpoint drag loops and freezes. nodeId is stable for
// the whole gesture, so a ref snapshot stays frozen until the next reshape.
const snapshot = useRef<{ id: string | null; node: AnyNode | null }>({ id: null, node: null })
if (snapshot.current.id !== nodeId) {
snapshot.current = {
id: nodeId,
node: nodeId ? (useScene.getState().nodes[nodeId as AnyNodeId] ?? null) : null,
}
}
return snapshot.current.node
}
export default useInteractionScope