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:
co-authored by
Claude Opus 4.6
parent
26c9e17fed
commit
fcac55ca30
+63
-4
@@ -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.3–2m preset),
|
// Preset capture mode frames a single subtree (often a 0.3–2m 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user