refactor(editor): delete curving + endpoint reshaping flags

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

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

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