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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-23 07:53:01 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 26c9e17fed
commit fcac55ca30
16 changed files with 1042 additions and 258 deletions
@@ -339,7 +339,7 @@ describe('observeSvgAnnotationLayoutChanges', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined let notify: MutationCallback | undefined
let animationFrame: FrameRequestCallback | undefined let animationFrames: FrameRequestCallback[] = []
let disconnected = false let disconnected = false
let observedOptions: MutationObserverInit | undefined let observedOptions: MutationObserverInit | undefined
@@ -363,11 +363,16 @@ describe('observeSvgAnnotationLayoutChanges', () => {
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrame = callback animationFrames.push(callback)
return 1 return animationFrames.length
}) as typeof requestAnimationFrame }) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try { try {
const flushAnimationFrame = () => {
const callbacks = animationFrames
animationFrames = []
for (const callback of callbacks) callback(0)
}
let layoutPasses = 0 let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1 layoutPasses += 1
@@ -376,7 +381,9 @@ describe('observeSvgAnnotationLayoutChanges', () => {
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
expect(layoutPasses).toBe(0) expect(layoutPasses).toBe(0)
animationFrame?.(0) flushAnimationFrame()
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(1) expect(layoutPasses).toBe(1)
expect(observedOptions).toMatchObject({ expect(observedOptions).toMatchObject({
attributes: true, attributes: true,
@@ -405,4 +412,56 @@ describe('observeSvgAnnotationLayoutChanges', () => {
globalThis.cancelAnimationFrame = originalCancelAnimationFrame 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
}
})
}) })
@@ -227,23 +227,30 @@ export function resolveSvgAnnotationCollisions(
return preflightIssues return preflightIssues
} }
export function observeSvgAnnotationLayoutChanges( export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void {
svg: SVGSVGElement,
onChange: () => void,
): () => void {
let scheduledFrame: number | null = null 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 = () => { const schedule = () => {
mutationVersion += 1
if (scheduledFrame !== null) return if (scheduledFrame !== null) return
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) observedVersion = mutationVersion - 1
scheduledFrame = requestFrame(() => { scheduledFrame = requestFrame(flushWhenSettled)
scheduledFrame = null
onChange()
})
} }
const observer = new MutationObserver((mutations) => { const observer = new MutationObserver((mutations) => {
if (mutations.some(isAnnotationLayoutMutation)) schedule() if (mutations.some(isAnnotationLayoutMutation)) schedule()
}) })
observer.observe(svg, { observer.observe(target, {
attributes: true, attributes: true,
attributeFilter: [ attributeFilter: [
'cx', 'cx',
@@ -66,6 +66,7 @@ import {
curveReshapeScope, curveReshapeScope,
endpointReshapeScope, endpointReshapeScope,
holeEditScope, holeEditScope,
isIdle,
tangentReshapeScope, tangentReshapeScope,
} from '../../../lib/interaction/scope' } from '../../../lib/interaction/scope'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -1456,18 +1457,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const markerRef = useRef<SVGGElement>(null) const markerRef = useRef<SVGGElement>(null)
const [layoutEpoch, setLayoutEpoch] = useState(0) const [layoutEpoch, setLayoutEpoch] = useState(0)
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
const layoutEnabled = active && interactionIdle
useLayoutEffect(() => { useLayoutEffect(() => {
if (!active) return if (!layoutEnabled) return
const svg = markerRef.current?.ownerSVGElement const registryLayer = markerRef.current?.parentElement
if (!svg) return if (!registryLayer) return
return observeSvgAnnotationLayoutChanges(svg, () => { return observeSvgAnnotationLayoutChanges(registryLayer, () => {
setLayoutEpoch((epoch) => epoch + 1) setLayoutEpoch((epoch) => epoch + 1)
}) })
}, [active]) }, [layoutEnabled])
useLayoutEffect(() => { useLayoutEffect(() => {
// The epoch is only a trigger; collision inputs are measured from the live SVG below. // The epoch is only a trigger; collision inputs are measured from the live SVG below.
void layoutEpoch void layoutEpoch
@@ -1475,89 +1478,149 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
resetPreflightIssues() resetPreflightIssues()
return return
} }
if (!interactionIdle) return
const svg = markerRef.current?.ownerSVGElement const svg = markerRef.current?.ownerSVGElement
if (!svg) return const registryLayer = markerRef.current?.parentElement
if (!(svg && registryLayer)) return
const preflightIssues = resolveSvgAnnotationCollisions(svg, { const preflightIssues = resolveSvgAnnotationCollisions(svg, {
layoutOverrides: annotationLayoutOverrides, layoutOverrides: annotationLayoutOverrides,
}) })
setPreflightIssues(preflightIssues) setPreflightIssues(preflightIssues)
const labels = Array.from( const labels = Array.from(
svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'), registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
) )
const cleanup: Array<() => void> = []
for (const [index, label] of labels.entries()) { for (const [index, label] of labels.entries()) {
const id = svgAnnotationLabelId(label, index) const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id label.dataset.floorplanAnnotationId = id
label.style.pointerEvents = 'all' label.style.pointerEvents = 'all'
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' 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, active,
annotationLayoutOverrides, annotationLayoutOverrides,
interactionIdle,
layoutEpoch, layoutEpoch,
resetPreflightIssues, resetPreflightIssues,
setAnnotationLayoutOverride,
setPreflightIssues, 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<SVGGElement>('[data-floorplan-annotation-label]')
return label && registryLayer.contains(label) ? label : null
}
const labelId = (label: SVGGElement): string => {
const labels = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[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<SVGGElement>(
'[data-floorplan-annotation-label]',
)) {
label.style.pointerEvents = ''
label.style.cursor = ''
}
}
}, [layoutEnabled, setAnnotationLayoutOverride])
return <g pointerEvents="none" ref={markerRef} /> return <g pointerEvents="none" ref={markerRef} />
} }
@@ -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<typeof globalThis.setTimeout>
},
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<typeof globalThis.setTimeout>
},
cancel: () => {
scheduled = null
},
})
lifecycle.begin()
lifecycle.scheduleEnd()
lifecycle.begin()
expect(scheduled).toBeNull()
expect(dragging).toEqual([true, true])
})
})
@@ -0,0 +1,41 @@
type TimerHandle = ReturnType<typeof globalThis.setTimeout>
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 }
}
@@ -35,6 +35,7 @@ import {
useEndpointReshape, useEndpointReshape,
useMovingNode, useMovingNode,
} from '../../store/use-interaction-scope' } from '../../store/use-interaction-scope'
import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
const currentTarget = new Vector3() const currentTarget = new Vector3()
const tempBox = new Box3() const tempBox = new Box3()
@@ -166,6 +167,10 @@ function isKeyboardPanKey(code: string): boolean {
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' 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 = { type CameraViewportSize = {
width: number width: number
height: number height: number
@@ -420,6 +425,14 @@ export const CustomCameraControls = () => {
const gl = useThree((state) => state.gl) const gl = useThree((state) => state.gl)
const raycaster = useThree((state) => state.raycaster) const raycaster = useThree((state) => state.raycaster)
const viewportSize = useThree((state) => state.size) const viewportSize = useThree((state) => state.size)
const cameraDraggingLifecycle = useMemo(
() =>
createCameraDraggingLifecycle({
setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging),
}),
[],
)
useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle])
useEffect(() => { useEffect(() => {
camera.layers.enable(EDITOR_LAYER) camera.layers.enable(EDITOR_LAYER)
camera.layers.enable(GRID_LAYER) camera.layers.enable(GRID_LAYER)
@@ -446,8 +459,9 @@ export const CustomCameraControls = () => {
const beginLocalCameraInteraction = useCallback(() => { const beginLocalCameraInteraction = useCallback(() => {
cancelPoseApplication() cancelPoseApplication()
cameraDraggingLifecycle.begin()
emitter.emit('camera-controls:interaction-start', undefined) emitter.emit('camera-controls:interaction-start', undefined)
}, [cancelPoseApplication]) }, [cameraDraggingLifecycle, cancelPoseApplication])
const applyPendingPose = useCallback(() => { const applyPendingPose = useCallback(() => {
if (isFirstPersonMode) { if (isFirstPersonMode) {
@@ -1007,6 +1021,9 @@ export const CustomCameraControls = () => {
if (isKeyboardPanKey(event.code)) { if (isKeyboardPanKey(event.code)) {
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false) const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
if (changed) { if (changed) {
if (!hasKeyboardPanInput(keyboardPanKeys.current)) {
cameraDraggingLifecycle.end()
}
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
@@ -1048,6 +1065,7 @@ export const CustomCameraControls = () => {
const onWheel = () => { const onWheel = () => {
beginLocalCameraInteraction() beginLocalCameraInteraction()
cameraDraggingLifecycle.scheduleEnd()
clearPendingFloorplanNavigationPose() clearPendingFloorplanNavigationPose()
} }
@@ -1067,6 +1085,7 @@ export const CustomCameraControls = () => {
panPointerId = null panPointerId = null
panPointerButton = null panPointerButton = null
clearNavigationCursor() clearNavigationCursor()
cameraDraggingLifecycle.end()
updateConfig() updateConfig()
} }
@@ -1089,9 +1108,11 @@ export const CustomCameraControls = () => {
gl.domElement.removeEventListener('wheel', onWheel, true) gl.domElement.removeEventListener('wheel', onWheel, true)
clearKeyboardPanKeys() clearKeyboardPanKeys()
clearNavigationCursor() clearNavigationCursor()
cameraDraggingLifecycle.end()
} }
}, [ }, [
beginLocalCameraInteraction, beginLocalCameraInteraction,
cameraDraggingLifecycle,
cameraMode, cameraMode,
gl, gl,
isPreviewMode, isPreviewMode,
@@ -1407,12 +1428,12 @@ export const CustomCameraControls = () => {
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
const onTransitionStart = useCallback(() => { const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true) cameraDraggingLifecycle.begin()
}, []) }, [cameraDraggingLifecycle])
const onRest = useCallback(() => { const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false) cameraDraggingLifecycle.end()
}, []) }, [cameraDraggingLifecycle])
// Preset capture mode frames a single subtree (often a 0.32m preset), // Preset capture mode frames a single subtree (often a 0.32m preset),
// so the default 2m minDistance prevents the user from getting close // so the default 2m minDistance prevents the user from getting close
@@ -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'])
})
})
@@ -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<RotationState>({
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)
}
@@ -191,6 +191,13 @@ import {
import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { PALETTE_COLORS } from '../ui/primitives/color-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' 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 { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
import { useFloorplanSceneData } from './use-floorplan-scene-data' import { useFloorplanSceneData } from './use-floorplan-scene-data'
@@ -320,6 +327,20 @@ type FloorplanRotationState = {
startClientX: number startClientX: number
initialUserRotationDeg: number initialUserRotationDeg: number
viewportCenterLocal: SvgPoint 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 = { type FloorplanScreenSelectionState = {
@@ -5287,6 +5308,7 @@ export function FloorplanPanel({
}) { }) {
const viewportHostRef = useRef<HTMLDivElement>(null) const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const floorplanBackgroundRef = useRef<SVGRectElement>(null)
const floorplanSceneRef = useRef<SVGGElement>(null) const floorplanSceneRef = useRef<SVGGElement>(null)
const floorplanContentRef = useRef<SVGGElement>(null) const floorplanContentRef = useRef<SVGGElement>(null)
const panStateRef = useRef<PanState | null>(null) const panStateRef = useRef<PanState | null>(null)
@@ -5314,6 +5336,21 @@ export function FloorplanPanel({
const latestFittedViewportRef = useRef<FloorplanViewport | null>(null) const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
const floorplanViewAnimationFrameRef = useRef<number | null>(null) const floorplanViewAnimationFrameRef = useRef<number | null>(null)
const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null) const floorplanViewAnimationTargetRef = useRef<FloorplanViewAnimationTarget | null>(null)
const floorplanZoomCommitTimerRef = useRef<number | null>(null)
const floorplanRenderScaleCommitTimerRef = useRef<number | null>(null)
const floorplanViewportInteractionInProgressRef = useRef(false)
const floorplanImperativeViewBoxRef = useRef<FloorplanPresentationViewBox | null>(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<NavigationSyncPose | null>( const latestNavigationSyncPoseRef = useRef<NavigationSyncPose | null>(
useEditor.getState().navigationSyncPose, useEditor.getState().navigationSyncPose,
) )
@@ -5421,7 +5458,7 @@ export function FloorplanPanel({
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
// Only sync ref from state when floorplan is open (state is source of truth). // 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. // 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 latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg
} }
@@ -5630,9 +5667,14 @@ export function FloorplanPanel({
const [isPanelReady, setIsPanelReady] = useState(false) const [isPanelReady, setIsPanelReady] = useState(false)
const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 })
const [viewport, setViewport] = useState<FloorplanViewport | null>(null) const [viewport, setViewport] = useState<FloorplanViewport | null>(null)
latestViewportRef.current = viewport const [floorplanRenderUnitsPerPixel, setFloorplanRenderUnitsPerPixel] = useState<number | null>(
null,
)
if (!floorplanViewportInteractionInProgressRef.current) {
latestViewportRef.current = viewport
}
// Tight bbox of the painted floor-plan scene (the rotation `<g>`'s // Tight bbox of the painted floor-plan scene (the rotation `<g>`'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 // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now
// empty stubs because rendering moved to the registry layer, so // empty stubs because rendering moved to the registry layer, so
// measuring the DOM is how `fittedViewport` learns where content lives. // measuring the DOM is how `fittedViewport` learns where content lives.
@@ -6610,42 +6652,71 @@ export function FloorplanPanel({
]) ])
latestFittedViewportRef.current = fittedViewport latestFittedViewportRef.current = fittedViewport
// Measure the painted floor-plan scene after each render. `getBBox()` // Measure the content-only subtree after its geometry settles. ViewBox-only
// gives us the tight bounds of whatever the registry layer emitted, // navigation does not change these bounds and must not force `getBBox()` on
// even for kinds whose legacy entry arrays are empty stubs. Bail out // every animation frame.
// when nothing has painted (empty group throws in some browsers). // biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree.
// 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.
useLayoutEffect(() => { useLayoutEffect(() => {
const el = floorplanContentRef.current const el = floorplanContentRef.current
if (!el) return if (!el) return
let bbox: { x: number; y: number; width: number; height: number } let scheduledFrame: number | null = null
try { let mutationVersion = 0
const measured = el.getBBox() let observedVersion = 0
bbox = { const measure = () => {
x: measured.x, let bbox: { x: number; y: number; width: number; height: number }
y: measured.y, try {
width: measured.width, const measured = el.getBBox()
height: measured.height, bbox = {
x: measured.x,
y: measured.y,
width: measured.width,
height: measured.height,
}
} catch {
return
} }
} catch { if (bbox.width <= 0 && bbox.height <= 0) return
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 const flushWhenSettled = () => {
setMeasuredSceneBBox((prev) => { if (observedVersion !== mutationVersion) {
if ( observedVersion = mutationVersion
prev && scheduledFrame = requestAnimationFrame(flushWhenSettled)
prev.x === bbox.x && return
prev.y === bbox.y &&
prev.width === bbox.width &&
prev.height === bbox.height
) {
return prev
} }
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( const applyFloorplanNavigationState = useCallback(
(nextViewport: FloorplanViewport, userRotationDeg: number) => { (nextViewport: FloorplanViewport, userRotationDeg: number) => {
@@ -6798,7 +6869,7 @@ export function FloorplanPanel({
if (!isFloorplanOpenRef.current) { if (!isFloorplanOpenRef.current) {
return return
} }
if (floorplanRotationStateRef.current) { if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) {
return 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(() => { useEffect(() => {
const levelChanged = previousLevelIdRef.current !== (levelId ?? null) const levelChanged = previousLevelIdRef.current !== (levelId ?? null)
@@ -7064,19 +7106,31 @@ export function FloorplanPanel({
height, height,
} }
}, [fittedViewport, svgAspectRatio, viewport]) }, [fittedViewport, svgAspectRatio, viewport])
const presentationViewBox = resolveFloorplanPresentationViewBox(
viewBox,
floorplanImperativeViewBoxRef.current,
floorplanViewportInteractionInProgressRef.current,
)
const floorplanWorldUnitsPerPixel = useMemo(() => { const floorplanWorldUnitsPerPixel = useMemo(() => {
const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1) const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1)
return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 return (widthUnitsPerPixel + heightUnitsPerPixel) / 2
}, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width]) }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width])
const floorplanWallHitTolerance = useMemo( const getLiveFloorplanWorldUnitsPerPixel = useCallback(() => {
() => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), const width = latestViewportRef.current?.width ?? viewBox.width
[floorplanWorldUnitsPerPixel], 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( const getFloorplanOpeningHitTolerance = useCallback(
() => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2),
[floorplanWorldUnitsPerPixel], [getLiveFloorplanWorldUnitsPerPixel],
) )
const wallSelectionHatchSpacing = useMemo( const wallSelectionHatchSpacing = useMemo(
() => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001), () => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001),
@@ -7361,7 +7415,9 @@ export function FloorplanPanel({
), ),
[gridBounds, gridSteps.majorStep], [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(() => { useEffect(() => {
setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters')
@@ -7889,8 +7945,104 @@ export function FloorplanPanel({
[beginPanelInteraction, panelRect], [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( const zoomViewportAtClientPoint = useCallback(
(clientX: number, clientY: number, widthFactor: number) => { (clientX: number, clientY: number, widthFactor: number) => {
if (!canZoomFloorplanDuringNavigation(floorplanRotationStateRef.current !== null)) {
return
}
if (!Number.isFinite(widthFactor) || widthFactor <= 0) { if (!Number.isFinite(widthFactor) || widthFactor <= 0) {
return return
} }
@@ -7906,12 +8058,21 @@ export function FloorplanPanel({
} }
const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg) const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg)
const currentViewport = viewport ?? fittedViewport const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
const currentViewBox = viewBox 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( const nextWidth = resolveFloorplanViewWidth(
currentViewport.width * widthFactor, currentViewport.width * widthFactor,
currentViewport.width, currentViewport.width,
fittedViewport, fitted,
true, true,
) )
const nextHeight = nextWidth / svgAspectRatio const nextHeight = nextWidth / svgAspectRatio
@@ -7925,30 +8086,140 @@ export function FloorplanPanel({
y: nextMinY + nextHeight / 2, y: nextMinY + nextHeight / 2,
} }
const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg)
const nextViewport = {
centerX: nextCenterSvg.x,
centerY: nextCenterSvg.y,
width: nextWidth,
}
smoothFloorplanNavigationView( stopFloorplanViewAnimation()
localCenter, if (floorplanRenderScaleCommitTimerRef.current !== null) {
latestFloorplanUserRotationDegRef.current, window.clearTimeout(floorplanRenderScaleCommitTimerRef.current)
nextWidth, floorplanRenderScaleCommitTimerRef.current = null
) }
publishFloorplanNavigationPose( floorplanViewportInteractionInProgressRef.current = true
localCenter, applyFloorplanViewportImperatively(nextViewport)
latestFloorplanUserRotationDegRef.current, scheduleFloorplanZoomCommit()
nextWidth, const userRotationDeg = latestFloorplanUserRotationDegRef.current
) floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth }
if (useEditor.getState().viewMode === 'split') {
publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth)
}
}, },
[ [
fittedViewport, applyFloorplanViewportImperatively,
floorplanSceneRotationDeg, floorplanSceneRotationDeg,
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
publishFloorplanNavigationPose, publishFloorplanNavigationPose,
smoothFloorplanNavigationView, scheduleFloorplanZoomCommit,
stopFloorplanViewAnimation,
svgAspectRatio, 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(() => { const clearWallPlacementDraft = useCallback(() => {
setDraftStart(null) setDraftStart(null)
setWallChainFirstVertex(null) setWallChainFirstVertex(null)
@@ -8906,8 +9177,12 @@ export function FloorplanPanel({
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
stopFloorplanViewAnimation()
floorplanNavigationClickSuppressedRef.current = true floorplanNavigationClickSuppressedRef.current = true
const currentViewport = viewport ?? fittedViewport const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) return
floorplanViewportInteractionInProgressRef.current = true
panStateRef.current = { panStateRef.current = {
pointerId: event.pointerId, pointerId: event.pointerId,
clientX: event.clientX, clientX: event.clientX,
@@ -8932,17 +9207,35 @@ export function FloorplanPanel({
event.preventDefault() event.preventDefault()
event.stopPropagation() 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( const viewportCenterLocal = rotateSvgPoint(
{ x: currentViewport.centerX, y: currentViewport.centerY }, { 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 = { floorplanRotationStateRef.current = {
pointerId: event.pointerId, pointerId: event.pointerId,
startClientX: event.clientX, startClientX: event.clientX,
initialUserRotationDeg: floorplanUserRotationDeg, initialUserRotationDeg: currentUserRotationDeg,
viewportCenterLocal, viewportCenterLocal,
svg,
svgStyle,
latestUserRotationDeg: currentUserRotationDeg,
latestViewport: currentViewport,
} }
setIsRotatingFloorplan(true) setIsRotatingFloorplan(true)
setCursorPoint(null) setCursorPoint(null)
@@ -8951,12 +9244,11 @@ export function FloorplanPanel({
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
}, },
[ [
fittedViewport, commitFloorplanZoom,
floorplanSceneRotationDeg, buildingRotationDeg,
floorplanUserRotationDeg,
viewport,
setFloorplanCursorPosition, setFloorplanCursorPosition,
setCursorPoint, setCursorPoint,
stopFloorplanViewAnimation,
], ],
) )
@@ -8996,24 +9288,31 @@ export function FloorplanPanel({
[isScreenSelectionToolActive, setPreviewSelectedIds], [isScreenSelectionToolActive, setPreviewSelectedIds],
) )
const endFloorplanNavigation = useCallback((event?: ReactPointerEvent<SVGSVGElement>) => { const endFloorplanNavigation = useCallback(
if ( (event?: ReactPointerEvent<SVGSVGElement>) => {
event && const wasPanning = panStateRef.current !== null
(panStateRef.current || floorplanRotationStateRef.current) && const rotationState = floorplanRotationStateRef.current
event.currentTarget.hasPointerCapture(event.pointerId) if (
) { event &&
event.currentTarget.releasePointerCapture(event.pointerId) (panStateRef.current || floorplanRotationStateRef.current) &&
} event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
panStateRef.current = null panStateRef.current = null
floorplanRotationStateRef.current = null floorplanRotationStateRef.current = null
setIsPanning(false) if (wasPanning) commitFloorplanPan()
setIsRotatingFloorplan(false) if (rotationState) commitFloorplanRotation(rotationState)
setIsPanning(false)
setIsRotatingFloorplan(false)
window.setTimeout(() => { window.setTimeout(() => {
floorplanNavigationClickSuppressedRef.current = false floorplanNavigationClickSuppressedRef.current = false
}, 0) }, 0)
}, []) },
[commitFloorplanPan, commitFloorplanRotation],
)
const hoveredWallIdRef = useRef<string | null>(null) const hoveredWallIdRef = useRef<string | null>(null)
const hoveredCeilingIdRef = useRef<string | null>(null) const hoveredCeilingIdRef = useRef<string | null>(null)
@@ -9213,9 +9512,14 @@ export function FloorplanPanel({
(rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL
const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg
smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg)
publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) if (useEditor.getState().viewMode === 'split') {
setCursorPoint(null) publishFloorplanNavigationPose(
rotationState.viewportCenterLocal,
nextUserRotationDeg,
rotationState.latestViewport.width,
)
}
return return
} }
@@ -9225,8 +9529,11 @@ export function FloorplanPanel({
const deltaX = event.clientX - panStateRef.current.clientX const deltaX = event.clientX - panStateRef.current.clientX
const deltaY = event.clientY - panStateRef.current.clientY const deltaY = event.clientY - panStateRef.current.clientY
const worldPerPixelX = viewBox.width / surfaceSize.width const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
const worldPerPixelY = viewBox.height / surfaceSize.height if (!currentViewport) return
const currentHeight = currentViewport.width / svgAspectRatio
const worldPerPixelX = currentViewport.width / surfaceSize.width
const worldPerPixelY = currentHeight / surfaceSize.height
const nextCenterSvg = { const nextCenterSvg = {
x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX, x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX,
@@ -9237,8 +9544,20 @@ export function FloorplanPanel({
FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg
const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
smoothFloorplanNavigationView(localCenter, currentUserRotationDeg) const nextViewport = {
publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) 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 = { panStateRef.current = {
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -9578,6 +9897,8 @@ export function FloorplanPanel({
}, },
[ [
buildingRotationDeg, buildingRotationDeg,
applyFloorplanViewportImperatively,
applyFloorplanRotationImperatively,
draftStart, draftStart,
ceilingDraftPoints, ceilingDraftPoints,
emitFloorplanWallLeave, emitFloorplanWallLeave,
@@ -9606,15 +9927,13 @@ export function FloorplanPanel({
isWallBuildActive, isWallBuildActive,
levelId, levelId,
publishFloorplanNavigationPose, publishFloorplanNavigationPose,
smoothFloorplanNavigationView,
referenceScaleDraft, referenceScaleDraft,
roofDraftStart, roofDraftStart,
elevatorResizeDragState, elevatorResizeDragState,
siteVertexDragState, siteVertexDragState,
surfaceSize.height, surfaceSize.height,
surfaceSize.width, surfaceSize.width,
viewBox.height, svgAspectRatio,
viewBox.width,
walls, walls,
setCursorPoint, setCursorPoint,
setDraftEnd, setDraftEnd,
@@ -9879,10 +10198,10 @@ export function FloorplanPanel({
displayWallPolygons, displayWallPolygons,
floorplanElevatorEntries, floorplanElevatorEntries,
floorplanItemEntries, floorplanItemEntries,
floorplanOpeningHitTolerance,
floorplanRoofEntries, floorplanRoofEntries,
floorplanStairEntries, floorplanStairEntries,
floorplanWallHitTolerance, getFloorplanOpeningHitTolerance,
getFloorplanWallHitTolerance,
getOpeningCenterLine, getOpeningCenterLine,
isFloorplanItemContextActive, isFloorplanItemContextActive,
openingsPolygons, openingsPolygons,
@@ -11030,6 +11349,7 @@ export function FloorplanPanel({
const handleGestureEnd = (event: Event) => { const handleGestureEnd = (event: Event) => {
gestureScaleRef.current = 1 gestureScaleRef.current = 1
commitFloorplanZoom()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
@@ -11049,7 +11369,7 @@ export function FloorplanPanel({
svg.removeEventListener('gesturechange', handleGestureChange) svg.removeEventListener('gesturechange', handleGestureChange)
svg.removeEventListener('gestureend', handleGestureEnd) svg.removeEventListener('gestureend', handleGestureEnd)
} }
}, [zoomViewportAtClientPoint]) }, [commitFloorplanZoom, zoomViewportAtClientPoint])
const restoreGroundLevelStructureSelection = useCallback(() => { const restoreGroundLevelStructureSelection = useCallback(() => {
const sceneNodes = useScene.getState().nodes const sceneNodes = useScene.getState().nodes
@@ -11167,7 +11487,7 @@ export function FloorplanPanel({
ref={containerRef} ref={containerRef}
> >
<FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} /> <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
<div className="relative min-h-0 flex-1" ref={viewportHostRef}> <div className="relative min-h-0 flex-1 bg-white" ref={viewportHostRef}>
<FloorplanCursorIndicator <FloorplanCursorIndicator
cursorColor={floorplanCursorColor} cursorColor={floorplanCursorColor}
floorplanSelectionTool={floorplanSelectionTool} floorplanSelectionTool={floorplanSelectionTool}
@@ -11360,7 +11680,7 @@ export function FloorplanPanel({
cursor: cursor:
floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR), floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR),
}} }}
viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`}
> >
<defs> <defs>
<pattern <pattern
@@ -11398,10 +11718,11 @@ export function FloorplanPanel({
</defs> </defs>
<rect <rect
fill={palette.surface} fill={palette.surface}
height={viewBox.height} height={presentationViewBox.height}
width={viewBox.width} ref={floorplanBackgroundRef}
x={viewBox.minX} width={presentationViewBox.width}
y={viewBox.minY} x={presentationViewBox.minX}
y={presentationViewBox.minY}
/> />
<g <g
@@ -11463,7 +11784,7 @@ export function FloorplanPanel({
{isMarqueeSelectionToolActive && ( {isMarqueeSelectionToolActive && (
<rect <rect
fill="transparent" fill="transparent"
height={viewBox.height} height={presentationViewBox.height}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -11477,9 +11798,9 @@ export function FloorplanPanel({
onPointerMove={handleMarqueePointerMove} onPointerMove={handleMarqueePointerMove}
onPointerUp={handleMarqueePointerUp} onPointerUp={handleMarqueePointerUp}
style={{ cursor: EDITOR_CURSOR }} style={{ cursor: EDITOR_CURSOR }}
width={viewBox.width} width={presentationViewBox.width}
x={viewBox.minX} x={presentationViewBox.minX}
y={viewBox.minY} y={presentationViewBox.minY}
/> />
)} )}
@@ -11692,12 +12013,12 @@ export function FloorplanPanel({
{isFloorplanNavigationOverlayVisible && ( {isFloorplanNavigationOverlayVisible && (
<rect <rect
fill="transparent" fill="transparent"
height={viewBox.height} height={presentationViewBox.height}
pointerEvents="all" pointerEvents="all"
style={{ cursor: floorplanNavigationCursor ?? 'grab' }} style={{ cursor: floorplanNavigationCursor ?? 'grab' }}
width={viewBox.width} width={presentationViewBox.width}
x={viewBox.minX} x={presentationViewBox.minX}
y={viewBox.minY} y={presentationViewBox.minY}
/> />
)} )}
</svg> </svg>
@@ -89,10 +89,10 @@ type UseFloorplanHitTestingArgs = {
displayWallPolygons: WallPolygonEntry[] displayWallPolygons: WallPolygonEntry[]
floorplanElevatorEntries: ElevatorPolygonEntry[] floorplanElevatorEntries: ElevatorPolygonEntry[]
floorplanItemEntries: FloorplanItemEntry[] floorplanItemEntries: FloorplanItemEntry[]
floorplanOpeningHitTolerance: number getFloorplanOpeningHitTolerance: () => number
floorplanRoofEntries: FloorplanRoofEntry[] floorplanRoofEntries: FloorplanRoofEntry[]
floorplanStairEntries: FloorplanStairEntry[] floorplanStairEntries: FloorplanStairEntry[]
floorplanWallHitTolerance: number getFloorplanWallHitTolerance: () => number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
isFloorplanItemContextActive: boolean isFloorplanItemContextActive: boolean
openingsPolygons: OpeningPolygonEntry[] openingsPolygons: OpeningPolygonEntry[]
@@ -107,10 +107,10 @@ export function useFloorplanHitTesting({
displayWallPolygons, displayWallPolygons,
floorplanElevatorEntries, floorplanElevatorEntries,
floorplanItemEntries, floorplanItemEntries,
floorplanOpeningHitTolerance, getFloorplanOpeningHitTolerance,
floorplanRoofEntries, floorplanRoofEntries,
floorplanStairEntries, floorplanStairEntries,
floorplanWallHitTolerance, getFloorplanWallHitTolerance,
getOpeningCenterLine, getOpeningCenterLine,
isFloorplanItemContextActive, isFloorplanItemContextActive,
openingsPolygons, openingsPolygons,
@@ -132,8 +132,8 @@ export function useFloorplanHitTesting({
elevators: floorplanElevatorEntries, elevators: floorplanElevatorEntries,
walls: displayWallPolygons, walls: displayWallPolygons,
slabs: displaySlabPolygons, slabs: displaySlabPolygons,
openingHitTolerance: floorplanOpeningHitTolerance, openingHitTolerance: getFloorplanOpeningHitTolerance(),
wallHitTolerance: floorplanWallHitTolerance, wallHitTolerance: getFloorplanWallHitTolerance(),
columns: columnPolygons, columns: columnPolygons,
getOpeningCenterLine, getOpeningCenterLine,
}) })
@@ -145,10 +145,10 @@ export function useFloorplanHitTesting({
displayWallPolygons, displayWallPolygons,
floorplanItemEntries, floorplanItemEntries,
floorplanElevatorEntries, floorplanElevatorEntries,
floorplanOpeningHitTolerance,
floorplanRoofEntries, floorplanRoofEntries,
floorplanStairEntries, floorplanStairEntries,
floorplanWallHitTolerance, getFloorplanOpeningHitTolerance,
getFloorplanWallHitTolerance,
getOpeningCenterLine, getOpeningCenterLine,
isFloorplanItemContextActive, isFloorplanItemContextActive,
openingsPolygons, openingsPolygons,
@@ -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' }),
])
})
})
@@ -18,6 +18,23 @@ export type FloorplanPreflightIssue = {
message: string 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 = { type FloorplanPreflightState = {
issues: FloorplanPreflightIssue[] issues: FloorplanPreflightIssue[]
layoutIssues: FloorplanPreflightIssue[] layoutIssues: FloorplanPreflightIssue[]
@@ -38,9 +55,17 @@ export const useFloorplanPreflight = create<FloorplanPreflightState>((set) => ({
clearanceChecksEnabled: false, clearanceChecksEnabled: false,
moduleChecksEnabled: false, moduleChecksEnabled: false,
setIssues: (issues) => 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) => 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 }), setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }),
setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }), setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }),
reset: () => reset: () =>
@@ -1,6 +1,7 @@
'use client' 'use client'
import { import {
type AnyNode,
type AnyNodeId, type AnyNodeId,
type ConstructionDimensionDatumPolicy, type ConstructionDimensionDatumPolicy,
type ConstructionDimensionDrawingPresentation, type ConstructionDimensionDrawingPresentation,
@@ -86,16 +87,6 @@ export default function ConstructionDimensionPanel() {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined
return node?.type === 'construction-dimension' ? node : null 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 updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode) const deleteNode = useScene((state) => state.deleteNode)
const activeDrawingType = useDrawingView((state) => state.drawingType) const activeDrawingType = useDrawingView((state) => state.drawingType)
@@ -127,10 +118,14 @@ export default function ConstructionDimensionPanel() {
drawingType, drawingType,
presentation, presentation,
) )
const firstFoundationController =
presentation === 'controlled' && !dimension.controllingDimensionId
? selectFoundationControllers(useScene.getState().nodes, dimension.id)[0]
: undefined
update({ update({
drawingOverrides, drawingOverrides,
...(presentation === 'controlled' && !dimension.controllingDimensionId ...(presentation === 'controlled' && !dimension.controllingDimensionId
? { controllingDimensionId: foundationControllers[0]?.id ?? null } ? { controllingDimensionId: firstFoundationController?.id ?? null }
: {}), : {}),
}) })
} }
@@ -207,21 +202,13 @@ export default function ConstructionDimensionPanel() {
value={activePresentation} value={activePresentation}
/> />
{activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? ( {activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? (
<SelectField <FoundationControllerField
disabled={foundationControllers.length === 0} dimensionId={dimension.id}
label="Foundation controller"
onChange={(controllingDimensionId) => onChange={(controllingDimensionId) =>
update({ update({
controllingDimensionId: controllingDimensionId as NonNullable< controllingDimensionId,
ConstructionDimensionNode['controllingDimensionId']
>,
}) })
} }
options={foundationControllers.map((controller) => ({
label: controller.name || 'Foundation dimension',
value: controller.id,
}))}
placeholder="No foundation dimensions"
value={dimension.controllingDimensionId ?? ''} value={dimension.controllingDimensionId ?? ''}
/> />
) : null} ) : null}
@@ -339,6 +326,51 @@ export default function ConstructionDimensionPanel() {
) )
} }
function selectFoundationControllers(
nodes: Record<string, AnyNode>,
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<ConstructionDimensionNode['controllingDimensionId']>) => void
}) {
const foundationControllers = useScene(
useShallow((state) => selectFoundationControllers(state.nodes, dimensionId)),
)
return (
<SelectField
disabled={foundationControllers.length === 0}
label="Foundation controller"
onChange={(controllingDimensionId) =>
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[] { function parseSuppressedSegments(value: string): number[] {
return [ return [
...new Set( ...new Set(
@@ -526,6 +526,7 @@ const PostProcessingPasses = ({
let visualAlpha = contentAlpha let visualAlpha = contentAlpha
if (outlineEnabled) { if (outlineEnabled) {
const outlineNode = mergedOutline(scene, camera, { const outlineNode = mergedOutline(scene, camera, {
enabled: () => !useViewer.getState().cameraDragging,
primaryObjects: outliner.selectedObjects, primaryObjects: outliner.selectedObjects,
secondaryObjects: outliner.hoveredObjects, secondaryObjects: outliner.hoveredObjects,
primaryEdgeThickness: uniform(1), primaryEdgeThickness: uniform(1),
@@ -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()
})
})
@@ -125,6 +125,7 @@ export class MergedOutlineNode extends TempNode {
primaryEdgeGlowNode: any primaryEdgeGlowNode: any
secondaryEdgeGlowNode: any secondaryEdgeGlowNode: any
downSampleRatio: number downSampleRatio: number
enabled: () => boolean
updateBeforeType: string updateBeforeType: string
private readonly _depthRT: RenderTarget private readonly _depthRT: RenderTarget
@@ -189,6 +190,7 @@ export class MergedOutlineNode extends TempNode {
primaryEdgeGlow?: any primaryEdgeGlow?: any
secondaryEdgeGlow?: any secondaryEdgeGlow?: any
downSampleRatio?: number downSampleRatio?: number
enabled?: () => boolean
} = {}, } = {},
) { ) {
super('vec4') super('vec4')
@@ -201,6 +203,7 @@ export class MergedOutlineNode extends TempNode {
primaryEdgeGlow = float(0), primaryEdgeGlow = float(0),
secondaryEdgeGlow = float(0), secondaryEdgeGlow = float(0),
downSampleRatio = 2, downSampleRatio = 2,
enabled = () => true,
} = params } = params
this.scene = scene this.scene = scene
@@ -212,6 +215,7 @@ export class MergedOutlineNode extends TempNode {
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
this.downSampleRatio = downSampleRatio this.downSampleRatio = downSampleRatio
this.enabled = enabled
this.updateBeforeType = NodeUpdateType.FRAME this.updateBeforeType = NodeUpdateType.FRAME
this._depthRT = new RenderTarget() this._depthRT = new RenderTarget()
@@ -301,8 +305,9 @@ export class MergedOutlineNode extends TempNode {
} }
updateBefore(frame: any) { updateBefore(frame: any) {
const hasPrimary = this.primaryObjects.length > 0 const enabled = this.enabled()
const hasSecondary = this.secondaryObjects.length > 0 const hasPrimary = enabled && this.primaryObjects.length > 0
const hasSecondary = enabled && this.secondaryObjects.length > 0
const hasAny = hasPrimary || hasSecondary const hasAny = hasPrimary || hasSecondary
// Fast-path: nothing to render and nothing was rendered last frame either, // Fast-path: nothing to render and nothing was rendered last frame either,