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:
Wassim SAMAD
2026-06-23 11:30:16 -04:00
co-authored by Claude Opus 4.8
parent f773e6b8c5
commit 2f2c3ef8f9
21 changed files with 273 additions and 119 deletions
@@ -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
} }
@@ -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
const activeHandleDrag =
scope.kind === 'handle-drag' ? { nodeId: scope.nodeId, label: scope.handle } : null
if (
activeHandleDrag?.nodeId === siteId &&
activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
) { ) {
editor.setActiveHandleDrag(null) 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()
+4 -1
View File
@@ -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') {
+7
View File
@@ -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,
}
}
-1
View File
@@ -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,
}) })
-41
View File
@@ -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
+33 -15
View File
@@ -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">
+33 -15
View File
@@ -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">