diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts index bc3be802..a1108ee9 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts @@ -339,7 +339,7 @@ describe('observeSvgAnnotationLayoutChanges', () => { const originalRequestAnimationFrame = globalThis.requestAnimationFrame const originalCancelAnimationFrame = globalThis.cancelAnimationFrame let notify: MutationCallback | undefined - let animationFrame: FrameRequestCallback | undefined + let animationFrames: FrameRequestCallback[] = [] let disconnected = false let observedOptions: MutationObserverInit | undefined @@ -363,11 +363,16 @@ describe('observeSvgAnnotationLayoutChanges', () => { globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - animationFrame = callback - return 1 + animationFrames.push(callback) + return animationFrames.length }) as typeof requestAnimationFrame globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } let layoutPasses = 0 const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { layoutPasses += 1 @@ -376,7 +381,9 @@ describe('observeSvgAnnotationLayoutChanges', () => { notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) expect(layoutPasses).toBe(0) - animationFrame?.(0) + flushAnimationFrame() + expect(layoutPasses).toBe(0) + flushAnimationFrame() expect(layoutPasses).toBe(1) expect(observedOptions).toMatchObject({ attributes: true, @@ -405,4 +412,56 @@ describe('observeSvgAnnotationLayoutChanges', () => { globalThis.cancelAnimationFrame = originalCancelAnimationFrame } }) + + test('waits for a quiet frame instead of resolving on every mutation frame', () => { + const OriginalMutationObserver = globalThis.MutationObserver + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + let notify: MutationCallback | undefined + let animationFrames: FrameRequestCallback[] = [] + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(): void {} + disconnect(): void {} + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrames.push(callback) + return animationFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + for (let frame = 0; frame < 30; frame += 1) { + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + flushAnimationFrame() + } + + expect(layoutPasses).toBe(0) + flushAnimationFrame() + expect(layoutPasses).toBe(1) + stop() + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts index 934a806e..c6ab37ef 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -227,23 +227,30 @@ export function resolveSvgAnnotationCollisions( return preflightIssues } -export function observeSvgAnnotationLayoutChanges( - svg: SVGSVGElement, - onChange: () => void, -): () => void { +export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void { let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestFrame(flushWhenSettled) + return + } + scheduledFrame = null + onChange() + } const schedule = () => { + mutationVersion += 1 if (scheduledFrame !== null) return - const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) - scheduledFrame = requestFrame(() => { - scheduledFrame = null - onChange() - }) + observedVersion = mutationVersion - 1 + scheduledFrame = requestFrame(flushWhenSettled) } const observer = new MutationObserver((mutations) => { if (mutations.some(isAnnotationLayoutMutation)) schedule() }) - observer.observe(svg, { + observer.observe(target, { attributes: true, attributeFilter: [ 'cx', diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 3959bb0f..0bb25899 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -66,6 +66,7 @@ import { curveReshapeScope, endpointReshapeScope, holeEditScope, + isIdle, tangentReshapeScope, } from '../../../lib/interaction/scope' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -1456,18 +1457,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const markerRef = useRef(null) const [layoutEpoch, setLayoutEpoch] = useState(0) + const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) + const layoutEnabled = active && interactionIdle useLayoutEffect(() => { - if (!active) return - const svg = markerRef.current?.ownerSVGElement - if (!svg) return - return observeSvgAnnotationLayoutChanges(svg, () => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + return observeSvgAnnotationLayoutChanges(registryLayer, () => { setLayoutEpoch((epoch) => epoch + 1) }) - }, [active]) + }, [layoutEnabled]) useLayoutEffect(() => { // The epoch is only a trigger; collision inputs are measured from the live SVG below. void layoutEpoch @@ -1475,89 +1478,149 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { resetPreflightIssues() return } + if (!interactionIdle) return const svg = markerRef.current?.ownerSVGElement - if (!svg) return + const registryLayer = markerRef.current?.parentElement + if (!(svg && registryLayer)) return const preflightIssues = resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides, }) setPreflightIssues(preflightIssues) const labels = Array.from( - svg.querySelectorAll('[data-floorplan-annotation-label]'), + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), ) - const cleanup: Array<() => void> = [] for (const [index, label] of labels.entries()) { const id = svgAnnotationLabelId(label, index) label.dataset.floorplanAnnotationId = id label.style.pointerEvents = 'all' label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' - - const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0) return - event.preventDefault() - event.stopPropagation() - label.style.cursor = 'grabbing' - const matrix = label.getScreenCTM() - if (!matrix) return - const start = { x: event.clientX, y: event.clientY } - const existing = annotationLayoutOverrides[id] ?? { - ...readFloorplanAnnotationLayoutOffset(label), - pinned: true, - } - let latest = existing - let moved = false - const onPointerMove = (moveEvent: PointerEvent) => { - moved = true - const local = screenVectorToFloorplanAnnotationLocal( - matrix, - moveEvent.clientX - start.x, - moveEvent.clientY - start.y, - ) - latest = { - dx: existing.dx + local.x, - dy: existing.dy + local.y, - pinned: true, - } - const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' - label.setAttribute( - 'transform', - `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), - ) - } - const onPointerUp = () => { - label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' - window.removeEventListener('pointermove', onPointerMove) - window.removeEventListener('pointerup', onPointerUp) - if (moved) setAnnotationLayoutOverride(id, latest) - } - window.addEventListener('pointermove', onPointerMove) - window.addEventListener('pointerup', onPointerUp) - } - const onDoubleClick = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - setAnnotationLayoutOverride(id, null) - } - label.addEventListener('pointerdown', onPointerDown) - label.addEventListener('dblclick', onDoubleClick) - cleanup.push(() => { - label.removeEventListener('pointerdown', onPointerDown) - label.removeEventListener('dblclick', onDoubleClick) - label.style.pointerEvents = '' - label.style.cursor = '' - }) - } - return () => { - for (const fn of cleanup) fn() } }, [ active, annotationLayoutOverrides, + interactionIdle, layoutEpoch, resetPreflightIssues, - setAnnotationLayoutOverride, setPreflightIssues, ]) + + useEffect(() => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + let cleanupPointerDrag: (() => void) | null = null + let cancelActivePointerDrag: (() => void) | null = null + + const findLabel = (target: EventTarget | null): SVGGElement | null => { + if (!(target instanceof Element)) return null + const label = target.closest('[data-floorplan-annotation-label]') + return label && registryLayer.contains(label) ? label : null + } + + const labelId = (label: SVGGElement): string => { + const labels = Array.from( + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), + ) + return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label))) + } + + const onPointerDown = (event: PointerEvent) => { + const label = findLabel(event.target) + if (!(label && event.button === 0)) return + const matrix = label.getScreenCTM() + if (!matrix) return + event.preventDefault() + event.stopPropagation() + cancelActivePointerDrag?.() + label.style.cursor = 'grabbing' + const id = labelId(label) + const start = { x: event.clientX, y: event.clientY } + const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? { + ...readFloorplanAnnotationLayoutOffset(label), + pinned: true, + } + const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true + let latest = existing + let moved = false + + const onPointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== event.pointerId) return + moved = true + const local = screenVectorToFloorplanAnnotationLocal( + matrix, + moveEvent.clientX - start.x, + moveEvent.clientY - start.y, + ) + latest = { + dx: existing.dx + local.x, + dy: existing.dy + local.y, + pinned: true, + } + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), + ) + } + + const finishPointerDrag = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== event.pointerId) return + cleanupPointerDrag?.() + label.style.cursor = moved || wasPinned ? 'grab' : 'move' + if (moved) setAnnotationLayoutOverride(id, latest) + } + + const cancelPointerDrag = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== event.pointerId) return + cancelActivePointerDrag?.() + } + + cancelActivePointerDrag = () => { + cleanupPointerDrag?.() + label.style.cursor = wasPinned ? 'grab' : 'move' + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(), + ) + } + + cleanupPointerDrag = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', finishPointerDrag) + window.removeEventListener('pointercancel', cancelPointerDrag) + cleanupPointerDrag = null + cancelActivePointerDrag = null + } + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', finishPointerDrag) + window.addEventListener('pointercancel', cancelPointerDrag) + } + + const onDoubleClick = (event: MouseEvent) => { + const label = findLabel(event.target) + if (!label) return + event.preventDefault() + event.stopPropagation() + label.style.cursor = 'move' + setAnnotationLayoutOverride(labelId(label), null) + } + + registryLayer.addEventListener('pointerdown', onPointerDown) + registryLayer.addEventListener('dblclick', onDoubleClick) + return () => { + cancelActivePointerDrag?.() + registryLayer.removeEventListener('pointerdown', onPointerDown) + registryLayer.removeEventListener('dblclick', onDoubleClick) + for (const label of registryLayer.querySelectorAll( + '[data-floorplan-annotation-label]', + )) { + label.style.pointerEvents = '' + label.style.cursor = '' + } + } + }, [layoutEnabled, setAnnotationLayoutOverride]) return } diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts new file mode 100644 index 00000000..da60a433 --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' + +describe('camera dragging lifecycle', () => { + test('releases wheel interactions even when camera controls never report rest', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + expect(dragging).toEqual([true]) + + const release = scheduled as (() => void) | null + release?.() + expect(dragging).toEqual([true, false]) + }) + + test('cancels a pending wheel release when another interaction begins', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + lifecycle.begin() + + expect(scheduled).toBeNull() + expect(dragging).toEqual([true, true]) + }) +}) diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts new file mode 100644 index 00000000..1eb371fa --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts @@ -0,0 +1,41 @@ +type TimerHandle = ReturnType + +export function createCameraDraggingLifecycle({ + setDragging, + fallbackMs = 500, + schedule = globalThis.setTimeout, + cancel = globalThis.clearTimeout, +}: { + setDragging: (dragging: boolean) => void + fallbackMs?: number + schedule?: (callback: () => void, delay: number) => TimerHandle + cancel?: (timer: TimerHandle) => void +}) { + let releaseTimer: TimerHandle | null = null + + const clearScheduledEnd = () => { + if (releaseTimer === null) return + cancel(releaseTimer) + releaseTimer = null + } + + const begin = () => { + clearScheduledEnd() + setDragging(true) + } + + const end = () => { + clearScheduledEnd() + setDragging(false) + } + + const scheduleEnd = () => { + clearScheduledEnd() + releaseTimer = schedule(() => { + releaseTimer = null + setDragging(false) + }, fallbackMs) + } + + return { begin, end, scheduleEnd } +} diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 1772e199..d415fb97 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -35,6 +35,7 @@ import { useEndpointReshape, useMovingNode, } from '../../store/use-interaction-scope' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' const currentTarget = new Vector3() const tempBox = new Box3() @@ -166,6 +167,10 @@ function isKeyboardPanKey(code: string): boolean { return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' } +function hasKeyboardPanInput(state: KeyboardPanState): boolean { + return state.forward || state.backward || state.left || state.right +} + type CameraViewportSize = { width: number height: number @@ -420,6 +425,14 @@ export const CustomCameraControls = () => { const gl = useThree((state) => state.gl) const raycaster = useThree((state) => state.raycaster) const viewportSize = useThree((state) => state.size) + const cameraDraggingLifecycle = useMemo( + () => + createCameraDraggingLifecycle({ + setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging), + }), + [], + ) + useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle]) useEffect(() => { camera.layers.enable(EDITOR_LAYER) camera.layers.enable(GRID_LAYER) @@ -446,8 +459,9 @@ export const CustomCameraControls = () => { const beginLocalCameraInteraction = useCallback(() => { cancelPoseApplication() + cameraDraggingLifecycle.begin() emitter.emit('camera-controls:interaction-start', undefined) - }, [cancelPoseApplication]) + }, [cameraDraggingLifecycle, cancelPoseApplication]) const applyPendingPose = useCallback(() => { if (isFirstPersonMode) { @@ -1007,6 +1021,9 @@ export const CustomCameraControls = () => { if (isKeyboardPanKey(event.code)) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false) if (changed) { + if (!hasKeyboardPanInput(keyboardPanKeys.current)) { + cameraDraggingLifecycle.end() + } event.preventDefault() event.stopPropagation() } @@ -1048,6 +1065,7 @@ export const CustomCameraControls = () => { const onWheel = () => { beginLocalCameraInteraction() + cameraDraggingLifecycle.scheduleEnd() clearPendingFloorplanNavigationPose() } @@ -1067,6 +1085,7 @@ export const CustomCameraControls = () => { panPointerId = null panPointerButton = null clearNavigationCursor() + cameraDraggingLifecycle.end() updateConfig() } @@ -1089,9 +1108,11 @@ export const CustomCameraControls = () => { gl.domElement.removeEventListener('wheel', onWheel, true) clearKeyboardPanKeys() clearNavigationCursor() + cameraDraggingLifecycle.end() } }, [ beginLocalCameraInteraction, + cameraDraggingLifecycle, cameraMode, gl, isPreviewMode, @@ -1407,12 +1428,12 @@ export const CustomCameraControls = () => { }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { - useViewer.getState().setCameraDragging(true) - }, []) + cameraDraggingLifecycle.begin() + }, [cameraDraggingLifecycle]) const onRest = useCallback(() => { - useViewer.getState().setCameraDragging(false) - }, []) + cameraDraggingLifecycle.end() + }, [cameraDraggingLifecycle]) // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 2m minDistance prevents the user from getting close diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts new file mode 100644 index 00000000..b114d9ea --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' + +describe('floorplan navigation presentation', () => { + test('keeps the imperative viewBox authoritative during navigation', () => { + const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 } + const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 } + + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, true)).toBe( + imperativeViewBox, + ) + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, false)).toBe( + reactViewBox, + ) + }) + + test('does not mix wheel zoom with a compositor rotation preview', () => { + expect(canZoomFloorplanDuringNavigation(true)).toBe(false) + expect(canZoomFloorplanDuringNavigation(false)).toBe(true) + }) + + test('does not apply synchronized camera poses over local navigation', () => { + expect(canApplyFloorplanNavigationSync(true)).toBe(false) + expect(canApplyFloorplanNavigationSync(false)).toBe(true) + }) + + test('commits every active navigation channel before teardown', () => { + const calls: string[] = [] + const rotationState = { angle: 42 } + + finalizeFloorplanNavigation({ + zoomPending: true, + panActive: true, + rotationState, + commitZoom: () => calls.push('zoom'), + commitPan: () => calls.push('pan'), + commitRotation: (state) => calls.push(`rotation:${state.angle}`), + }) + + expect(calls).toEqual(['zoom', 'pan', 'rotation:42']) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts new file mode 100644 index 00000000..30295809 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -0,0 +1,42 @@ +export type FloorplanPresentationViewBox = { + minX: number + minY: number + width: number + height: number +} + +export function resolveFloorplanPresentationViewBox( + reactViewBox: FloorplanPresentationViewBox, + imperativeViewBox: FloorplanPresentationViewBox | null, + interactionInProgress: boolean, +): FloorplanPresentationViewBox { + return interactionInProgress && imperativeViewBox ? imperativeViewBox : reactViewBox +} + +export function canZoomFloorplanDuringNavigation(rotationInProgress: boolean): boolean { + return !rotationInProgress +} + +export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): boolean { + return !interactionInProgress +} + +export function finalizeFloorplanNavigation({ + zoomPending, + panActive, + rotationState, + commitZoom, + commitPan, + commitRotation, +}: { + zoomPending: boolean + panActive: boolean + rotationState: RotationState | null + commitZoom: () => void + commitPan: () => void + commitRotation: (rotationState: RotationState) => void +}): void { + if (zoomPending) commitZoom() + if (panActive) commitPan() + if (rotationState) commitRotation(rotationState) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d26ce3ca..fae830cc 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -191,6 +191,13 @@ import { import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + type FloorplanPresentationViewBox, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanSceneData } from './use-floorplan-scene-data' @@ -320,6 +327,20 @@ type FloorplanRotationState = { startClientX: number initialUserRotationDeg: number viewportCenterLocal: SvgPoint + svg: SVGSVGElement + svgStyle: { + transform: string + transformOrigin: string + willChange: string + } + latestUserRotationDeg: number + latestViewport: FloorplanViewport +} + +function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) { + rotationState.svg.style.transform = rotationState.svgStyle.transform + rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin + rotationState.svg.style.willChange = rotationState.svgStyle.willChange } type FloorplanScreenSelectionState = { @@ -5287,6 +5308,7 @@ export function FloorplanPanel({ }) { const viewportHostRef = useRef(null) const svgRef = useRef(null) + const floorplanBackgroundRef = useRef(null) const floorplanSceneRef = useRef(null) const floorplanContentRef = useRef(null) const panStateRef = useRef(null) @@ -5314,6 +5336,21 @@ export function FloorplanPanel({ const latestFittedViewportRef = useRef(null) const floorplanViewAnimationFrameRef = useRef(null) const floorplanViewAnimationTargetRef = useRef(null) + const floorplanZoomCommitTimerRef = useRef(null) + const floorplanRenderScaleCommitTimerRef = useRef(null) + const floorplanViewportInteractionInProgressRef = useRef(false) + const floorplanImperativeViewBoxRef = useRef(null) + const latestFloorplanRenderUnitsPerPixelRef = useRef(1) + const floorplanZoomPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) + const floorplanPanPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) const latestNavigationSyncPoseRef = useRef( useEditor.getState().navigationSyncPose, ) @@ -5421,7 +5458,7 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg // Only sync ref from state when floorplan is open (state is source of truth). // When hidden, the imperative 3D path owns the ref and must not be clobbered. - if (isFloorplanOpenRef.current) { + if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) { latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg } @@ -5630,9 +5667,14 @@ export function FloorplanPanel({ const [isPanelReady, setIsPanelReady] = useState(false) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [viewport, setViewport] = useState(null) - latestViewportRef.current = viewport + const [floorplanRenderUnitsPerPixel, setFloorplanRenderUnitsPerPixel] = useState( + null, + ) + if (!floorplanViewportInteractionInProgressRef.current) { + latestViewportRef.current = viewport + } // Tight bbox of the painted floor-plan scene (the rotation ``'s - // children), read via SVG `getBBox()` after each render. The legacy + // children), read via SVG `getBBox()` after content changes settle. The legacy // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now // empty stubs because rendering moved to the registry layer, so // measuring the DOM is how `fittedViewport` learns where content lives. @@ -6610,42 +6652,71 @@ export function FloorplanPanel({ ]) latestFittedViewportRef.current = fittedViewport - // Measure the painted floor-plan scene after each render. `getBBox()` - // gives us the tight bounds of whatever the registry layer emitted, - // even for kinds whose legacy entry arrays are empty stubs. Bail out - // when nothing has painted (empty group throws in some browsers). - // We measure the content-only sub-group (not the full scene group) to - // exclude the grid layer, whose extent tracks the viewBox and would - // otherwise create a measure→fit→measure update loop. + // Measure the content-only subtree after its geometry settles. ViewBox-only + // navigation does not change these bounds and must not force `getBBox()` on + // every animation frame. + // biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree. useLayoutEffect(() => { const el = floorplanContentRef.current if (!el) return - let bbox: { x: number; y: number; width: number; height: number } - try { - const measured = el.getBBox() - bbox = { - x: measured.x, - y: measured.y, - width: measured.width, - height: measured.height, + let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const measure = () => { + let bbox: { x: number; y: number; width: number; height: number } + try { + const measured = el.getBBox() + bbox = { + x: measured.x, + y: measured.y, + width: measured.width, + height: measured.height, + } + } catch { + return } - } catch { - return + if (bbox.width <= 0 && bbox.height <= 0) return + setMeasuredSceneBBox((prev) => { + if ( + prev && + prev.x === bbox.x && + prev.y === bbox.y && + prev.width === bbox.width && + prev.height === bbox.height + ) { + return prev + } + return bbox + }) } - if (bbox.width <= 0 && bbox.height <= 0) return - setMeasuredSceneBBox((prev) => { - if ( - prev && - prev.x === bbox.x && - prev.y === bbox.y && - prev.width === bbox.width && - prev.height === bbox.height - ) { - return prev + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestAnimationFrame(flushWhenSettled) + return } - return bbox + scheduledFrame = null + measure() + } + const observer = new MutationObserver(() => { + mutationVersion += 1 + if (scheduledFrame !== null) return + observedVersion = mutationVersion - 1 + scheduledFrame = requestAnimationFrame(flushWhenSettled) }) - }) + + measure() + observer.observe(el, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame !== null) cancelAnimationFrame(scheduledFrame) + } + }, [isFloorplanOpen]) const applyFloorplanNavigationState = useCallback( (nextViewport: FloorplanViewport, userRotationDeg: number) => { @@ -6798,7 +6869,7 @@ export function FloorplanPanel({ if (!isFloorplanOpenRef.current) { return } - if (floorplanRotationStateRef.current) { + if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) { return } @@ -6977,35 +7048,6 @@ export function FloorplanPanel({ } }, []) - // Reset to auto-fit each time the 2D editor re-opens. The panel stays - // mounted across close/open (hidden via `display: none`), so without - // this the user's last pan/zoom — and any stale `measuredSceneBBox` - // captured before they closed it — would survive and the reopened - // editor would show the same off-screen viewport instead of fitting - // to the current scene. - useEffect(() => { - if (!isFloorplanOpen) { - stopFloorplanViewAnimation() - floorplanSpacePanPressedRef.current = false - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsSpacePanPressed(false) - setIsPanning(false) - setIsRotatingFloorplan(false) - return - } - setMeasuredSceneBBox(null) - - if (!latestNavigationSyncPoseRef.current) { - stopFloorplanViewAnimation() - hasUserAdjustedViewportRef.current = false - latestFloorplanUserRotationDegRef.current = 0 - latestViewportRef.current = null - setFloorplanUserRotationDeg(0) - setViewport(null) - } - }, [isFloorplanOpen, stopFloorplanViewAnimation]) - useEffect(() => { const levelChanged = previousLevelIdRef.current !== (levelId ?? null) @@ -7064,19 +7106,31 @@ export function FloorplanPanel({ height, } }, [fittedViewport, svgAspectRatio, viewport]) + const presentationViewBox = resolveFloorplanPresentationViewBox( + viewBox, + floorplanImperativeViewBoxRef.current, + floorplanViewportInteractionInProgressRef.current, + ) const floorplanWorldUnitsPerPixel = useMemo(() => { const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1) return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width]) - const floorplanWallHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getLiveFloorplanWorldUnitsPerPixel = useCallback(() => { + const width = latestViewportRef.current?.width ?? viewBox.width + const height = width / svgAspectRatio + const widthUnitsPerPixel = width / Math.max(surfaceSize.width, 1) + const heightUnitsPerPixel = height / Math.max(surfaceSize.height, 1) + return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 + }, [surfaceSize.height, surfaceSize.width, svgAspectRatio, viewBox.width]) + const getFloorplanWallHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) - const floorplanOpeningHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getFloorplanOpeningHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) const wallSelectionHatchSpacing = useMemo( () => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001), @@ -7361,7 +7415,9 @@ export function FloorplanPanel({ ), [gridBounds, gridSteps.majorStep], ) - const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const liveFloorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const floorplanUnitsPerPixel = floorplanRenderUnitsPerPixel ?? liveFloorplanUnitsPerPixel + latestFloorplanRenderUnitsPerPixelRef.current = floorplanUnitsPerPixel useEffect(() => { setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') @@ -7889,8 +7945,104 @@ export function FloorplanPanel({ [beginPanelInteraction, panelRect], ) + const applyFloorplanViewportImperatively = useCallback( + (nextViewport: FloorplanViewport) => { + const nextHeight = nextViewport.width / svgAspectRatio + const nextMinX = nextViewport.centerX - nextViewport.width / 2 + const nextMinY = nextViewport.centerY - nextHeight / 2 + floorplanImperativeViewBoxRef.current = { + minX: nextMinX, + minY: nextMinY, + width: nextViewport.width, + height: nextHeight, + } + hasUserAdjustedViewportRef.current = true + latestViewportRef.current = nextViewport + svgRef.current?.setAttribute( + 'viewBox', + `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`, + ) + const background = floorplanBackgroundRef.current + if (background) { + background.setAttribute('x', String(nextMinX)) + background.setAttribute('y', String(nextMinY)) + background.setAttribute('width', String(nextViewport.width)) + background.setAttribute('height', String(nextHeight)) + } + }, + [svgAspectRatio], + ) + + const applyFloorplanRotationImperatively = useCallback( + (rotationState: FloorplanRotationState, nextUserRotationDeg: number) => { + const currentViewport = latestViewportRef.current ?? rotationState.latestViewport + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg + const nextCenterSvg = rotateSvgPoint(rotationState.viewportCenterLocal, nextSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = nextUserRotationDeg + latestViewportRef.current = nextViewport + // Transform the already-painted SVG as one compositor layer. Mutating the + // scene rotation/viewBox here forces the heavy vector plan to rerasterize. + rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)` + + rotationState.latestUserRotationDeg = nextUserRotationDeg + rotationState.latestViewport = nextViewport + }, + [buildingRotationDeg], + ) + + const commitFloorplanZoom = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + floorplanZoomCommitTimerRef.current = null + } + const nextViewport = latestViewportRef.current + const pendingPose = floorplanZoomPoseRef.current + floorplanZoomPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (!nextViewport) return + setFloorplanRenderUnitsPerPixel( + (current) => current ?? latestFloorplanRenderUnitsPerPixelRef.current, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanRenderScaleCommitTimerRef.current = window.setTimeout(() => { + floorplanRenderScaleCommitTimerRef.current = null + setFloorplanRenderUnitsPerPixel(null) + }, 350) + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const scheduleFloorplanZoomCommit = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + floorplanZoomCommitTimerRef.current = window.setTimeout(commitFloorplanZoom, 300) + }, [commitFloorplanZoom]) + const zoomViewportAtClientPoint = useCallback( (clientX: number, clientY: number, widthFactor: number) => { + if (!canZoomFloorplanDuringNavigation(floorplanRotationStateRef.current !== null)) { + return + } if (!Number.isFinite(widthFactor) || widthFactor <= 0) { return } @@ -7906,12 +8058,21 @@ export function FloorplanPanel({ } const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg) - const currentViewport = viewport ?? fittedViewport - const currentViewBox = viewBox + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) { + return + } + const currentViewBox = { + minX: currentViewport.centerX - currentViewport.width / 2, + minY: currentViewport.centerY - currentViewport.width / svgAspectRatio / 2, + width: currentViewport.width, + height: currentViewport.width / svgAspectRatio, + } + const fitted = latestFittedViewportRef.current const nextWidth = resolveFloorplanViewWidth( currentViewport.width * widthFactor, currentViewport.width, - fittedViewport, + fitted, true, ) const nextHeight = nextWidth / svgAspectRatio @@ -7925,30 +8086,140 @@ export function FloorplanPanel({ y: nextMinY + nextHeight / 2, } const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: nextWidth, + } - smoothFloorplanNavigationView( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) - publishFloorplanNavigationPose( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) + stopFloorplanViewAnimation() + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + floorplanRenderScaleCommitTimerRef.current = null + } + floorplanViewportInteractionInProgressRef.current = true + applyFloorplanViewportImperatively(nextViewport) + scheduleFloorplanZoomCommit() + const userRotationDeg = latestFloorplanUserRotationDegRef.current + floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth) + } }, [ - fittedViewport, + applyFloorplanViewportImperatively, floorplanSceneRotationDeg, getSvgPointFromClientPoint, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, + scheduleFloorplanZoomCommit, + stopFloorplanViewAnimation, svgAspectRatio, - viewBox, - viewport, ], ) + useEffect( + () => () => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + }, + [], + ) + + const commitFloorplanPan = useCallback(() => { + const nextViewport = latestViewportRef.current + const pendingPose = floorplanPanPoseRef.current + floorplanPanPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (nextViewport) { + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + } + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const commitFloorplanRotation = useCallback( + (rotationState: FloorplanRotationState) => { + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + restoreFloorplanRotationPresentation(rotationState) + setFloorplanUserRotationDeg((current) => + current === rotationState.latestUserRotationDeg + ? current + : rotationState.latestUserRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, rotationState.latestViewport) + ? current + : rotationState.latestViewport, + ) + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + rotationState.latestUserRotationDeg, + rotationState.latestViewport.width, + ) + }, + [publishFloorplanNavigationPose], + ) + + // Finalize imperative navigation when the floorplan closes so reopening + // restores the last visible pose instead of stale React state. + useEffect(() => { + if (isFloorplanOpen) return + stopFloorplanViewAnimation() + const rotationState = floorplanRotationStateRef.current + finalizeFloorplanNavigation({ + zoomPending: + floorplanZoomCommitTimerRef.current !== null || floorplanZoomPoseRef.current !== null, + panActive: panStateRef.current !== null, + rotationState, + commitZoom: commitFloorplanZoom, + commitPan: commitFloorplanPan, + commitRotation: commitFloorplanRotation, + }) + floorplanSpacePanPressedRef.current = false + panStateRef.current = null + floorplanRotationStateRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + setIsSpacePanPressed(false) + setIsPanning(false) + setIsRotatingFloorplan(false) + }, [ + commitFloorplanPan, + commitFloorplanRotation, + commitFloorplanZoom, + isFloorplanOpen, + stopFloorplanViewAnimation, + ]) + + useEffect(() => { + if (!isFloorplanOpen) return + setMeasuredSceneBBox(null) + + if (!latestNavigationSyncPoseRef.current) { + stopFloorplanViewAnimation() + hasUserAdjustedViewportRef.current = false + latestFloorplanUserRotationDegRef.current = 0 + latestViewportRef.current = null + setFloorplanUserRotationDeg(0) + setViewport(null) + } + }, [isFloorplanOpen, stopFloorplanViewAnimation]) + const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) @@ -8906,8 +9177,12 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() floorplanNavigationClickSuppressedRef.current = true - const currentViewport = viewport ?? fittedViewport + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + floorplanViewportInteractionInProgressRef.current = true panStateRef.current = { pointerId: event.pointerId, clientX: event.clientX, @@ -8932,17 +9207,35 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() - const currentViewport = viewport ?? fittedViewport + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const svg = svgRef.current + if (!(currentViewport && svg)) return + const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const viewportCenterLocal = rotateSvgPoint( { x: currentViewport.centerX, y: currentViewport.centerY }, - -floorplanSceneRotationDeg, + -currentSceneRotationDeg, ) - + const svgStyle = { + transform: svg.style.transform, + transformOrigin: svg.style.transformOrigin, + willChange: svg.style.willChange, + } + floorplanViewportInteractionInProgressRef.current = true + svg.style.transformOrigin = 'center' + svg.style.willChange = 'transform' floorplanRotationStateRef.current = { pointerId: event.pointerId, startClientX: event.clientX, - initialUserRotationDeg: floorplanUserRotationDeg, + initialUserRotationDeg: currentUserRotationDeg, viewportCenterLocal, + svg, + svgStyle, + latestUserRotationDeg: currentUserRotationDeg, + latestViewport: currentViewport, } setIsRotatingFloorplan(true) setCursorPoint(null) @@ -8951,12 +9244,11 @@ export function FloorplanPanel({ event.currentTarget.setPointerCapture(event.pointerId) }, [ - fittedViewport, - floorplanSceneRotationDeg, - floorplanUserRotationDeg, - viewport, + commitFloorplanZoom, + buildingRotationDeg, setFloorplanCursorPosition, setCursorPoint, + stopFloorplanViewAnimation, ], ) @@ -8996,24 +9288,31 @@ export function FloorplanPanel({ [isScreenSelectionToolActive, setPreviewSelectedIds], ) - const endFloorplanNavigation = useCallback((event?: ReactPointerEvent) => { - if ( - event && - (panStateRef.current || floorplanRotationStateRef.current) && - event.currentTarget.hasPointerCapture(event.pointerId) - ) { - event.currentTarget.releasePointerCapture(event.pointerId) - } + const endFloorplanNavigation = useCallback( + (event?: ReactPointerEvent) => { + const wasPanning = panStateRef.current !== null + const rotationState = floorplanRotationStateRef.current + if ( + event && + (panStateRef.current || floorplanRotationStateRef.current) && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { + event.currentTarget.releasePointerCapture(event.pointerId) + } - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsPanning(false) - setIsRotatingFloorplan(false) + panStateRef.current = null + floorplanRotationStateRef.current = null + if (wasPanning) commitFloorplanPan() + if (rotationState) commitFloorplanRotation(rotationState) + setIsPanning(false) + setIsRotatingFloorplan(false) - window.setTimeout(() => { - floorplanNavigationClickSuppressedRef.current = false - }, 0) - }, []) + window.setTimeout(() => { + floorplanNavigationClickSuppressedRef.current = false + }, 0) + }, + [commitFloorplanPan, commitFloorplanRotation], + ) const hoveredWallIdRef = useRef(null) const hoveredCeilingIdRef = useRef(null) @@ -9213,9 +9512,14 @@ export function FloorplanPanel({ (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg - smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) - publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) - setCursorPoint(null) + applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg) + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + nextUserRotationDeg, + rotationState.latestViewport.width, + ) + } return } @@ -9225,8 +9529,11 @@ export function FloorplanPanel({ const deltaX = event.clientX - panStateRef.current.clientX const deltaY = event.clientY - panStateRef.current.clientY - const worldPerPixelX = viewBox.width / surfaceSize.width - const worldPerPixelY = viewBox.height / surfaceSize.height + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + const currentHeight = currentViewport.width / svgAspectRatio + const worldPerPixelX = currentViewport.width / surfaceSize.width + const worldPerPixelY = currentHeight / surfaceSize.height const nextCenterSvg = { x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX, @@ -9237,8 +9544,20 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) - smoothFloorplanNavigationView(localCenter, currentUserRotationDeg) - publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + applyFloorplanViewportImperatively(nextViewport) + floorplanPanPoseRef.current = { + localCenter, + userRotationDeg: currentUserRotationDeg, + viewWidth: currentViewport.width, + } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width) + } panStateRef.current = { pointerId: event.pointerId, @@ -9578,6 +9897,8 @@ export function FloorplanPanel({ }, [ buildingRotationDeg, + applyFloorplanViewportImperatively, + applyFloorplanRotationImperatively, draftStart, ceilingDraftPoints, emitFloorplanWallLeave, @@ -9606,15 +9927,13 @@ export function FloorplanPanel({ isWallBuildActive, levelId, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, referenceScaleDraft, roofDraftStart, elevatorResizeDragState, siteVertexDragState, surfaceSize.height, surfaceSize.width, - viewBox.height, - viewBox.width, + svgAspectRatio, walls, setCursorPoint, setDraftEnd, @@ -9879,10 +10198,10 @@ export function FloorplanPanel({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -11030,6 +11349,7 @@ export function FloorplanPanel({ const handleGestureEnd = (event: Event) => { gestureScaleRef.current = 1 + commitFloorplanZoom() event.preventDefault() event.stopPropagation() } @@ -11049,7 +11369,7 @@ export function FloorplanPanel({ svg.removeEventListener('gesturechange', handleGestureChange) svg.removeEventListener('gestureend', handleGestureEnd) } - }, [zoomViewportAtClientPoint]) + }, [commitFloorplanZoom, zoomViewportAtClientPoint]) const restoreGroundLevelStructureSelection = useCallback(() => { const sceneNodes = useScene.getState().nodes @@ -11167,7 +11487,7 @@ export function FloorplanPanel({ ref={containerRef} > -
+
{ event.preventDefault() event.stopPropagation() @@ -11477,9 +11798,9 @@ export function FloorplanPanel({ onPointerMove={handleMarqueePointerMove} onPointerUp={handleMarqueePointerUp} style={{ cursor: EDITOR_CURSOR }} - width={viewBox.width} - x={viewBox.minX} - y={viewBox.minY} + width={presentationViewBox.width} + x={presentationViewBox.minX} + y={presentationViewBox.minY} /> )} @@ -11692,12 +12013,12 @@ export function FloorplanPanel({ {isFloorplanNavigationOverlayVisible && ( )} diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 6894e7f4..4e71512f 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -89,10 +89,10 @@ type UseFloorplanHitTestingArgs = { displayWallPolygons: WallPolygonEntry[] floorplanElevatorEntries: ElevatorPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] - floorplanOpeningHitTolerance: number + getFloorplanOpeningHitTolerance: () => number floorplanRoofEntries: FloorplanRoofEntry[] floorplanStairEntries: FloorplanStairEntry[] - floorplanWallHitTolerance: number + getFloorplanWallHitTolerance: () => number getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null isFloorplanItemContextActive: boolean openingsPolygons: OpeningPolygonEntry[] @@ -107,10 +107,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, + getFloorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -132,8 +132,8 @@ export function useFloorplanHitTesting({ elevators: floorplanElevatorEntries, walls: displayWallPolygons, slabs: displaySlabPolygons, - openingHitTolerance: floorplanOpeningHitTolerance, - wallHitTolerance: floorplanWallHitTolerance, + openingHitTolerance: getFloorplanOpeningHitTolerance(), + wallHitTolerance: getFloorplanWallHitTolerance(), columns: columnPolygons, getOpeningCenterLine, }) @@ -145,10 +145,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanItemEntries, floorplanElevatorEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, diff --git a/packages/editor/src/store/use-floorplan-preflight.test.ts b/packages/editor/src/store/use-floorplan-preflight.test.ts new file mode 100644 index 00000000..8dffa10d --- /dev/null +++ b/packages/editor/src/store/use-floorplan-preflight.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import useFloorplanPreflight, { type FloorplanPreflightIssue } from './use-floorplan-preflight' + +const COLLISION_ISSUE: FloorplanPreflightIssue = { + id: 'dimension-1', + kind: 'unresolved-collision', + severity: 'warning', + message: 'The same collision remains unresolved.', +} + +afterEach(() => { + useFloorplanPreflight.getState().setIssues([]) + useFloorplanPreflight.getState().setAuditIssues([]) +}) + +describe('useFloorplanPreflight', () => { + test('does not notify subscribers when layout issues are unchanged', () => { + const state = useFloorplanPreflight.getState() + state.setIssues([COLLISION_ISSUE]) + let notifications = 0 + const unsubscribe = useFloorplanPreflight.subscribe(() => { + notifications += 1 + }) + + useFloorplanPreflight.getState().setIssues([{ ...COLLISION_ISSUE }]) + + unsubscribe() + expect(notifications).toBe(0) + }) + + test('still publishes changed layout issues alongside audit issues', () => { + const state = useFloorplanPreflight.getState() + state.setAuditIssues([ + { + id: 'audit-1', + kind: 'dimension-completeness', + severity: 'info', + message: 'Audit issue', + }, + ]) + + useFloorplanPreflight.getState().setIssues([COLLISION_ISSUE]) + + expect(useFloorplanPreflight.getState().issues).toEqual([ + COLLISION_ISSUE, + expect.objectContaining({ id: 'audit-1' }), + ]) + }) +}) diff --git a/packages/editor/src/store/use-floorplan-preflight.ts b/packages/editor/src/store/use-floorplan-preflight.ts index e4f10b2e..d00c7bd7 100644 --- a/packages/editor/src/store/use-floorplan-preflight.ts +++ b/packages/editor/src/store/use-floorplan-preflight.ts @@ -18,6 +18,23 @@ export type FloorplanPreflightIssue = { message: string } +function preflightIssuesEqual( + left: readonly FloorplanPreflightIssue[], + right: readonly FloorplanPreflightIssue[], +): boolean { + if (left.length !== right.length) return false + return left.every((issue, index) => { + const candidate = right[index] + return ( + candidate !== undefined && + issue.id === candidate.id && + issue.kind === candidate.kind && + issue.severity === candidate.severity && + issue.message === candidate.message + ) + }) +} + type FloorplanPreflightState = { issues: FloorplanPreflightIssue[] layoutIssues: FloorplanPreflightIssue[] @@ -38,9 +55,17 @@ export const useFloorplanPreflight = create((set) => ({ clearanceChecksEnabled: false, moduleChecksEnabled: false, setIssues: (issues) => - set((state) => ({ layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] })), + set((state) => + preflightIssuesEqual(state.layoutIssues, issues) + ? state + : { layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] }, + ), setAuditIssues: (issues) => - set((state) => ({ auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] })), + set((state) => + preflightIssuesEqual(state.auditIssues, issues) + ? state + : { auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] }, + ), setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }), setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }), reset: () => diff --git a/packages/nodes/src/construction-dimension/panel.tsx b/packages/nodes/src/construction-dimension/panel.tsx index c1018fb2..3a1ef55a 100644 --- a/packages/nodes/src/construction-dimension/panel.tsx +++ b/packages/nodes/src/construction-dimension/panel.tsx @@ -1,6 +1,7 @@ 'use client' import { + type AnyNode, type AnyNodeId, type ConstructionDimensionDatumPolicy, type ConstructionDimensionDrawingPresentation, @@ -86,16 +87,6 @@ export default function ConstructionDimensionPanel() { const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined return node?.type === 'construction-dimension' ? node : null }) - const foundationControllers = useScene( - useShallow((state) => - Object.values(state.nodes).filter( - (candidate): candidate is ConstructionDimensionNode => - candidate.type === 'construction-dimension' && - candidate.id !== dimension?.id && - candidate.drawingType === 'foundation-plan', - ), - ), - ) const updateNode = useScene((state) => state.updateNode) const deleteNode = useScene((state) => state.deleteNode) const activeDrawingType = useDrawingView((state) => state.drawingType) @@ -127,10 +118,14 @@ export default function ConstructionDimensionPanel() { drawingType, presentation, ) + const firstFoundationController = + presentation === 'controlled' && !dimension.controllingDimensionId + ? selectFoundationControllers(useScene.getState().nodes, dimension.id)[0] + : undefined update({ drawingOverrides, ...(presentation === 'controlled' && !dimension.controllingDimensionId - ? { controllingDimensionId: foundationControllers[0]?.id ?? null } + ? { controllingDimensionId: firstFoundationController?.id ?? null } : {}), }) } @@ -207,21 +202,13 @@ export default function ConstructionDimensionPanel() { value={activePresentation} /> {activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? ( - update({ - controllingDimensionId: controllingDimensionId as NonNullable< - ConstructionDimensionNode['controllingDimensionId'] - >, + controllingDimensionId, }) } - options={foundationControllers.map((controller) => ({ - label: controller.name || 'Foundation dimension', - value: controller.id, - }))} - placeholder="No foundation dimensions" value={dimension.controllingDimensionId ?? ''} /> ) : null} @@ -339,6 +326,51 @@ export default function ConstructionDimensionPanel() { ) } +function selectFoundationControllers( + nodes: Record, + excludedId: AnyNodeId, +): ConstructionDimensionNode[] { + return Object.values(nodes).filter( + (candidate): candidate is ConstructionDimensionNode => + candidate.type === 'construction-dimension' && + candidate.id !== excludedId && + candidate.drawingType === 'foundation-plan', + ) +} + +function FoundationControllerField({ + dimensionId, + value, + onChange, +}: { + dimensionId: AnyNodeId + value: string + onChange: (value: NonNullable) => void +}) { + const foundationControllers = useScene( + useShallow((state) => selectFoundationControllers(state.nodes, dimensionId)), + ) + return ( + + onChange( + controllingDimensionId as NonNullable< + ConstructionDimensionNode['controllingDimensionId'] + >, + ) + } + options={foundationControllers.map((controller) => ({ + label: controller.name || 'Foundation dimension', + value: controller.id, + }))} + placeholder="No foundation dimensions" + value={value} + /> + ) +} + function parseSuppressedSegments(value: string): number[] { return [ ...new Set( diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e83eaf..74f2f509 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -526,6 +526,7 @@ const PostProcessingPasses = ({ let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { + enabled: () => !useViewer.getState().cameraDragging, primaryObjects: outliner.selectedObjects, secondaryObjects: outliner.hoveredObjects, primaryEdgeThickness: uniform(1), diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts new file mode 100644 index 00000000..34a81b5b --- /dev/null +++ b/packages/viewer/src/lib/merged-outline-node.test.ts @@ -0,0 +1,22 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { Object3D, PerspectiveCamera, Scene } from 'three' +import { mergedOutline } from './merged-outline-node' + +describe('merged outline rendering', () => { + test('skips outline work while the pass is disabled', () => { + const outline = mergedOutline(new Scene(), new PerspectiveCamera(), { + enabled: () => false, + primaryObjects: [new Object3D()], + }) + const frame = { + get renderer(): never { + throw new Error('outline renderer should not be touched') + }, + } + + expect(() => outline.updateBefore(frame)).not.toThrow() + outline.dispose() + }) +}) diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index 4c621ffb..e7dbdcd8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -125,6 +125,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlowNode: any secondaryEdgeGlowNode: any downSampleRatio: number + enabled: () => boolean updateBeforeType: string private readonly _depthRT: RenderTarget @@ -189,6 +190,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow?: any secondaryEdgeGlow?: any downSampleRatio?: number + enabled?: () => boolean } = {}, ) { super('vec4') @@ -201,6 +203,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow = float(0), secondaryEdgeGlow = float(0), downSampleRatio = 2, + enabled = () => true, } = params this.scene = scene @@ -212,6 +215,7 @@ export class MergedOutlineNode extends TempNode { this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) this.downSampleRatio = downSampleRatio + this.enabled = enabled this.updateBeforeType = NodeUpdateType.FRAME this._depthRT = new RenderTarget() @@ -301,8 +305,9 @@ export class MergedOutlineNode extends TempNode { } updateBefore(frame: any) { - const hasPrimary = this.primaryObjects.length > 0 - const hasSecondary = this.secondaryObjects.length > 0 + const enabled = this.enabled() + const hasPrimary = enabled && this.primaryObjects.length > 0 + const hasSecondary = enabled && this.secondaryObjects.length > 0 const hasAny = hasPrimary || hasSecondary // Fast-path: nothing to render and nothing was rendered last frame either,