From fcac55ca30869bb5cffd7f042c29b068928af759 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Thu, 23 Jul 2026 17:23:01 +0530 Subject: [PATCH] editor: improve floorplan navigation performance (#535) * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * fix(editor): defer floorplan annotation collision layout * fix(viewer): suspend outlines during camera movement * fix(editor): keep floorplan zoom off the render hot path * fix(editor): keep floorplan pan off render hot path * fix(editor): keep floorplan rotation off render hot path * fix(editor): keep rotation backdrop white * fix(editor): finalize floorplan navigation safely * fix(editor): keep navigation interaction state current * fix(editor): stop floorplan animation before pan --------- Co-authored-by: Claude Opus 4.6 --- .../floorplan-annotation-layout.test.ts | 67 +- .../renderers/floorplan-annotation-layout.ts | 27 +- .../renderers/floorplan-registry-layer.tsx | 197 ++++-- .../editor/camera-dragging-lifecycle.test.ts | 49 ++ .../editor/camera-dragging-lifecycle.ts | 41 ++ .../editor/custom-camera-controls.tsx | 31 +- .../floorplan-navigation-presentation.test.ts | 47 ++ .../floorplan-navigation-presentation.ts | 42 ++ .../src/components/editor/floorplan-panel.tsx | 597 ++++++++++++++---- .../editor/use-floorplan-hit-testing.ts | 16 +- .../src/store/use-floorplan-preflight.test.ts | 49 ++ .../src/store/use-floorplan-preflight.ts | 29 +- .../src/construction-dimension/panel.tsx | 76 ++- .../src/components/viewer/post-processing.tsx | 1 + .../src/lib/merged-outline-node.test.ts | 22 + .../viewer/src/lib/merged-outline-node.ts | 9 +- 16 files changed, 1042 insertions(+), 258 deletions(-) create mode 100644 packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts create mode 100644 packages/editor/src/components/editor/camera-dragging-lifecycle.ts create mode 100644 packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts create mode 100644 packages/editor/src/components/editor/floorplan-navigation-presentation.ts create mode 100644 packages/editor/src/store/use-floorplan-preflight.test.ts create mode 100644 packages/viewer/src/lib/merged-outline-node.test.ts 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,