refactor(editor): delete editingHole + activeHandleDrag legacy flags
Migrate the two pure-mirror interaction flags off `useEditor` onto the authoritative `useInteractionScope`. Both carried payloads byte-identical to the scope union, so this is a zero-behaviour-change refactor: readers use the reference-stable `useEditingHole()` / `useActiveHandleDrag()` hooks (or the `getEditingHole()` imperative read), and producers drive the scope directly with guarded `endIf` so clearing one interaction never stomps an unrelated scope. Adds the `holeEditScope` builder + a no-leaked-flag invariant test. Closes the first slice of the legacy-flag deletion; the rich-payload flags (movingNode, curving*, *Endpoint, placementDragMode) remain. 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
f773e6b8c5
commit
2f2c3ef8f9
@@ -22,6 +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'
|
||||||
|
|
||||||
const currentTarget = new Vector3()
|
const currentTarget = new Vector3()
|
||||||
const tempBox = new Box3()
|
const tempBox = new Box3()
|
||||||
@@ -614,7 +615,7 @@ export const CustomCameraControls = () => {
|
|||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||||
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
||||||
const isInteracting = Boolean(
|
const isInteracting = Boolean(
|
||||||
tool ||
|
tool ||
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ 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 { 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 from '../../store/use-interaction-scope'
|
import useInteractionScope, { useActiveHandleDrag } 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'
|
||||||
|
|
||||||
@@ -217,12 +218,11 @@ export function FloatingActionMenu() {
|
|||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
||||||
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`
|
||||||
// flips only at drag start / end, so subscribing here is cheap — the live
|
// flips only at drag start / end, so subscribing here is cheap — the live
|
||||||
// height value is written imperatively in the useFrame below.
|
// height value is written imperatively in the useFrame below.
|
||||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
||||||
const rotationAxis = useEditor((s) => s.rotationAxis)
|
const rotationAxis = useEditor((s) => s.rotationAxis)
|
||||||
// The floating action menu is an action-conflicting control: hard-hidden
|
// The floating action menu is an action-conflicting control: hard-hidden
|
||||||
@@ -634,11 +634,13 @@ export function FloatingActionMenu() {
|
|||||||
holes: [...currentHoles, newHole],
|
holes: [...currentHoles, newHole],
|
||||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||||
})
|
})
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
|
||||||
// Re-assert selection so the node stays selected
|
// Re-assert selection so the node stays selected
|
||||||
setSelection({ selectedIds: [selectedId] })
|
setSelection({ selectedIds: [selectedId] })
|
||||||
},
|
},
|
||||||
[node, selectedId, updateNode, setEditingHole, setSelection],
|
[node, selectedId, updateNode, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import useEditor, {
|
|||||||
isMagneticSnapActive,
|
isMagneticSnapActive,
|
||||||
selectSiteFloorplanContext,
|
selectSiteFloorplanContext,
|
||||||
} from '../../store/use-editor'
|
} from '../../store/use-editor'
|
||||||
|
import useInteractionScope, { useActiveHandleDrag } 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'
|
||||||
@@ -4551,7 +4552,7 @@ export function FloorplanPanel({
|
|||||||
const curvingFence = useEditor((state) => state.curvingFence)
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
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 = useEditor((state) => state.activeHandleDrag)
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
||||||
const setMovingNode = useEditor((state) => state.setMovingNode)
|
const setMovingNode = useEditor((state) => state.setMovingNode)
|
||||||
@@ -4561,8 +4562,6 @@ export function FloorplanPanel({
|
|||||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
const setTool = useEditor((state) => state.setTool)
|
const setTool = useEditor((state) => state.setTool)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const editingHole = useEditor((state) => state.editingHole)
|
|
||||||
const setEditingHole = useEditor((state) => state.setEditingHole)
|
|
||||||
const deleteNode = useScene((state) => state.deleteNode)
|
const deleteNode = useScene((state) => state.deleteNode)
|
||||||
const updateNode = useScene((state) => state.updateNode)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
const {
|
const {
|
||||||
@@ -7215,12 +7214,14 @@ export function FloorplanPanel({
|
|||||||
const draft = siteBoundaryDraftRef.current
|
const draft = siteBoundaryDraftRef.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
clearSiteBoundaryLivePreview(draft.siteId)
|
clearSiteBoundaryLivePreview(draft.siteId)
|
||||||
const editor = useEditor.getState()
|
const scope = useInteractionScope.getState().scope
|
||||||
|
const activeHandleDrag =
|
||||||
|
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
||||||
if (
|
if (
|
||||||
editor.activeHandleDrag?.nodeId === draft.siteId &&
|
activeHandleDrag?.nodeId === draft.siteId &&
|
||||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||||
) {
|
) {
|
||||||
editor.setActiveHandleDrag(null)
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9718,7 +9719,9 @@ export function FloorplanPanel({
|
|||||||
siteBoundaryDraftRef.current = nextDraft
|
siteBoundaryDraftRef.current = nextDraft
|
||||||
setSiteBoundaryDraft(nextDraft)
|
setSiteBoundaryDraft(nextDraft)
|
||||||
setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
|
setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
|
||||||
useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
||||||
setSiteVertexDragState({
|
setSiteVertexDragState({
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
siteId,
|
siteId,
|
||||||
@@ -9800,7 +9803,9 @@ export function FloorplanPanel({
|
|||||||
siteBoundaryDraftRef.current = nextDraft
|
siteBoundaryDraftRef.current = nextDraft
|
||||||
setSiteBoundaryDraft(nextDraft)
|
setSiteBoundaryDraft(nextDraft)
|
||||||
setSiteBoundaryLivePreview(siteId, nextPolygon)
|
setSiteBoundaryLivePreview(siteId, nextPolygon)
|
||||||
useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
||||||
setSiteVertexDragState({
|
setSiteVertexDragState({
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
siteId,
|
siteId,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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 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'
|
||||||
@@ -681,12 +682,14 @@ function LinearArrow({
|
|||||||
overrideId,
|
overrideId,
|
||||||
onBegin: () => {
|
onBegin: () => {
|
||||||
if (measureLabel) {
|
if (measureLabel) {
|
||||||
useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.begin({ kind: 'handle-drag', nodeId, handle: measureLabel })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEnd: () => {
|
onEnd: () => {
|
||||||
if (measureLabel) {
|
if (measureLabel) {
|
||||||
useEditor.getState().setActiveHandleDrag(null)
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
}
|
}
|
||||||
if (onDrag) useOpeningGuides.getState().clear()
|
if (onDrag) useOpeningGuides.getState().clear()
|
||||||
},
|
},
|
||||||
@@ -1149,15 +1152,17 @@ function ArcArrow({
|
|||||||
// handles route a measurement label here; rotate gets a sentinel label so
|
// handles route a measurement label here; rotate gets a sentinel label so
|
||||||
// the HUD shows the rotate hint, not a dimension pill.
|
// the HUD shows the rotate hint, not a dimension pill.
|
||||||
if (isRotateShape) {
|
if (isRotateShape) {
|
||||||
useEditor
|
useInteractionScope
|
||||||
.getState()
|
.getState()
|
||||||
.setActiveHandleDrag({ nodeId: node.id, label: ROTATE_HANDLE_DRAG_LABEL })
|
.begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onEnd: () => {
|
onEnd: () => {
|
||||||
setRotationDelta(null)
|
setRotationDelta(null)
|
||||||
if (isRotateShape) useEditor.getState().setActiveHandleDrag(null)
|
if (isRotateShape) {
|
||||||
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
||||||
const hit = new Vector3()
|
const hit = new Vector3()
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ 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 { 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'
|
||||||
|
|
||||||
@@ -1557,8 +1558,10 @@ export const SelectionManager = () => {
|
|||||||
// Clicking any node (e.g. the slab surface outside a hole) exits slab
|
// Clicking any node (e.g. the slab surface outside a hole) exits slab
|
||||||
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
|
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
|
||||||
// click reaching here means the user clicked outside the hole.
|
// click reaching here means the user clicked outside the hole.
|
||||||
if (useEditor.getState().editingHole) {
|
if (getEditingHole()) {
|
||||||
useEditor.getState().setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
}
|
}
|
||||||
|
|
||||||
activeStrategy.handleSelect(
|
activeStrategy.handleSelect(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
|||||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||||
import { formatLinearMeasurement } from '../../lib/measurements'
|
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||||
import useEditor from '../../store/use-editor'
|
import { useActiveHandleDrag } from '../../store/use-interaction-scope'
|
||||||
|
|
||||||
type ViewportSize = {
|
type ViewportSize = {
|
||||||
width: number
|
width: number
|
||||||
@@ -37,7 +37,7 @@ export function SiteEdgeLabels() {
|
|||||||
const node = state.nodes[firstRoot]
|
const node = state.nodes[firstRoot]
|
||||||
return node?.type === 'site' ? (node as SiteNode) : null
|
return node?.type === 'site' ? (node as SiteNode) : null
|
||||||
})
|
})
|
||||||
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const unit = useViewer((state) => state.unit)
|
const unit = useViewer((state) => state.unit)
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
const cameraMode = useViewer((state) => state.cameraMode)
|
||||||
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import {
|
|||||||
} from 'three'
|
} from 'three'
|
||||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
|
import { holeEditScope } from '../../lib/interaction/scope'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import useInteractionScope, { useEditingHole } from '../../store/use-interaction-scope'
|
||||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import { swallowNextClick } from './handles/use-handle-drag'
|
import { swallowNextClick } from './handles/use-handle-drag'
|
||||||
|
|
||||||
@@ -254,7 +256,7 @@ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
|
|||||||
const node = useScene((state) => state.nodes[slabId as AnyNodeId])
|
const node = useScene((state) => state.nodes[slabId as AnyNodeId])
|
||||||
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
|
const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
|
||||||
const hoveredHole = useEditor((state) => state.hoveredHole)
|
const hoveredHole = useEditor((state) => state.hoveredHole)
|
||||||
const editingHole = useEditor((state) => state.editingHole)
|
const editingHole = useEditingHole()
|
||||||
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
const setHoveredHole = useEditor((state) => state.setHoveredHole)
|
||||||
|
|
||||||
const slab = node?.type === 'slab' ? (node as SlabNode) : null
|
const slab = node?.type === 'slab' ? (node as SlabNode) : null
|
||||||
@@ -389,21 +391,25 @@ function SlabHoleHighlight({
|
|||||||
// user edits the source rather than the synced hole. Everything else —
|
// user edits the source rather than the synced hole. Everything else —
|
||||||
// manual holes and holes that predate holeMetadata — opens the editor.
|
// manual holes and holes that predate holeMetadata — opens the editor.
|
||||||
if (metadata?.source === 'stair' && metadata.stairId) {
|
if (metadata?.source === 'stair' && metadata.stairId) {
|
||||||
useEditor.getState().setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
useEditor.getState().setHoveredHole(null)
|
useEditor.getState().setHoveredHole(null)
|
||||||
resetPointerCursor()
|
resetPointerCursor()
|
||||||
selectOwnedNode(metadata.stairId, 'stair')
|
selectOwnedNode(metadata.stairId, 'stair')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (metadata?.source === 'elevator' && metadata.elevatorId) {
|
if (metadata?.source === 'elevator' && metadata.elevatorId) {
|
||||||
useEditor.getState().setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
useEditor.getState().setHoveredHole(null)
|
useEditor.getState().setHoveredHole(null)
|
||||||
resetPointerCursor()
|
resetPointerCursor()
|
||||||
selectOwnedNode(metadata.elevatorId, 'elevator')
|
selectOwnedNode(metadata.elevatorId, 'elevator')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
|
useInteractionScope.getState().begin(holeEditScope({ nodeId: slabId, holeIndex }))
|
||||||
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
|
useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
|
||||||
},
|
},
|
||||||
[holeIndex, metadata, slabId],
|
[holeIndex, metadata, slabId],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
|||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
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 { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import {
|
import {
|
||||||
createArrowHitAreaGeometry,
|
createArrowHitAreaGeometry,
|
||||||
@@ -468,7 +469,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
|
|
||||||
document.body.style.cursor = 'ns-resize'
|
document.body.style.cursor = 'ns-resize'
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useEditor.getState().setActiveHandleDrag({ nodeId: wallId, label: 'height' })
|
useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: wallId, handle: 'height' })
|
||||||
// Suppress R3F node pointer events until pointerup completes so the
|
// Suppress R3F node pointer events until pointerup completes so the
|
||||||
// synthesized click doesn't reroute selection to whatever mesh sits
|
// synthesized click doesn't reroute selection to whatever mesh sits
|
||||||
// under the cursor at release.
|
// under the cursor at release.
|
||||||
@@ -498,7 +499,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
|||||||
document.body.style.cursor = ''
|
document.body.style.cursor = ''
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useEditor.getState().setActiveHandleDrag(null)
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
useViewer.getState().setInputDragging(false)
|
useViewer.getState().setInputDragging(false)
|
||||||
dragCleanupRef.current = null
|
dragCleanupRef.current = null
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -19,6 +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 { 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'
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ const CeilingSelectionAffordance = ({
|
|||||||
editor.setMovingNode(null)
|
editor.setMovingNode(null)
|
||||||
editor.setMovingWallEndpoint(null)
|
editor.setMovingWallEndpoint(null)
|
||||||
editor.setCurvingWall(null)
|
editor.setCurvingWall(null)
|
||||||
editor.setEditingHole(null)
|
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] })
|
||||||
}, [effectiveCeiling.id])
|
}, [effectiveCeiling.id])
|
||||||
@@ -485,7 +486,7 @@ const CornerBracket = ({
|
|||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
useEditor.getState().setMovingWallEndpoint(null)
|
useEditor.getState().setMovingWallEndpoint(null)
|
||||||
useEditor.getState().setCurvingWall(null)
|
useEditor.getState().setCurvingWall(null)
|
||||||
useEditor.getState().setEditingHole(null)
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
|
||||||
emitter.emit('ceiling:click' as any, {
|
emitter.emit('ceiling:click' as any, {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { EDITOR_LAYER } from '../../../lib/constants'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
|
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
|
||||||
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
|
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
|
||||||
|
import useInteractionScope from '../../../store/use-interaction-scope'
|
||||||
import {
|
import {
|
||||||
ARROW_COLOR,
|
ARROW_COLOR,
|
||||||
ARROW_HOVER_COLOR,
|
ARROW_HOVER_COLOR,
|
||||||
@@ -376,14 +377,20 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
|
|
||||||
if (!siteId) return
|
if (!siteId) return
|
||||||
|
|
||||||
const editor = useEditor.getState()
|
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
useInteractionScope
|
||||||
} else if (
|
.getState()
|
||||||
editor.activeHandleDrag?.nodeId === siteId &&
|
.begin({ kind: 'handle-drag', nodeId: siteId, handle: SITE_BOUNDARY_DRAG_LABEL })
|
||||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
} else {
|
||||||
) {
|
const scope = useInteractionScope.getState().scope
|
||||||
editor.setActiveHandleDrag(null)
|
const activeHandleDrag =
|
||||||
|
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
||||||
|
if (
|
||||||
|
activeHandleDrag?.nodeId === siteId &&
|
||||||
|
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||||
|
) {
|
||||||
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDragging) {
|
if (!isDragging) {
|
||||||
@@ -396,12 +403,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!siteId) return
|
if (!siteId) return
|
||||||
const editor = useEditor.getState()
|
const scope = useInteractionScope.getState().scope
|
||||||
|
const activeHandleDrag =
|
||||||
|
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
||||||
if (
|
if (
|
||||||
editor.activeHandleDrag?.nodeId === siteId &&
|
activeHandleDrag?.nodeId === siteId &&
|
||||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||||
) {
|
) {
|
||||||
editor.setActiveHandleDrag(null)
|
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
||||||
}
|
}
|
||||||
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
||||||
isDraggingSiteBoundaryRef.current = false
|
isDraggingSiteBoundaryRef.current = false
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { type ComponentType, lazy, Suspense } from 'react'
|
import { type ComponentType, lazy, Suspense } 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 { 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'
|
||||||
@@ -61,7 +62,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
const curvingFence = useEditor((state) => state.curvingFence)
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
const editingHole = useEditor((state) => state.editingHole)
|
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)
|
||||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../../../lib/contextual-help'
|
} from '../../../lib/contextual-help'
|
||||||
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
import { canDirectMoveNode, canDirectRotateNode } from '../../../lib/direct-manipulation'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { useActiveHandleDrag } from '../../../store/use-interaction-scope'
|
||||||
import { BuildingHelper } from './building-helper'
|
import { BuildingHelper } from './building-helper'
|
||||||
import { ContextualHelperPanel } from './contextual-helper-panel'
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
||||||
import { ItemHelper } from './item-helper'
|
import { ItemHelper } from './item-helper'
|
||||||
@@ -66,7 +67,7 @@ export function HelperManager() {
|
|||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const tool = useEditor((s) => s.tool)
|
const tool = useEditor((s) => s.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
const activeHandleDrag = useActiveHandleDrag()
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const modifiers = useActiveModifierKeys()
|
const modifiers = useActiveModifierKeys()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
|
import { emitDeleteSFX, sfxEmitter } from '../lib/sfx-bus'
|
||||||
import { toggleWindowOpenState } from '../lib/window-interaction'
|
import { toggleWindowOpenState } from '../lib/window-interaction'
|
||||||
import useEditor from '../store/use-editor'
|
import useEditor from '../store/use-editor'
|
||||||
|
import useInteractionScope from '../store/use-interaction-scope'
|
||||||
|
|
||||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||||
// so that the global Escape handler knows not to also switch to select mode.
|
// so that the global Escape handler knows not to also switch to select mode.
|
||||||
@@ -101,7 +102,9 @@ export const useKeyboard = ({
|
|||||||
const currentPhase = useEditor.getState().phase
|
const currentPhase = useEditor.getState().phase
|
||||||
const currentStructureLayer = useEditor.getState().structureLayer
|
const currentStructureLayer = useEditor.getState().structureLayer
|
||||||
|
|
||||||
useEditor.getState().setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
|
||||||
|
|
||||||
// From zone mode, return to structure select
|
// From zone mode, return to structure select
|
||||||
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
|
||||||
|
|||||||
@@ -245,6 +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 {
|
export {
|
||||||
buildResetSurfaceMaterialUpdates,
|
buildResetSurfaceMaterialUpdates,
|
||||||
buildRoofSurfaceMaterialPatch,
|
buildRoofSurfaceMaterialPatch,
|
||||||
@@ -328,6 +329,12 @@ export type {
|
|||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
} from './store/use-editor'
|
} from './store/use-editor'
|
||||||
export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
|
export { default as useEditor, isAngleSnapActive, isMagneticSnapActive } from './store/use-editor'
|
||||||
|
export {
|
||||||
|
default as useInteractionScope,
|
||||||
|
getEditingHole,
|
||||||
|
useActiveHandleDrag,
|
||||||
|
useEditingHole,
|
||||||
|
} from './store/use-interaction-scope'
|
||||||
export {
|
export {
|
||||||
default as useOpeningGuides,
|
default as useOpeningGuides,
|
||||||
type OpeningGuide3D,
|
type OpeningGuide3D,
|
||||||
|
|||||||
@@ -77,3 +77,37 @@ export function scopeNodeId(scope: InteractionScope): string | null {
|
|||||||
export function selectionEnabled(scope: InteractionScope): boolean {
|
export function selectionEnabled(scope: InteractionScope): boolean {
|
||||||
return scope.kind === 'idle'
|
return scope.kind === 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derived views of the scope that mirror the legacy `useEditor` flags they
|
||||||
|
// replaced. Each returns null unless that exact interaction is active, so a
|
||||||
|
// stale payload is unrepresentable: the value is a pure function of the single
|
||||||
|
// authoritative scope, not an independent flag that can drift out of sync.
|
||||||
|
|
||||||
|
// The legacy `activeHandleDrag` flag. `label` keeps the legacy field name so
|
||||||
|
// downstream `=== ROTATE_HANDLE_DRAG_LABEL` / `=== 'height'` checks are unchanged.
|
||||||
|
export function handleDragInfo(scope: InteractionScope): { nodeId: string; label: string } | null {
|
||||||
|
return scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legacy `editingHole` flag (`SurfaceHoleTarget`).
|
||||||
|
export function editingHoleInfo(
|
||||||
|
scope: InteractionScope,
|
||||||
|
): { nodeId: string; holeIndex: number } | null {
|
||||||
|
return scope.kind === 'reshaping' && scope.reshape === 'hole' && scope.holeIndex !== undefined
|
||||||
|
? { nodeId: scope.nodeId, holeIndex: scope.holeIndex }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the scope payload for a hole reshape, so producers don't re-spell the
|
||||||
|
// discriminator at every call site.
|
||||||
|
export function holeEditScope(target: {
|
||||||
|
nodeId: string
|
||||||
|
holeIndex: number
|
||||||
|
}): ActiveInteractionScope {
|
||||||
|
return {
|
||||||
|
kind: 'reshaping',
|
||||||
|
nodeId: target.nodeId,
|
||||||
|
reshape: 'hole',
|
||||||
|
holeIndex: target.holeIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -362,7 +362,6 @@ function resetEditorInteractionState() {
|
|||||||
movingNode: null,
|
movingNode: null,
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
spaces: {},
|
spaces: {},
|
||||||
editingHole: null,
|
|
||||||
hoveredHole: null,
|
hoveredHole: null,
|
||||||
isPreviewMode: false,
|
isPreviewMode: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -297,18 +297,6 @@ type EditorState = {
|
|||||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||||
movingFenceEndpoint: MovingFenceEndpoint | null
|
movingFenceEndpoint: MovingFenceEndpoint | null
|
||||||
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
||||||
/**
|
|
||||||
* Generic per-kind handle drag state. Set by a node's resize handle
|
|
||||||
* (height arrow, width arrow, rise / sweep / inner-radius for curved
|
|
||||||
* stairs, …) at drag-start and cleared on drag-end. `label`
|
|
||||||
* identifies which dimension the handle controls — measurement
|
|
||||||
* overlays read it to render the right caption; the camera controls
|
|
||||||
* use the truthy value to suppress one-finger pan-rotate. Replaces
|
|
||||||
* the previous per-kind `resizing*` fields so adding a new resize
|
|
||||||
* handle doesn't require a new store field.
|
|
||||||
*/
|
|
||||||
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
|
||||||
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | 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
|
||||||
@@ -343,9 +331,6 @@ type EditorState = {
|
|||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
spaces: Record<string, Space>
|
spaces: Record<string, Space>
|
||||||
setSpaces: (spaces: Record<string, Space>) => void
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
|
|
||||||
editingHole: SurfaceHoleTarget | null
|
|
||||||
setEditingHole: (hole: SurfaceHoleTarget | null) => void
|
|
||||||
hoveredHole: SurfaceHoleTarget | null
|
hoveredHole: SurfaceHoleTarget | null
|
||||||
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
|
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
|
||||||
// Preview mode (viewer-like experience inside the editor)
|
// Preview mode (viewer-like experience inside the editor)
|
||||||
@@ -895,13 +880,6 @@ const useEditor = create<EditorState>()(
|
|||||||
}
|
}
|
||||||
set({ movingFenceEndpoint: value })
|
set({ movingFenceEndpoint: value })
|
||||||
},
|
},
|
||||||
activeHandleDrag: null,
|
|
||||||
setActiveHandleDrag: (drag) => {
|
|
||||||
const scope = useInteractionScope.getState()
|
|
||||||
if (drag) scope.begin({ kind: 'handle-drag', nodeId: drag.nodeId, handle: drag.label })
|
|
||||||
else scope.endIf((s) => s.kind === 'handle-drag')
|
|
||||||
set({ activeHandleDrag: drag })
|
|
||||||
},
|
|
||||||
rotationAxis: 'y',
|
rotationAxis: 'y',
|
||||||
cycleRotationAxis: () => {
|
cycleRotationAxis: () => {
|
||||||
const order = ['y', 'x', 'z'] as const
|
const order = ['y', 'x', 'z'] as const
|
||||||
@@ -1015,25 +993,6 @@ const useEditor = create<EditorState>()(
|
|||||||
}),
|
}),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
editingHole: null,
|
|
||||||
setEditingHole: (hole) => {
|
|
||||||
const scope = useInteractionScope.getState()
|
|
||||||
if (hole)
|
|
||||||
scope.begin({
|
|
||||||
kind: 'reshaping',
|
|
||||||
nodeId: hole.nodeId,
|
|
||||||
reshape: 'hole',
|
|
||||||
holeIndex: hole.holeIndex,
|
|
||||||
})
|
|
||||||
else {
|
|
||||||
const prev = get().editingHole
|
|
||||||
if (prev)
|
|
||||||
scope.endIf(
|
|
||||||
(s) => s.kind === 'reshaping' && s.reshape === 'hole' && s.nodeId === prev.nodeId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
set({ editingHole: hole })
|
|
||||||
},
|
|
||||||
hoveredHole: null,
|
hoveredHole: null,
|
||||||
setHoveredHole: (hole) =>
|
setHoveredHole: (hole) =>
|
||||||
set((state) =>
|
set((state) =>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test'
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
import { isActive, isIdle, scopeNodeId, selectionEnabled } from '../lib/interaction/scope'
|
import {
|
||||||
|
type ActiveInteractionScope,
|
||||||
|
editingHoleInfo,
|
||||||
|
handleDragInfo,
|
||||||
|
isActive,
|
||||||
|
isIdle,
|
||||||
|
scopeNodeId,
|
||||||
|
selectionEnabled,
|
||||||
|
} from '../lib/interaction/scope'
|
||||||
import useInteractionScope from './use-interaction-scope'
|
import useInteractionScope from './use-interaction-scope'
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -77,3 +85,59 @@ describe('use-interaction-scope state machine', () => {
|
|||||||
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
expect(useInteractionScope.getState().scope.kind).toBe('idle')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('derived flag views are leak-free (no parallel flags)', () => {
|
||||||
|
const scope = () => useInteractionScope.getState().scope
|
||||||
|
|
||||||
|
test('handleDragInfo mirrors handle-drag and clears on end', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
||||||
|
expect(handleDragInfo(scope())).toEqual({ nodeId: 'wall_1', label: 'height' })
|
||||||
|
s.end()
|
||||||
|
// After end the derived view is null — a stale activeHandleDrag is
|
||||||
|
// unrepresentable because it is a pure function of the single scope.
|
||||||
|
expect(handleDragInfo(scope())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('editingHoleInfo mirrors a hole reshape and clears on end', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 })
|
||||||
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 })
|
||||||
|
s.end()
|
||||||
|
expect(editingHoleInfo(scope())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-hole reshape never reads as an editing hole', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' })
|
||||||
|
expect(editingHoleInfo(scope())).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('switching interactions never leaks the prior derived view', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' })
|
||||||
|
// Single-owner replacement: the handle-drag view must vanish the instant a
|
||||||
|
// different interaction begins — the two cannot be simultaneously active.
|
||||||
|
s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 })
|
||||||
|
expect(handleDragInfo(scope())).toBeNull()
|
||||||
|
expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every active scope kind leaves at most the views it owns', () => {
|
||||||
|
const s = useInteractionScope.getState()
|
||||||
|
const kinds: ActiveInteractionScope[] = [
|
||||||
|
{ kind: 'placing', nodeId: 'i', nodeType: 'item', view: '3d', pressDrag: false },
|
||||||
|
{ kind: 'moving', nodeId: 'i', nodeType: 'item', view: '3d' },
|
||||||
|
{ kind: 'drafting', tool: 'wall' },
|
||||||
|
{ kind: 'box-select' },
|
||||||
|
{ kind: 'painting' },
|
||||||
|
]
|
||||||
|
for (const k of kinds) {
|
||||||
|
s.begin(k)
|
||||||
|
// None of these own a handle-drag or hole view.
|
||||||
|
expect(handleDragInfo(scope())).toBeNull()
|
||||||
|
expect(editingHoleInfo(scope())).toBeNull()
|
||||||
|
}
|
||||||
|
s.end()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import {
|
import {
|
||||||
type ActiveInteractionScope,
|
type ActiveInteractionScope,
|
||||||
|
editingHoleInfo,
|
||||||
|
handleDragInfo,
|
||||||
IDLE_SCOPE,
|
IDLE_SCOPE,
|
||||||
type InteractionScope,
|
type InteractionScope,
|
||||||
} from '../lib/interaction/scope'
|
} from '../lib/interaction/scope'
|
||||||
@@ -52,4 +55,18 @@ const useInteractionScope = create<InteractionScopeState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Derived, reference-stable views of the active scope, replacing the legacy
|
||||||
|
// `useEditor.activeHandleDrag` / `useEditor.editingHole` flags. `useShallow`
|
||||||
|
// keeps the result reference-stable across unrelated scope changes, so hot-path
|
||||||
|
// subscribers (camera controls, floating menu) don't re-render on every update.
|
||||||
|
export const useActiveHandleDrag = (): { nodeId: string; label: string } | null =>
|
||||||
|
useInteractionScope(useShallow((s) => handleDragInfo(s.scope)))
|
||||||
|
|
||||||
|
export const useEditingHole = (): { nodeId: string; holeIndex: number } | null =>
|
||||||
|
useInteractionScope(useShallow((s) => editingHoleInfo(s.scope)))
|
||||||
|
|
||||||
|
// Imperative (non-React) reads for event handlers / effects.
|
||||||
|
export const getEditingHole = (): { nodeId: string; holeIndex: number } | null =>
|
||||||
|
editingHoleInfo(useInteractionScope.getState().scope)
|
||||||
|
|
||||||
export default useInteractionScope
|
export default useInteractionScope
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
|||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
|
holeEditScope,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelWrapper,
|
PanelWrapper,
|
||||||
SliderControl,
|
SliderControl,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
|
useEditingHole,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
useInteractionScope,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
@@ -25,8 +28,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
export function CeilingPanel() {
|
export function CeilingPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const editingHole = useEditor((s) => s.editingHole)
|
const editingHole = useEditingHole()
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
@@ -48,20 +50,26 @@ export function CeilingPanel() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
}, [setSelection, setEditingHole])
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
}, [node, setEditingHole])
|
}, [node])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
}, [setEditingHole])
|
}, [])
|
||||||
|
|
||||||
const handleAddHole = useCallback(() => {
|
const handleAddHole = useCallback(() => {
|
||||||
if (!(node && selectedId)) return
|
if (!(node && selectedId)) return
|
||||||
@@ -91,15 +99,17 @@ export function CeilingPanel() {
|
|||||||
holes: [...currentHoles, newHole],
|
holes: [...currentHoles, newHole],
|
||||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||||
})
|
})
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
useInteractionScope
|
||||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
.getState()
|
||||||
|
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
|
||||||
|
}, [node, selectedId, handleUpdate])
|
||||||
|
|
||||||
const handleEditHole = useCallback(
|
const handleEditHole = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: index })
|
useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index }))
|
||||||
},
|
},
|
||||||
[selectedId, setEditingHole],
|
[selectedId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteHole = useCallback(
|
const handleDeleteHole = useCallback(
|
||||||
@@ -114,10 +124,12 @@ export function CeilingPanel() {
|
|||||||
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
||||||
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
||||||
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
@@ -213,7 +225,13 @@ export function CeilingPanel() {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
label="Done"
|
label="Done"
|
||||||
onClick={() => setEditingHole(null)}
|
onClick={() =>
|
||||||
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf(
|
||||||
|
(scope) => scope.kind === 'reshaping' && scope.reshape === 'hole',
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : isAutoHole ? (
|
) : isAutoHole ? (
|
||||||
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
|||||||
import {
|
import {
|
||||||
ActionButton,
|
ActionButton,
|
||||||
ActionGroup,
|
ActionGroup,
|
||||||
|
holeEditScope,
|
||||||
PanelSection,
|
PanelSection,
|
||||||
PanelWrapper,
|
PanelWrapper,
|
||||||
SliderControl,
|
SliderControl,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
|
useEditingHole,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
useInteractionScope,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
@@ -28,8 +31,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||||||
export function SlabPanel() {
|
export function SlabPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const editingHole = useEditor((s) => s.editingHole)
|
const editingHole = useEditingHole()
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
@@ -52,20 +54,26 @@ export function SlabPanel() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
}, [setSelection, setEditingHole])
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
}, [node, setEditingHole])
|
}, [node])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
}, [setEditingHole])
|
}, [])
|
||||||
|
|
||||||
const handleAddHole = useCallback(() => {
|
const handleAddHole = useCallback(() => {
|
||||||
if (!(node && selectedId)) return
|
if (!(node && selectedId)) return
|
||||||
@@ -95,15 +103,17 @@ export function SlabPanel() {
|
|||||||
holes: [...currentHoles, newHole],
|
holes: [...currentHoles, newHole],
|
||||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||||
})
|
})
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
useInteractionScope
|
||||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
.getState()
|
||||||
|
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
|
||||||
|
}, [node, selectedId, handleUpdate])
|
||||||
|
|
||||||
const handleEditHole = useCallback(
|
const handleEditHole = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
setEditingHole({ nodeId: selectedId, holeIndex: index })
|
useInteractionScope.getState().begin(holeEditScope({ nodeId: selectedId, holeIndex: index }))
|
||||||
},
|
},
|
||||||
[selectedId, setEditingHole],
|
[selectedId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteHole = useCallback(
|
const handleDeleteHole = useCallback(
|
||||||
@@ -118,10 +128,12 @@ export function SlabPanel() {
|
|||||||
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
||||||
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
||||||
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
||||||
setEditingHole(null)
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'hole')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
@@ -219,7 +231,13 @@ export function SlabPanel() {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="h-7 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
label="Done"
|
label="Done"
|
||||||
onClick={() => setEditingHole(null)}
|
onClick={() =>
|
||||||
|
useInteractionScope
|
||||||
|
.getState()
|
||||||
|
.endIf(
|
||||||
|
(scope) => scope.kind === 'reshaping' && scope.reshape === 'hole',
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : isAutoHole ? (
|
) : isAutoHole ? (
|
||||||
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user