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 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
}
})
})
@@ -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
const schedule = () => {
if (scheduledFrame !== null) return
let mutationVersion = 0
let observedVersion = 0
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
scheduledFrame = requestFrame(() => {
const flushWhenSettled = () => {
if (observedVersion !== mutationVersion) {
observedVersion = mutationVersion
scheduledFrame = requestFrame(flushWhenSettled)
return
}
scheduledFrame = null
onChange()
})
}
const schedule = () => {
mutationVersion += 1
if (scheduledFrame !== null) return
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',
@@ -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<SVGGElement>(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,38 +1478,74 @@ 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<SVGGElement>('[data-floorplan-annotation-label]'),
registryLayer.querySelectorAll<SVGGElement>('[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'
}
}, [
active,
annotationLayoutOverrides,
interactionIdle,
layoutEpoch,
resetPreflightIssues,
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) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
label.style.cursor = 'grabbing'
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 = annotationLayoutOverrides[id] ?? {
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,
@@ -1524,40 +1563,64 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
`${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)
const finishPointerDrag = (endEvent: PointerEvent) => {
if (endEvent.pointerId !== event.pointerId) return
cleanupPointerDrag?.()
label.style.cursor = moved || wasPinned ? 'grab' : 'move'
if (moved) setAnnotationLayoutOverride(id, latest)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
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()
setAnnotationLayoutOverride(id, null)
label.style.cursor = 'move'
setAnnotationLayoutOverride(labelId(label), null)
}
label.addEventListener('pointerdown', onPointerDown)
label.addEventListener('dblclick', onDoubleClick)
cleanup.push(() => {
label.removeEventListener('pointerdown', onPointerDown)
label.removeEventListener('dblclick', onDoubleClick)
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 = ''
})
}
return () => {
for (const fn of cleanup) fn()
}
}, [
active,
annotationLayoutOverrides,
layoutEpoch,
resetPreflightIssues,
setAnnotationLayoutOverride,
setPreflightIssues,
])
}, [layoutEnabled, setAnnotationLayoutOverride])
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,
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.32m preset),
// 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 { 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<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const floorplanBackgroundRef = useRef<SVGRectElement>(null)
const floorplanSceneRef = useRef<SVGGElement>(null)
const floorplanContentRef = useRef<SVGGElement>(null)
const panStateRef = useRef<PanState | null>(null)
@@ -5314,6 +5336,21 @@ export function FloorplanPanel({
const latestFittedViewportRef = useRef<FloorplanViewport | null>(null)
const floorplanViewAnimationFrameRef = useRef<number | 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>(
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<FloorplanViewport | null>(null)
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
// 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,16 +6652,17 @@ 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 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()
@@ -6645,8 +6688,36 @@ export function FloorplanPanel({
}
return bbox
})
}
const flushWhenSettled = () => {
if (observedVersion !== mutationVersion) {
observedVersion = mutationVersion
scheduledFrame = requestAnimationFrame(flushWhenSettled)
return
}
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) => {
hasUserAdjustedViewportRef.current = true
@@ -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,7 +9288,10 @@ export function FloorplanPanel({
[isScreenSelectionToolActive, setPreviewSelectedIds],
)
const endFloorplanNavigation = useCallback((event?: ReactPointerEvent<SVGSVGElement>) => {
const endFloorplanNavigation = useCallback(
(event?: ReactPointerEvent<SVGSVGElement>) => {
const wasPanning = panStateRef.current !== null
const rotationState = floorplanRotationStateRef.current
if (
event &&
(panStateRef.current || floorplanRotationStateRef.current) &&
@@ -9007,13 +9302,17 @@ export function FloorplanPanel({
panStateRef.current = null
floorplanRotationStateRef.current = null
if (wasPanning) commitFloorplanPan()
if (rotationState) commitFloorplanRotation(rotationState)
setIsPanning(false)
setIsRotatingFloorplan(false)
window.setTimeout(() => {
floorplanNavigationClickSuppressedRef.current = false
}, 0)
}, [])
},
[commitFloorplanPan, commitFloorplanRotation],
)
const hoveredWallIdRef = 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
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}
>
<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
cursorColor={floorplanCursorColor}
floorplanSelectionTool={floorplanSelectionTool}
@@ -11360,7 +11680,7 @@ export function FloorplanPanel({
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>
<pattern
@@ -11398,10 +11718,11 @@ export function FloorplanPanel({
</defs>
<rect
fill={palette.surface}
height={viewBox.height}
width={viewBox.width}
x={viewBox.minX}
y={viewBox.minY}
height={presentationViewBox.height}
ref={floorplanBackgroundRef}
width={presentationViewBox.width}
x={presentationViewBox.minX}
y={presentationViewBox.minY}
/>
<g
@@ -11463,7 +11784,7 @@ export function FloorplanPanel({
{isMarqueeSelectionToolActive && (
<rect
fill="transparent"
height={viewBox.height}
height={presentationViewBox.height}
onClick={(event) => {
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 && (
<rect
fill="transparent"
height={viewBox.height}
height={presentationViewBox.height}
pointerEvents="all"
style={{ cursor: floorplanNavigationCursor ?? 'grab' }}
width={viewBox.width}
x={viewBox.minX}
y={viewBox.minY}
width={presentationViewBox.width}
x={presentationViewBox.minX}
y={presentationViewBox.minY}
/>
)}
</svg>
@@ -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,
@@ -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
}
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<FloorplanPreflightState>((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: () =>
@@ -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' ? (
<SelectField
disabled={foundationControllers.length === 0}
label="Foundation controller"
<FoundationControllerField
dimensionId={dimension.id}
onChange={(controllingDimensionId) =>
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<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[] {
return [
...new Set(
@@ -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),
@@ -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
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,