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:
co-authored by
Claude Opus 4.8
parent
2f2c3ef8f9
commit
cfc1fb1672
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
+11
-7
@@ -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
|
||||
})()}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user