diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx
index 89bd2b6d..5352f64c 100644
--- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx
@@ -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
}
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 53a1317b..954a33e1 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
@@ -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
diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx
index ed008a4f..be16e0b8 100644
--- a/packages/editor/src/components/editor/custom-camera-controls.tsx
+++ b/packages/editor/src/components/editor/custom-camera-controls.tsx
@@ -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 =
diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx
index 9bed4002..a177a01c 100644
--- a/packages/editor/src/components/editor/floating-action-menu.tsx
+++ b/packages/editor/src/components/editor/floating-action-menu.tsx
@@ -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
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 14fb4183..066fa938 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -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,
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx
index c0a72d1d..2f6bbc9c 100644
--- a/packages/editor/src/components/editor/node-arrow-handles.tsx
+++ b/packages/editor/src/components/editor/node-arrow-handles.tsx
@@ -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.
diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx
index 086329ff..daf7b2f4 100644
--- a/packages/editor/src/components/editor/selection-manager.tsx
+++ b/packages/editor/src/components/editor/selection-manager.tsx
@@ -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(() => {
diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx
index 52f15011..e16c594b 100644
--- a/packages/editor/src/components/editor/wall-move-side-handles.tsx
+++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx
@@ -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.
}
diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx
index d83cc72e..70cc7031 100644
--- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx
+++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx
@@ -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')
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index bb02184d..d5a498a4 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -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 = () => {
) : null
})()}
- {movingWallEndpoint &&
+ {endpointTarget &&
+ reshapingNode &&
(() => {
const RegistryAffordance = getRegistryAffordanceTool(
- movingWallEndpoint.wall.type,
+ reshapingNode.type,
'move-endpoint',
)
return RegistryAffordance ? (
-
+
) : null
})()}
- {movingFenceEndpoint &&
+ {isCurveReshape &&
+ reshapingNode &&
(() => {
- const RegistryAffordance = getRegistryAffordanceTool(
- movingFenceEndpoint.fence.type,
- 'move-endpoint',
- )
+ const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve')
return RegistryAffordance ? (
-
-
- ) : null
- })()}
- {curvingWall &&
- (() => {
- const Registry = getRegistryAffordanceTool(curvingWall.type, 'curve')
- return Registry ? (
-
-
-
- ) : null
- })()}
- {curvingFence &&
- (() => {
- const RegistryAffordance = getRegistryAffordanceTool(curvingFence.type, 'curve')
- return RegistryAffordance ? (
-
-
+
) : null
})()}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index d852ae83..a9d68f4d 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -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,
diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts
index 4333003d..c18d2903 100644
--- a/packages/editor/src/lib/editor-api.ts
+++ b/packages/editor/src/lib/editor-api.ts
@@ -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
-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 = {
- 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[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[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))
},
}
}
diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts
index 674bcc54..4a74433e 100644
--- a/packages/editor/src/lib/interaction/scope.ts
+++ b/packages/editor/src/lib/interaction/scope.ts
@@ -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 }
+}
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index edaad0d2..f13cbfa1 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -168,16 +168,6 @@ export type Tool = SiteTool | StructureTool | FurnishTool
*/
export type ToolDefaults = Record
-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()(
},
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()(
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,
diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts
index 5a76c26e..48dac2a7 100644
--- a/packages/editor/src/store/use-interaction-scope.ts
+++ b/packages/editor/src/store/use-interaction-scope.ts
@@ -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
diff --git a/packages/nodes/src/fence/curve-tool.tsx b/packages/nodes/src/fence/curve-tool.tsx
index 0d03b18d..2b4dfee5 100644
--- a/packages/nodes/src/fence/curve-tool.tsx
+++ b/packages/nodes/src/fence/curve-tool.tsx
@@ -20,7 +20,7 @@ import {
markToolCancelConsumed,
snapScalarToGrid,
triggerSFX,
- useEditor,
+ useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -51,7 +51,9 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
])
const exitCurveMode = useCallback(() => {
- useEditor.getState().setCurvingFence(null)
+ useInteractionScope
+ .getState()
+ .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'curve')
}, [])
useEffect(() => {
diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx
index c57be884..c86d2eaf 100644
--- a/packages/nodes/src/fence/move-endpoint-tool.tsx
+++ b/packages/nodes/src/fence/move-endpoint-tool.tsx
@@ -15,11 +15,10 @@ import {
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
MeasurementPill,
- type MovingFenceEndpoint,
triggerSFX,
useAlignmentGuides,
useDragAction,
- useEditor,
+ useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
@@ -40,10 +39,14 @@ import { moveFenceEndpointDragAction } from './actions/move-endpoint'
* - Angle label between this segment and any neighbour segment sharing
* the dragged endpoint — same legacy treatment.
*
- * Mounted by the legacy ToolManager via the `move-endpoint` affordance
- * key. `target.fence` + `target.endpoint` come from the editor store
- * (`useEditor.movingFenceEndpoint`).
+ * Mounted by ToolManager via the `move-endpoint` affordance key. ToolManager
+ * reconstructs this `target` from the reshaped node + the scope's endpoint.
*/
+export type MovingFenceEndpoint = {
+ fence: FenceNode
+ endpoint: 'start' | 'end'
+}
+
type SegmentLike = {
id: string
start: FencePlanPoint
@@ -104,7 +107,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [fenceId] })
- useEditor.getState().setMovingFenceEndpoint(null)
+ useInteractionScope
+ .getState()
+ .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'endpoint')
}
useDragAction({
diff --git a/packages/nodes/src/wall/curve-tool.tsx b/packages/nodes/src/wall/curve-tool.tsx
index f1cd2e0c..4490f075 100644
--- a/packages/nodes/src/wall/curve-tool.tsx
+++ b/packages/nodes/src/wall/curve-tool.tsx
@@ -19,7 +19,7 @@ import {
snapBuildingLocalToWorldGrid,
snapScalarToGrid,
triggerSFX,
- useEditor,
+ useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -47,7 +47,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
])
const exitCurveMode = useCallback(() => {
- useEditor.getState().setCurvingWall(null)
+ useInteractionScope
+ .getState()
+ .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'curve')
}, [])
useEffect(() => {
diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx
index 1a8d9575..0c85e2a4 100644
--- a/packages/nodes/src/wall/move-endpoint-tool.tsx
+++ b/packages/nodes/src/wall/move-endpoint-tool.tsx
@@ -22,12 +22,11 @@ import {
isMagneticSnapActive,
isSegmentLongEnough,
MeasurementPill,
- type MovingWallEndpoint,
markToolCancelConsumed,
snapWallDraftPointDetailed,
triggerSFX,
useAlignmentGuides,
- useEditor,
+ useInteractionScope,
useWallSnapIndicator,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -44,9 +43,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'
* dismisses without committing.
*
* Mounted via `def.affordanceTools['move-endpoint']` from
- * `wall/definition.ts`. Editor state trigger is
- * `useEditor.movingWallEndpoint`.
+ * `wall/definition.ts`. Triggered by an `endpoint` reshape scope; ToolManager
+ * reconstructs this `target` from the reshaped node + the scope's endpoint.
*/
+export type MovingWallEndpoint = {
+ wall: WallNode
+ endpoint: 'start' | 'end'
+}
+
/** Figma-style alignment-snap threshold (meters), matching the item move /
* placement tools. 8 cm gives a magnetic pull without fighting grid snap. */
const ALIGNMENT_THRESHOLD_M = 0.08
@@ -202,7 +206,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const unit = useViewer((s) => s.unit)
const exitMoveMode = useCallback(() => {
- useEditor.getState().setMovingWallEndpoint(null)
+ useInteractionScope
+ .getState()
+ .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'endpoint')
}, [])
useEffect(() => {
diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx
index 31f49519..809a9018 100644
--- a/packages/nodes/src/wall/panel.tsx
+++ b/packages/nodes/src/wall/panel.tsx
@@ -14,6 +14,7 @@ import {
import {
ActionButton,
ActionGroup,
+ curveReshapeScope,
getLinearUnitLabel,
linearControlValueToMeters,
metersToLinearUnit,
@@ -21,7 +22,7 @@ import {
PanelWrapper,
SliderControl,
triggerSFX,
- useEditor,
+ useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Spline } from 'lucide-react'
@@ -31,7 +32,6 @@ export default function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const unit = useViewer((s) => s.unit)
const setSelection = useViewer((s) => s.setSelection)
- const setCurvingWall = useEditor((s) => s.setCurvingWall)
const sceneNode = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -115,9 +115,9 @@ export default function WallPanel() {
const handleCurve = useCallback(() => {
if (!node) return
triggerSFX('sfx:item-pick')
- setCurvingWall(node)
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
setSelection({ selectedIds: [] })
- }, [node, setCurvingWall, setSelection])
+ }, [node, setSelection])
if (!(node && node.type === 'wall' && selectedId)) return null