editor: improve floorplan navigation performance (#541)

* 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

* perf(editor): reduce floorplan annotation navigation work

* perf(editor): reduce floorplan camera update work

* perf(editor): isolate floorplan pose updates

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-24 08:00:03 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 28ab27f5ec
commit 3b70deb837
21 changed files with 1340 additions and 540 deletions
-1
View File
@@ -222,7 +222,6 @@ type CameraControlEvents = {
'camera-controls:orbit-ccw': undefined
'camera-controls:fit-scene': CameraControlFitSceneEvent
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
'camera-controls:pose': CameraPose
'camera-controls:apply-pose': CameraPose
'camera-controls:cancel-pose': undefined
'camera-controls:interaction-start': undefined
@@ -0,0 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { createFloorplanRenderScaleReference } from './floorplan-render-context'
describe('floorplan render context', () => {
test('updates the live scale without changing the registry-facing reader', () => {
const scale = createFloorplanRenderScaleReference(0.02)
const read = scale.read
scale.update(0.01)
expect(scale.read).toBe(read)
expect(read()).toBe(0.01)
})
})
@@ -1,7 +1,7 @@
'use client'
import type { FloorplanPalette } from '@pascal-app/core'
import { createContext, type ReactNode, useContext, useMemo } from 'react'
import { createContext, type ReactNode, useContext, useMemo, useRef } from 'react'
/**
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
@@ -34,7 +34,34 @@ export type FloorplanRenderContextValue = {
sceneRotationDeg: number
}
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
export type FloorplanStaticRenderContextValue = Omit<
FloorplanRenderContextValue,
'sceneRotationDeg' | 'unitsPerPixel'
> & {
getSceneRotationDeg: () => number
getUnitsPerPixel: () => number
}
const FloorplanStaticRenderContext = createContext<FloorplanStaticRenderContextValue | null>(null)
const FloorplanSceneRotationContext = createContext(0)
const FloorplanUnitsPerPixelContext = createContext(1)
export type FloorplanRenderScaleReference = {
read: () => number
update: (unitsPerPixel: number) => void
}
export function createFloorplanRenderScaleReference(
initialUnitsPerPixel: number,
): FloorplanRenderScaleReference {
let unitsPerPixel = initialUnitsPerPixel
return {
read: () => unitsPerPixel,
update: (nextUnitsPerPixel) => {
unitsPerPixel = nextUnitsPerPixel
},
}
}
export function FloorplanRenderProvider({
children,
@@ -42,12 +69,30 @@ export function FloorplanRenderProvider({
palette,
hatchPatternId,
sceneRotationDeg,
}: FloorplanRenderContextValue & { children: ReactNode }) {
const value = useMemo<FloorplanRenderContextValue>(
() => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
[unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
getSceneRotationDeg,
}: FloorplanRenderContextValue & {
children: ReactNode
getSceneRotationDeg: () => number
}) {
const renderScaleReference = useRef<FloorplanRenderScaleReference | null>(null)
if (!renderScaleReference.current) {
renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel)
}
renderScaleReference.current.update(unitsPerPixel)
const getUnitsPerPixel = renderScaleReference.current.read
const staticValue = useMemo<FloorplanStaticRenderContextValue>(
() => ({ palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel }),
[palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel],
)
return (
<FloorplanStaticRenderContext.Provider value={staticValue}>
<FloorplanUnitsPerPixelContext.Provider value={unitsPerPixel}>
<FloorplanSceneRotationContext.Provider value={sceneRotationDeg}>
{children}
</FloorplanSceneRotationContext.Provider>
</FloorplanUnitsPerPixelContext.Provider>
</FloorplanStaticRenderContext.Provider>
)
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
}
/**
@@ -58,5 +103,27 @@ export function FloorplanRenderProvider({
* whole legacy panel along.
*/
export function useFloorplanRender(): FloorplanRenderContextValue | null {
return useContext(FloorplanRenderContext)
const staticValue = useContext(FloorplanStaticRenderContext)
const sceneRotationDeg = useContext(FloorplanSceneRotationContext)
const unitsPerPixel = useContext(FloorplanUnitsPerPixelContext)
return useMemo(
() =>
staticValue
? {
unitsPerPixel,
palette: staticValue.palette,
hatchPatternId: staticValue.hatchPatternId,
sceneRotationDeg,
}
: null,
[sceneRotationDeg, staticValue, unitsPerPixel],
)
}
export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue | null {
return useContext(FloorplanStaticRenderContext)
}
export function useFloorplanSceneRotation(): number {
return useContext(FloorplanSceneRotationContext)
}
@@ -3,9 +3,9 @@ import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-exte
import {
collectAnnotationLayoutPreflightIssues,
floorplanAnnotationObstacleMode,
observeSvgAnnotationLayoutChanges,
polylineObstacleRectangles,
resolveAnnotationLabelRectangles,
resolveSvgAnnotationCollisions,
} from './floorplan-annotation-layout'
describe('floorplanAnnotationObstacleMode', () => {
@@ -333,135 +333,14 @@ describe('resolveAnnotationLabelRectangles', () => {
})
})
describe('observeSvgAnnotationLayoutChanges', () => {
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
const OriginalMutationObserver = globalThis.MutationObserver
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined
let animationFrames: FrameRequestCallback[] = []
let disconnected = false
let observedOptions: MutationObserverInit | undefined
describe('resolveSvgAnnotationCollisions', () => {
test('uses captured label references instead of querying for them again', () => {
const svg = {
querySelectorAll: () => {
throw new Error('labels were rediscovered')
},
} as unknown as SVGSVGElement
class FakeMutationObserver {
constructor(callback: MutationCallback) {
notify = callback
}
observe(_target: Node, options?: MutationObserverInit): void {
observedOptions = options
}
disconnect(): void {
disconnected = true
}
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
})
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(1)
expect(observedOptions).toMatchObject({
attributes: true,
childList: true,
subtree: true,
attributeFilter: expect.any(Array),
})
notify?.(
[
{
attributeName: 'style',
target: { closest: () => ({}) },
type: 'attributes',
} as unknown as MutationRecord,
],
{} as MutationObserver,
)
expect(layoutPasses).toBe(1)
stop()
expect(disconnected).toBe(true)
} finally {
globalThis.MutationObserver = OriginalMutationObserver
globalThis.requestAnimationFrame = originalRequestAnimationFrame
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
}
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toEqual([])
})
})
@@ -127,9 +127,14 @@ export function resolveAnnotationLabelRectangles(
export function resolveSvgAnnotationCollisions(
svg: SVGSVGElement,
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
options: {
labels?: readonly SVGGElement[]
layoutOverrides?: AnnotationLayoutOverrides
} = {},
): AnnotationPreflightIssue[] {
const labels = Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
const labels =
options.labels ??
Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
if (labels.length === 0) return []
for (const label of labels) {
@@ -227,101 +232,6 @@ export function resolveSvgAnnotationCollisions(
return preflightIssues
}
export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void {
let scheduledFrame: number | null = null
let mutationVersion = 0
let observedVersion = 0
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
const flushWhenSettled = () => {
if (observedVersion !== mutationVersion) {
observedVersion = mutationVersion
scheduledFrame = requestFrame(flushWhenSettled)
return
}
scheduledFrame = null
onChange()
}
const schedule = () => {
mutationVersion += 1
if (scheduledFrame !== null) return
observedVersion = mutationVersion - 1
scheduledFrame = requestFrame(flushWhenSettled)
}
const observer = new MutationObserver((mutations) => {
if (mutations.some(isAnnotationLayoutMutation)) schedule()
})
observer.observe(target, {
attributes: true,
attributeFilter: [
'cx',
'cy',
'd',
'dominant-baseline',
'font-family',
'font-size',
'font-weight',
'height',
'points',
'r',
'rx',
'ry',
'stroke-width',
'text-anchor',
'transform',
'visibility',
'width',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
],
characterData: true,
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (scheduledFrame === null) return
if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
else clearTimeout(scheduledFrame)
}
}
function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
if (mutation.type !== 'attributes') return true
const attribute = mutation.attributeName ?? ''
const target = mutation.target as Element
const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
if (
attribute === 'data-floorplan-annotation-id' ||
attribute === 'data-floorplan-annotation-layout-dx' ||
attribute === 'data-floorplan-annotation-layout-dy' ||
attribute === 'data-floorplan-layout-unresolved'
) {
return false
}
if (
closest?.('[data-floorplan-annotation-label]') &&
(attribute === 'style' || attribute === 'transform')
) {
return false
}
if (
closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
(attribute === 'x1' ||
attribute === 'x2' ||
attribute === 'y1' ||
attribute === 'y2' ||
attribute === 'visibility')
) {
return false
}
return true
}
export function collectAnnotationLayoutPreflightIssues(
rectangles: readonly AnnotationLabelRectangle[],
shifts: readonly AnnotationLabelShift[],
@@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({
/>
) : null}
<g
data-floorplan-annotation-angle-radians={Math.atan2(
geometry.end[1] - geometry.start[1],
geometry.end[0] - geometry.start[0],
)}
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
@@ -429,11 +434,16 @@ export function FloorplanDimensionStringRenderer({
/>
) : null}
<g
data-floorplan-annotation-angle-radians={Math.atan2(
segment.end[1] - segment.start[1],
segment.end[0] - segment.start[0],
)}
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-annotation-transform-before-rotation={`translate(${layout.labelPoint[0]} ${layout.labelPoint[1]})`}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
@@ -487,9 +487,15 @@ function renderNode(
return (
<g
data-floorplan-annotation-angle-radians={g.angle}
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
data-floorplan-annotation-transform-after-rotation={`translate(0 ${
-(g.offsetPx ?? 0) * unitsPerPixel
})`}
data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
key={keyHint}
pointerEvents="none"
transform={labelTransform}
@@ -1,5 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
import {
resolveFloorplanAnnotationLabelTransform,
resolveFloorplanAnnotationUpdate,
resolveFloorplanLabelAngle,
shouldUpdateFloorplanLabelRotation,
updateSvgFloorplanLabelOrientations,
} from './floorplan-label-angle'
describe('resolveFloorplanLabelAngle', () => {
test('keeps segment labels readable while preserving their screen direction', () => {
@@ -12,4 +18,72 @@ describe('resolveFloorplanLabelAngle', () => {
expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
})
test('ignores sub-degree rotation changes for annotation layout', () => {
expect(shouldUpdateFloorplanLabelRotation(30, 30.9)).toBe(false)
expect(shouldUpdateFloorplanLabelRotation(30, 31)).toBe(true)
expect(shouldUpdateFloorplanLabelRotation(359.5, 0.25)).toBe(false)
})
test('updates only label orientation while preserving its layout shift', () => {
expect(
resolveFloorplanAnnotationLabelTransform({
afterRotation: 'translate(0 -0.2)',
angleRadians: 0,
beforeRotation: 'translate(4 6)',
layoutDx: 0.5,
layoutDy: -0.25,
sceneRotationDeg: 90,
screenUpright: true,
}),
).toEqual({
defaultTransform: 'translate(4 6) rotate(-90) translate(0 -0.2)',
transform: 'translate(4 6) rotate(-90) translate(0 -0.2) translate(0.5 -0.25)',
})
})
test('keeps a rotation-only update out of the full collision layout path', () => {
expect(
resolveFloorplanAnnotationUpdate({
layoutInputsChanged: false,
nextRotationDeg: 45,
previousRotationDeg: 30,
}),
).toEqual({
resolveCollisions: false,
updateLabelPresentation: true,
})
})
test('runs collision layout when floor-plan scene inputs change', () => {
expect(
resolveFloorplanAnnotationUpdate({
layoutInputsChanged: true,
nextRotationDeg: 30,
previousRotationDeg: 30,
}),
).toEqual({
resolveCollisions: true,
updateLabelPresentation: true,
})
})
test('updates captured label references without rediscovering the DOM', () => {
const attributes = new Map<string, string>([['transform', 'translate(4 6) rotate(0)']])
const label = {
dataset: {
floorplanAnnotationAngleRadians: '0',
floorplanAnnotationLayoutDx: '0.5',
floorplanAnnotationLayoutDy: '-0.25',
floorplanAnnotationScreenUpright: 'true',
floorplanAnnotationTransformAfterRotation: '',
floorplanAnnotationTransformBeforeRotation: 'translate(4 6)',
},
getAttribute: (name: string) => attributes.get(name) ?? null,
setAttribute: (name: string, value: string) => attributes.set(name, value),
} as unknown as SVGGElement
expect(updateSvgFloorplanLabelOrientations([label], 90)).toBe(1)
expect(attributes.get('transform')).toBe('translate(4 6) rotate(-90) translate(0.5 -0.25)')
})
})
@@ -12,3 +12,95 @@ export function resolveFloorplanLabelAngle(
else if (screenAngleDeg <= -90) localAngleDeg += 180
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
}
export function shouldUpdateFloorplanLabelRotation(
previousRotationDeg: number | null,
nextRotationDeg: number,
minimumDeltaDeg = 1,
): boolean {
if (previousRotationDeg === null) return true
const deltaDeg = ((((nextRotationDeg - previousRotationDeg + 180) % 360) + 360) % 360) - 180
return Math.abs(deltaDeg) >= minimumDeltaDeg
}
export function resolveFloorplanAnnotationUpdate({
layoutInputsChanged,
previousRotationDeg,
nextRotationDeg,
}: {
layoutInputsChanged: boolean
previousRotationDeg: number | null
nextRotationDeg: number
}): {
resolveCollisions: boolean
updateLabelPresentation: boolean
} {
return {
resolveCollisions: layoutInputsChanged,
updateLabelPresentation:
layoutInputsChanged ||
shouldUpdateFloorplanLabelRotation(previousRotationDeg, nextRotationDeg),
}
}
export function resolveFloorplanAnnotationLabelTransform({
angleRadians,
sceneRotationDeg,
screenUpright,
beforeRotation,
afterRotation,
layoutDx = 0,
layoutDy = 0,
}: {
angleRadians: number
sceneRotationDeg: number
screenUpright: boolean
beforeRotation: string
afterRotation: string
layoutDx?: number
layoutDy?: number
}): {
defaultTransform: string
transform: string
} {
const degrees = resolveFloorplanLabelAngle(angleRadians, sceneRotationDeg, screenUpright)
const defaultTransform = [beforeRotation, `rotate(${degrees})`, afterRotation]
.filter(Boolean)
.join(' ')
return {
defaultTransform,
transform:
layoutDx === 0 && layoutDy === 0
? defaultTransform
: `${defaultTransform} translate(${layoutDx} ${layoutDy})`,
}
}
export function updateSvgFloorplanLabelOrientations(
labels: Iterable<SVGGElement>,
sceneRotationDeg: number,
): number {
let updated = 0
for (const label of labels) {
const angleRadians = Number(label.dataset.floorplanAnnotationAngleRadians)
if (!Number.isFinite(angleRadians)) continue
const { defaultTransform, transform } = resolveFloorplanAnnotationLabelTransform({
angleRadians,
sceneRotationDeg,
screenUpright: label.dataset.floorplanAnnotationScreenUpright === 'true',
beforeRotation: label.dataset.floorplanAnnotationTransformBeforeRotation ?? '',
afterRotation: label.dataset.floorplanAnnotationTransformAfterRotation ?? '',
layoutDx: Number(label.dataset.floorplanAnnotationLayoutDx ?? 0),
layoutDy: Number(label.dataset.floorplanAnnotationLayoutDy ?? 0),
})
if (label.getAttribute('transform') === transform) continue
label.dataset.floorplanAnnotationDefaultTransform = defaultTransform
label.setAttribute('transform', transform)
updated += 1
}
return updated
}
@@ -88,17 +88,20 @@ import {
startFloorplanGroupMove,
startFloorplanGroupRotate,
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
import { useFloorplanSceneRotation, useFloorplanStaticRender } from '../floorplan-render-context'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
observeSvgAnnotationLayoutChanges,
resolveSvgAnnotationCollisions,
svgAnnotationLabelId,
} from './floorplan-annotation-layout'
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
import {
resolveFloorplanAnnotationUpdate,
resolveFloorplanLabelAngle,
updateSvgFloorplanLabelOrientations,
} from './floorplan-label-angle'
/**
* Registry-driven floor-plan layer.
@@ -417,7 +420,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const levelId = selectedLevelId ?? ambientLevelId
const isAmbient = !selectedLevelId && !!ambientLevelId
const renderCtx = useFloorplanRender()
const renderCtx = useFloorplanStaticRender()
const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Door / window placement (both build and move) needs the SVG's
@@ -1301,7 +1305,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const entries = floorplanData.entries
if (entries.length === 0) return null
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
const unitsPerPixel = renderCtx?.getUnitsPerPixel() ?? 1
const palette = renderCtx?.palette
return (
@@ -1360,7 +1364,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onHoveredIdChange={setHoveredId}
palette={palette}
pass="base"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
sceneRotationDeg={sceneRotationDeg}
selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
@@ -1414,7 +1418,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onHoveredIdChange={setHoveredId}
palette={palette}
pass="overlay"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
sceneRotationDeg={sceneRotationDeg}
selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
@@ -1446,7 +1450,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<RotationAngleOverlay
overlay={rotationOverlay}
palette={palette}
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
sceneRotationDeg={sceneRotationDeg}
unitsPerPixel={unitsPerPixel}
/>
) : null}
@@ -1456,40 +1460,119 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const markerRef = useRef<SVGGElement>(null)
const [layoutEpoch, setLayoutEpoch] = useState(0)
const collisionLabelElementsRef = useRef<SVGGElement[]>([])
const registryLabelElementsRef = useRef<SVGGElement[]>([])
const appliedRotationDegRef = useRef<number | null>(null)
const appliedLayoutInputsRef = useRef<object | null>(null)
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
const sceneRotationDeg = useFloorplanSceneRotation()
const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0)
const drawingType = useDrawingView((state) => state.drawingType)
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
const wallDimensionReference = useFloorplanAnnotationVisibility(
(state) => state.wallDimensionReference,
)
const layoutInputs = useMemo(
() => ({
annotationVisibility,
annotationLayoutOverrides,
drawingType,
interactionIdle,
settledLayoutEpoch,
wallDimensionReference,
}),
[
annotationVisibility,
annotationLayoutOverrides,
drawingType,
interactionIdle,
settledLayoutEpoch,
wallDimensionReference,
],
)
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
const layoutEnabled = active && interactionIdle
useLayoutEffect(() => {
useEffect(() => {
if (!layoutEnabled) return
const registryLayer = markerRef.current?.parentElement
if (!registryLayer) return
return observeSvgAnnotationLayoutChanges(registryLayer, () => {
setLayoutEpoch((epoch) => epoch + 1)
let frame: number | null = null
const invalidateLayout = () => {
if (frame !== null) return
frame = window.requestAnimationFrame(() => {
frame = null
setSettledLayoutEpoch((epoch) => epoch + 1)
})
}
const unsubscribeScene = useScene.subscribe((state, previousState) => {
if (
state.nodes !== previousState.nodes ||
state.installedPlugins !== previousState.installedPlugins
) {
invalidateLayout()
}
})
const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => {
if (state.transforms !== previousState.transforms) invalidateLayout()
})
const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => {
if (state.overrides !== previousState.overrides) invalidateLayout()
})
const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => {
if (state.elevators !== previousState.elevators) invalidateLayout()
})
return () => {
unsubscribeScene()
unsubscribeTransforms()
unsubscribeOverrides()
unsubscribeInteractive()
if (frame !== null) window.cancelAnimationFrame(frame)
}
}, [layoutEnabled])
useLayoutEffect(() => {
// The epoch is only a trigger; collision inputs are measured from the live SVG below.
void layoutEpoch
// Explicit layout inputs replace the former whole-subtree MutationObserver.
if (!active) {
appliedLayoutInputsRef.current = null
resetPreflightIssues()
return
}
if (!interactionIdle) return
const svg = markerRef.current?.ownerSVGElement
const registryLayer = markerRef.current?.parentElement
if (!(svg && registryLayer)) return
if (!registryLayer) return
const update = resolveFloorplanAnnotationUpdate({
layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs,
nextRotationDeg: sceneRotationDeg,
previousRotationDeg: appliedRotationDegRef.current,
})
if (update.resolveCollisions) {
const svg = markerRef.current?.ownerSVGElement
if (!svg) return
collisionLabelElementsRef.current = Array.from(
svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
registryLayer.contains(label),
)
}
const labels = registryLabelElementsRef.current
if (update.updateLabelPresentation) {
updateSvgFloorplanLabelOrientations(labels, sceneRotationDeg)
appliedRotationDegRef.current = sceneRotationDeg
}
if (!update.resolveCollisions) return
const svg = markerRef.current?.ownerSVGElement
if (!svg) return
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
labels: collisionLabelElementsRef.current,
layoutOverrides: annotationLayoutOverrides,
})
setPreflightIssues(preflightIssues)
appliedLayoutInputsRef.current = layoutInputs
const labels = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
for (const [index, label] of labels.entries()) {
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
@@ -1500,8 +1583,9 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
active,
annotationLayoutOverrides,
interactionIdle,
layoutEpoch,
layoutInputs,
resetPreflightIssues,
sceneRotationDeg,
setPreflightIssues,
])
@@ -1519,9 +1603,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
}
const labelId = (label: SVGGElement): string => {
const labels = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
const labels = registryLabelElementsRef.current
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
}
@@ -1613,9 +1695,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
cancelActivePointerDrag?.()
registryLayer.removeEventListener('pointerdown', onPointerDown)
registryLayer.removeEventListener('dblclick', onDoubleClick)
for (const label of registryLayer.querySelectorAll<SVGGElement>(
'[data-floorplan-annotation-label]',
)) {
for (const label of registryLabelElementsRef.current) {
label.style.pointerEvents = ''
label.style.cursor = ''
}
@@ -2772,9 +2852,15 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
return (
<g
data-floorplan-annotation-angle-radians={g.angle}
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
data-floorplan-annotation-screen-upright={g.screenUpright ? 'true' : undefined}
data-floorplan-annotation-transform-after-rotation={`translate(0 ${
-(g.offsetPx ?? 0) * labelUnitsPerPixel
})`}
data-floorplan-annotation-transform-before-rotation={`translate(${g.cx} ${g.cy})`}
key={keyHint}
pointerEvents="none"
transform={labelTransform}
@@ -29,6 +29,7 @@ import {
withCameraPoseDistance,
} from '../../lib/camera-pose'
import { EDITOR_LAYER } from '../../lib/constants'
import { publishCameraPose } from '../../store/camera-pose-store'
import useEditor from '../../store/use-editor'
import {
useActiveHandleDrag,
@@ -46,14 +47,9 @@ const tempSize = new Vector3()
const tempTarget = new Vector3()
const transitionFreezePosition = new Vector3()
const transitionFreezeTarget = new Vector3()
const syncTarget = new Vector3()
const syncSpherical = new Spherical()
const keyboardPanSpherical = new Spherical()
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
const NAVIGATION_SYNC_POSITION_EPSILON = 0.001
const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005
const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
const KEYBOARD_PAN_MIN_SPEED = 2
const KEYBOARD_PAN_MAX_SPEED = 55
@@ -63,14 +59,6 @@ type CameraPoseSnapshot = {
position: [number, number, number]
target: [number, number, number]
}
type NavigationCameraPoseSnapshot = {
target: [number, number, number]
azimuth: number
viewWidth: number
}
type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & {
publishOnComplete: boolean
}
type CameraViewWidthUpdate =
| { type: 'distance'; distance: number; viewWidth: number }
| { type: 'zoom'; viewWidth: number; zoom: number }
@@ -201,14 +189,6 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo
return Math.max(0.001, distance)
}
function getAngleDeltaRadians(a: number, b: number) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
function nearestEquivalentRadians(angle: number, reference: number) {
return reference + getAngleDeltaRadians(angle, reference)
}
function clampFinite(value: number, min: number, max: number) {
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY
@@ -233,21 +213,6 @@ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) {
)
}
function isCameraAtNavigationPose(
pose: NavigationCameraPoseSnapshot,
target: Vector3,
azimuth: number,
viewWidth: number,
) {
return (
Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
)
}
function getCameraDistanceForViewWidth(
camera: Camera,
viewWidth: number,
@@ -397,7 +362,6 @@ export const CustomCameraControls = () => {
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const selection = useViewer((s) => s.selection)
const cameraMode = useViewer((state) => state.cameraMode)
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
@@ -407,19 +371,8 @@ export const CustomCameraControls = () => {
)
const currentLevelId = selection.levelId
const firstLoad = useRef(true)
const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
const lastApplied2dNavigationRevision = useRef(0)
const savedSmoothTimeRef = useRef<number | null>(null)
const maxPolarAngle =
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
const clearPendingFloorplanNavigationPose = useCallback(() => {
pendingFloorplanNavigationPose.current = null
if (savedSmoothTimeRef.current !== null && controls.current) {
controls.current.smoothTime = savedSmoothTimeRef.current
savedSmoothTimeRef.current = null
}
}, [])
const camera = useThree((state) => state.camera)
const gl = useThree((state) => state.gl)
@@ -514,12 +467,10 @@ export const CustomCameraControls = () => {
}
}
clearPendingFloorplanNavigationPose()
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
}, [
camera,
cancelPoseApplication,
clearPendingFloorplanNavigationPose,
freezeActivePoseInterpolation,
isFirstPersonMode,
viewportSize,
@@ -584,19 +535,11 @@ export const CustomCameraControls = () => {
if (!controls.current) return
if (firstLoad.current) {
firstLoad.current = false
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
}
controls.current.getTarget(currentTarget)
clearPendingFloorplanNavigationPose()
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
}, [
clearPendingFloorplanNavigationPose,
currentLevelId,
isPreviewMode,
isFirstPersonMode,
isRestoringFirstPersonPose,
])
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
useEffect(() => {
if (isFirstPersonMode || !controls.current) return
@@ -624,7 +567,6 @@ export const CustomCameraControls = () => {
controls.current.getTarget(tempTarget)
tempDelta.copy(tempCenter).sub(tempTarget)
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(
tempPosition.x + tempDelta.x,
tempPosition.y + tempDelta.y,
@@ -635,106 +577,9 @@ export const CustomCameraControls = () => {
true,
)
},
[clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode],
[isPreviewMode, isFirstPersonMode],
)
useEffect(() => {
if (isFirstPersonMode) return
return useEditor.subscribe((state) => {
const pose = state.navigationSyncPose
if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
const control = controls.current
if (!control) return
lastApplied2dNavigationRevision.current = pose.revision
const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle)
const viewWidthUpdate = resolveCameraViewWidthUpdate(
control,
camera,
pose.viewWidth,
viewportSize,
)
pendingFloorplanNavigationPose.current = {
target: [...pose.target],
azimuth: targetAzimuth,
viewWidth: viewWidthUpdate.viewWidth,
publishOnComplete:
Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
}
// Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
// effective time constant is smoothTime/2, so smoothTime=0.18 gives
// τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
if (savedSmoothTimeRef.current === null) {
savedSmoothTimeRef.current = control.smoothTime
}
control.smoothTime = 0.18
control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
control.rotateTo(targetAzimuth, control.polarAngle, true)
applyCameraViewWidth(control, viewWidthUpdate)
})
}, [camera, isFirstPersonMode, viewportSize])
const publishCurrentNavigationPose = useCallback(() => {
if (isFirstPersonMode || !controls.current) return
controls.current.getTarget(syncTarget, false)
controls.current.getSpherical(syncSpherical, false)
const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize)
const pendingFloorplanPose = pendingFloorplanNavigationPose.current
if (pendingFloorplanPose) {
// The camera is still damping toward a 2D-originated pose; do not echo
// intermediate 3D poses back into the floorplan.
if (
isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
) {
lastPublishedNavigationSync.current = pendingFloorplanPose
clearPendingFloorplanNavigationPose()
if (pendingFloorplanPose.publishOnComplete) {
useEditor.getState().publishNavigationSyncPose({
source: '3d',
target: [
pendingFloorplanPose.target[0],
pendingFloorplanPose.target[1],
pendingFloorplanPose.target[2],
],
azimuth: pendingFloorplanPose.azimuth,
viewWidth: pendingFloorplanPose.viewWidth,
})
}
}
return
}
const previous = lastPublishedNavigationSync.current
if (
previous &&
Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON &&
Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) <
NAVIGATION_SYNC_AZIMUTH_EPSILON &&
Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON
) {
return
}
lastPublishedNavigationSync.current = {
target: [syncTarget.x, syncTarget.y, syncTarget.z],
azimuth: syncSpherical.theta,
viewWidth,
}
useEditor.getState().publishNavigationSyncPose({
source: '3d',
target: [syncTarget.x, syncTarget.y, syncTarget.z],
azimuth: syncSpherical.theta,
viewWidth,
})
}, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
const publishCurrentPose = useCallback(() => {
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
@@ -756,27 +601,13 @@ export const CustomCameraControls = () => {
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
})
if (pose) {
emitter.emit('camera-controls:pose', pose)
publishCameraPose(pose)
}
}, [camera, isFirstPersonMode, viewportSize])
const handleCameraUpdate = useCallback(() => {
publishCurrentNavigationPose()
publishCurrentPose()
}, [publishCurrentNavigationPose, publishCurrentPose])
useEffect(() => {
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
const frame = requestAnimationFrame(() => {
lastPublishedNavigationSync.current = null
publishCurrentNavigationPose()
})
return () => {
cancelAnimationFrame(frame)
}
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
}, [publishCurrentPose])
useFrame((_, delta) => {
if (isFirstPersonMode || !controls.current) return
@@ -839,7 +670,6 @@ export const CustomCameraControls = () => {
)
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
clearPendingFloorplanNavigationPose()
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
if (vertical !== 0) control.forward(vertical * step, true)
}, 0)
@@ -992,7 +822,6 @@ export const CustomCameraControls = () => {
) {
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
if (changed) beginLocalCameraInteraction()
clearPendingFloorplanNavigationPose()
event.preventDefault()
event.stopPropagation()
}
@@ -1058,7 +887,6 @@ export const CustomCameraControls = () => {
const onPointerDown = (event: PointerEvent) => {
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
clearPendingFloorplanNavigationPose()
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
panPointerId = event.pointerId
@@ -1069,7 +897,6 @@ export const CustomCameraControls = () => {
const onWheel = () => {
beginLocalCameraInteraction()
cameraDraggingLifecycle.scheduleEnd()
clearPendingFloorplanNavigationPose()
}
const onPointerUp = (event: PointerEvent) => {
@@ -1120,25 +947,18 @@ export const CustomCameraControls = () => {
gl,
isPreviewMode,
isFirstPersonMode,
clearPendingFloorplanNavigationPose,
])
// Cancel any in-progress 2D-origin navigation pose when the user starts
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
// fires only for user pointer interactions — not for programmatic
// moveTo/rotateTo which emit `transitionstart` instead. It also fires for
// pointerdowns whose button is mapped to ACTION.NONE (plain left click in
// edit mode); those must not flag the camera as dragging — no rest/sleep
// ever follows to clear the flag, which would leave canvas clicks
// (selection, placement) suppressed until the next real camera move.
// `controlstart` fires only for user pointer interactions. Pointerdowns
// mapped to ACTION.NONE must not flag the camera as dragging because no
// rest/sleep event follows to clear the flag.
const handleControlStart = useCallback(() => {
clearPendingFloorplanNavigationPose()
beginLocalCameraInteraction({
dragging: controls.current
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
: false,
})
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
}, [beginLocalCameraInteraction])
// Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode
@@ -1345,7 +1165,6 @@ export const CustomCameraControls = () => {
if (!node?.camera) return
const { position, target } = node.camera
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(
position[0],
position[1],
@@ -1366,7 +1185,6 @@ export const CustomCameraControls = () => {
// Otherwise, go to top view (0°)
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
clearPendingFloorplanNavigationPose()
controls.current.rotatePolarTo(targetAngle, true)
}
@@ -1379,7 +1197,6 @@ export const CustomCameraControls = () => {
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded - Math.PI / 2
clearPendingFloorplanNavigationPose()
controls.current.rotateTo(target, currentPolar, true)
}
@@ -1392,7 +1209,6 @@ export const CustomCameraControls = () => {
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
const target = rounded + Math.PI / 2
clearPendingFloorplanNavigationPose()
controls.current.rotateTo(target, currentPolar, true)
}
@@ -1404,7 +1220,6 @@ export const CustomCameraControls = () => {
if (isFirstPersonMode || !controls.current || isPreviewMode) return
if (!bounds) {
// Restore default framing pose when no bounds were computed.
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
return
}
@@ -1415,7 +1230,6 @@ export const CustomCameraControls = () => {
const maxExtent = Math.max(w, d)
const distance = Math.max(maxExtent * 1.4, 15)
const height = Math.max(maxExtent * 0.8, 10)
clearPendingFloorplanNavigationPose()
controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true)
}
@@ -1436,7 +1250,7 @@ export const CustomCameraControls = () => {
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
emitter.off('camera-controls:fit-scene', handleFitScene)
}
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
}, [focusNode, isPreviewMode, isFirstPersonMode])
const onTransitionStart = useCallback(() => {
cameraDraggingLifecycle.begin()
@@ -0,0 +1,217 @@
import { describe, expect, test } from 'bun:test'
import type { CameraPose } from '@pascal-app/core'
import type { NavigationSyncPose } from '../../store/use-editor'
import {
cameraPoseToFloorplanNavigationPose,
createFloorplanCameraNavigationChannel,
createFloorplanCameraSyncBridge,
floorplanNavigationPoseToCameraPose,
} from './floorplan-camera-sync'
const cameraPose: CameraPose = {
fov: 50,
position: [3, 4, 4],
projection: 'perspective',
target: [0, 1, 0],
viewWidth: 12,
}
function cameraPoseAtAzimuth(
azimuth: number,
target: [number, number, number] = [0, 1, 0],
): CameraPose {
const horizontalDistance = 5
return {
...cameraPose,
position: [
target[0] + Math.sin(azimuth) * horizontalDistance,
4,
target[2] + Math.cos(azimuth) * horizontalDistance,
],
target,
}
}
describe('floorplan camera sync', () => {
test('derives the floor-plan navigation pose from the generic camera pose', () => {
expect(
cameraPoseToFloorplanNavigationPose({
position: [10, 5, 0],
projection: 'perspective',
target: [0, 0, 0],
viewWidth: 20,
}),
).toEqual({
source: '3d',
target: [0, 0, 0],
azimuth: Math.PI / 2,
viewWidth: 20,
})
})
test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => {
expect(
floorplanNavigationPoseToCameraPose(
{
source: '2d',
revision: 4,
target: [10, 2, 20],
azimuth: Math.PI / 2,
viewWidth: 8,
},
cameraPose,
),
).toEqual({
fov: 50,
position: [15, 5, 20],
projection: 'perspective',
target: [10, 2, 20],
viewWidth: 8,
})
})
test('owns two-way synchronization without echoing tiny camera changes', () => {
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
const applied: CameraPose[] = []
const bridge = createFloorplanCameraSyncBridge({
applyCameraPose: (pose) => applied.push(pose),
publishNavigationPose: (pose) => published.push(pose),
})
bridge.receiveCameraPose(cameraPose)
bridge.receiveCameraPose({
...cameraPose,
position: [3.0001, 4, 4],
})
bridge.receiveNavigationPose({
source: '2d',
revision: 1,
target: [10, 2, 20],
azimuth: Math.PI / 2,
viewWidth: 8,
})
expect(published).toHaveLength(1)
expect(published[0]?.source).toBe('3d')
expect(applied).toHaveLength(1)
expect(applied[0]).toMatchObject({
fov: 50,
projection: 'perspective',
target: [10, 2, 20],
viewWidth: 8,
})
expect(applied[0]?.position[0]).toBeCloseTo(15)
expect(applied[0]?.position[1]).toBeCloseTo(5)
expect(applied[0]?.position[2]).toBeCloseTo(20)
})
test('does not publish floor-plan rotation below one degree', () => {
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
const bridge = createFloorplanCameraSyncBridge({
applyCameraPose: () => {},
publishNavigationPose: (pose) => published.push(pose),
})
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180))
expect(published).toHaveLength(1)
bridge.receiveCameraPose(cameraPoseAtAzimuth(Math.PI / 180))
expect(published).toHaveLength(2)
expect(published[1]?.azimuth).toBeCloseTo(Math.PI / 180)
})
test('keeps the previous floor-plan angle when a sub-degree rotation arrives with a pan', () => {
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
const bridge = createFloorplanCameraSyncBridge({
applyCameraPose: () => {},
publishNavigationPose: (pose) => published.push(pose),
})
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0]))
expect(published).toHaveLength(2)
expect(published[1]).toMatchObject({
target: [1, 1, 0],
azimuth: 0,
})
})
test('does no floor-plan synchronization in 3D-only mode and catches up once when visible', () => {
const published: Array<Omit<NavigationSyncPose, 'revision'>> = []
const applied: CameraPose[] = []
const bridge = createFloorplanCameraSyncBridge({
active: false,
applyCameraPose: (pose) => applied.push(pose),
publishNavigationPose: (pose) => published.push(pose),
})
const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4])
bridge.receiveCameraPose(cameraPoseAtAzimuth(0))
bridge.receiveCameraPose(latestPose)
bridge.receiveNavigationPose({
source: '2d',
revision: 1,
target: [10, 2, 20],
azimuth: Math.PI / 2,
viewWidth: 8,
})
expect(published).toEqual([])
expect(applied).toEqual([])
bridge.setActive(true)
expect(published).toEqual([
{
source: '3d',
target: latestPose.target,
azimuth: Math.PI / 2,
viewWidth: 12,
},
])
expect(applied).toEqual([])
})
test('waits for a generic camera reference before applying an early 2D pose', () => {
const applied: CameraPose[] = []
const bridge = createFloorplanCameraSyncBridge({
applyCameraPose: (pose) => applied.push(pose),
publishNavigationPose: () => {},
})
bridge.receiveNavigationPose({
source: '2d',
revision: 1,
target: [10, 2, 20],
azimuth: Math.PI / 2,
viewWidth: 8,
})
expect(applied).toEqual([])
bridge.receiveCameraPose(cameraPose)
expect(applied).toHaveLength(1)
})
test('delivers the live camera stream through transient subscribers', () => {
const channel = createFloorplanCameraNavigationChannel()
const received: NavigationSyncPose[] = []
const unsubscribe = channel.subscribe((pose) => received.push(pose))
const input = {
source: '3d' as const,
target: [0, 1, 2] as [number, number, number],
azimuth: 0.5,
viewWidth: 12,
}
channel.publish(input)
channel.publish({ ...input, azimuth: 0.75 })
unsubscribe()
channel.publish({ ...input, azimuth: 1 })
expect(received).toEqual([
{ ...input, revision: 1 },
{ ...input, azimuth: 0.75, revision: 2 },
])
})
})
@@ -0,0 +1,228 @@
'use client'
import { type CameraPose, emitter } from '@pascal-app/core'
import { useEffect, useRef } from 'react'
import { subscribeCameraPose } from '../../store/camera-pose-store'
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
import useEditor, {
type NavigationSyncPose,
type NavigationSyncPoseInput,
} from '../../store/use-editor'
const POSITION_EPSILON = 0.001
const AZIMUTH_EPSILON = Math.PI / 180
const VIEW_WIDTH_EPSILON = 0.001
type FloorplanNavigationSnapshot = Omit<NavigationSyncPoseInput, 'source'>
function angleDeltaRadians(a: number, b: number) {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
function normalizeNearZero(value: number) {
return Math.abs(value) < Number.EPSILON * 10 ? 0 : value
}
function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot {
return {
target: [...pose.target],
azimuth: pose.azimuth,
viewWidth: pose.viewWidth,
}
}
function navigationSnapshotsEqual(
previous: FloorplanNavigationSnapshot,
next: FloorplanNavigationSnapshot,
) {
return (
Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON &&
Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON &&
Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON &&
Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON &&
Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON
)
}
export function cameraPoseToFloorplanNavigationPose(
pose: CameraPose,
): NavigationSyncPoseInput | null {
if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) {
return null
}
return {
source: '3d',
target: [...pose.target],
azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]),
viewWidth: pose.viewWidth,
}
}
export function floorplanNavigationPoseToCameraPose(
navigationPose: NavigationSyncPose,
cameraPose: CameraPose,
): CameraPose {
const offsetX = cameraPose.position[0] - cameraPose.target[0]
const offsetY = cameraPose.position[1] - cameraPose.target[1]
const offsetZ = cameraPose.position[2] - cameraPose.target[2]
const horizontalDistance = Math.hypot(offsetX, offsetZ)
const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance)
const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance)
const target: [number, number, number] = [...navigationPose.target]
return {
...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }),
position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ],
projection: cameraPose.projection,
target,
viewWidth: navigationPose.viewWidth,
}
}
export type FloorplanCameraSyncBridge = {
receiveCameraPose: (pose: CameraPose) => void
receiveNavigationPose: (pose: NavigationSyncPose | null) => void
setActive: (active: boolean) => void
}
export type FloorplanCameraNavigationChannel = {
publish: (pose: NavigationSyncPoseInput) => void
subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void
}
export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel {
const listeners = new Set<(pose: NavigationSyncPose) => void>()
let revision = 0
return {
publish: (pose) => {
revision += 1
const revisedPose = { ...pose, revision }
for (const listener of listeners) {
listener(revisedPose)
}
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
const liveCameraNavigation = createFloorplanCameraNavigationChannel()
export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) {
return liveCameraNavigation.subscribe(listener)
}
export function createFloorplanCameraSyncBridge({
active: initialActive = true,
applyCameraPose,
publishNavigationPose,
}: {
active?: boolean
applyCameraPose: (pose: CameraPose) => void
publishNavigationPose: (pose: NavigationSyncPoseInput) => void
}): FloorplanCameraSyncBridge {
let active = initialActive
let latestCameraPose: CameraPose | null = null
let pendingNavigationPose: NavigationSyncPose | null = null
let lastAppliedNavigationRevision = 0
let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null
const applyPendingNavigationPose = () => {
if (!(latestCameraPose && pendingNavigationPose)) return false
if (pendingNavigationPose.revision === lastAppliedNavigationRevision) {
pendingNavigationPose = null
return false
}
const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose)
const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose)
latestCameraPose = appliedPose
lastAppliedNavigationRevision = pendingNavigationPose.revision
pendingNavigationPose = null
if (appliedNavigationPose) {
lastPublishedNavigation = navigationSnapshot(appliedNavigationPose)
}
applyCameraPose(appliedPose)
return true
}
const publishCameraNavigationPose = (pose: CameraPose) => {
let navigationPose = cameraPoseToFloorplanNavigationPose(pose)
if (!navigationPose) return
if (
lastPublishedNavigation &&
Math.abs(angleDeltaRadians(lastPublishedNavigation.azimuth, navigationPose.azimuth)) <
AZIMUTH_EPSILON
) {
navigationPose = {
...navigationPose,
azimuth: lastPublishedNavigation.azimuth,
}
}
const nextSnapshot = navigationSnapshot(navigationPose)
if (
lastPublishedNavigation &&
navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot)
) {
return
}
lastPublishedNavigation = nextSnapshot
publishNavigationPose(navigationPose)
}
return {
receiveCameraPose: (pose) => {
latestCameraPose = pose
if (!active) return
if (applyPendingNavigationPose()) return
publishCameraNavigationPose(pose)
},
receiveNavigationPose: (pose) => {
if (
!active ||
pose?.source !== '2d' ||
pose.revision === lastAppliedNavigationRevision ||
pose.revision === pendingNavigationPose?.revision
) {
return
}
pendingNavigationPose = pose
applyPendingNavigationPose()
},
setActive: (nextActive) => {
if (active === nextActive) return
active = nextActive
if (!active) {
pendingNavigationPose = null
return
}
if (latestCameraPose) publishCameraNavigationPose(latestCameraPose)
},
}
}
export function useFloorplanCameraSyncBridge() {
const active = useEditor((state) => state.viewMode !== '3d')
const bridgeRef = useRef<FloorplanCameraSyncBridge | null>(null)
if (!bridgeRef.current) {
bridgeRef.current = createFloorplanCameraSyncBridge({
active,
applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose),
publishNavigationPose: (pose) => liveCameraNavigation.publish(pose),
})
}
const bridge = bridgeRef.current
useEffect(() => bridge.setActive(active), [active, bridge])
useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge])
useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge])
}
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import {
canApplyFloorplanNavigationSync,
canZoomFloorplanDuringNavigation,
createFloorplanNavigationSyncScheduler,
finalizeFloorplanNavigation,
resolveFloorplanPresentationViewBox,
} from './floorplan-navigation-presentation'
@@ -44,4 +45,32 @@ describe('floorplan navigation presentation', () => {
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
})
test('coalesces a camera pose stream into one settled React commit', () => {
const presented: number[] = []
const committed: number[] = []
let scheduled: (() => void) | null = null
const scheduler = createFloorplanNavigationSyncScheduler<number>({
applyPresentation: (pose) => presented.push(pose),
commit: (pose) => committed.push(pose),
schedule: (callback) => {
scheduled = callback
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>
},
cancel: () => {
scheduled = null
},
})
for (let pose = 1; pose <= 30; pose += 1) {
scheduler.update(pose)
}
expect(presented).toHaveLength(30)
expect(committed).toEqual([])
const settle = scheduled as (() => void) | null
settle?.()
expect(committed).toEqual([30])
})
})
@@ -21,6 +21,63 @@ export function canApplyFloorplanNavigationSync(interactionInProgress: boolean):
return !interactionInProgress
}
export type FloorplanNavigationSyncScheduler<Pose> = {
update: (pose: Pose) => void
flush: () => void
discard: () => void
}
export function createFloorplanNavigationSyncScheduler<Pose>({
applyPresentation,
commit,
settleMs = 120,
schedule = globalThis.setTimeout,
cancel = globalThis.clearTimeout,
}: {
applyPresentation: (pose: Pose) => void
commit: (pose: Pose) => void
settleMs?: number
schedule?: (callback: () => void, delay: number) => ReturnType<typeof globalThis.setTimeout>
cancel?: (timer: ReturnType<typeof globalThis.setTimeout>) => void
}): FloorplanNavigationSyncScheduler<Pose> {
let latestPose: Pose | null = null
let settleTimer: ReturnType<typeof globalThis.setTimeout> | null = null
const clearSettleTimer = () => {
if (settleTimer === null) return
cancel(settleTimer)
settleTimer = null
}
const flush = () => {
clearSettleTimer()
if (latestPose === null) return
const pose = latestPose
latestPose = null
commit(pose)
}
const update = (pose: Pose) => {
latestPose = pose
applyPresentation(pose)
clearSettleTimer()
settleTimer = schedule(() => {
settleTimer = null
if (latestPose === null) return
const settledPose = latestPose
latestPose = null
commit(settledPose)
}, settleMs)
}
const discard = () => {
clearSettleTimer()
latestPose = null
}
return { update, flush, discard }
}
export function finalizeFloorplanNavigation<RotationState>({
zoomPending,
panActive,
@@ -95,6 +95,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
import useAlignmentGuides from '../../store/use-alignment-guides'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, {
@@ -191,9 +192,15 @@ import {
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
import {
subscribeFloorplanCameraNavigation,
useFloorplanCameraSyncBridge,
} from './floorplan-camera-sync'
import {
canApplyFloorplanNavigationSync,
canZoomFloorplanDuringNavigation,
createFloorplanNavigationSyncScheduler,
type FloorplanNavigationSyncScheduler,
type FloorplanPresentationViewBox,
finalizeFloorplanNavigation,
resolveFloorplanPresentationViewBox,
@@ -337,12 +344,34 @@ type FloorplanRotationState = {
latestViewport: FloorplanViewport
}
type FloorplanNavigationSyncPresentationState = {
initialUserRotationDeg: number
initialSceneRotationDeg: number
latestUserRotationDeg: number
latestSceneRotationDeg: number
latestViewport: FloorplanViewport
svg: SVGSVGElement
svgStyle: {
transform: string
transformOrigin: string
willChange: string
}
}
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
}
function restoreFloorplanNavigationSyncPresentation(
presentationState: FloorplanNavigationSyncPresentationState,
) {
presentationState.svg.style.transform = presentationState.svgStyle.transform
presentationState.svg.style.transformOrigin = presentationState.svgStyle.transformOrigin
presentationState.svg.style.willChange = presentationState.svgStyle.willChange
}
type FloorplanScreenSelectionState = {
pointerId: number
startClientX: number
@@ -5306,6 +5335,7 @@ export function FloorplanPanel({
compassHost?: HTMLElement | null
floorplanSceneSlot?: ReactNode
}) {
useFloorplanCameraSyncBridge()
const viewportHostRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const floorplanBackgroundRef = useRef<SVGRectElement>(null)
@@ -5340,6 +5370,16 @@ export function FloorplanPanel({
const floorplanRenderScaleCommitTimerRef = useRef<number | null>(null)
const floorplanViewportInteractionInProgressRef = useRef(false)
const floorplanImperativeViewBoxRef = useRef<FloorplanPresentationViewBox | null>(null)
const floorplanNavigationSyncPresentationRef =
useRef<FloorplanNavigationSyncPresentationState | null>(null)
const applyFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>(
() => {},
)
const commitFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>(
() => {},
)
const floorplanNavigationSyncSchedulerRef =
useRef<FloorplanNavigationSyncScheduler<NavigationSyncPose> | null>(null)
const latestFloorplanRenderUnitsPerPixelRef = useRef(1)
const floorplanZoomPoseRef = useRef<{
localCenter: SvgPoint
@@ -5456,6 +5496,11 @@ export function FloorplanPanel({
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
const floorplanSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
const getFloorplanSceneRotationDeg = useCallback(
() =>
FLOORPLAN_VIEW_ROTATION_DEG + latestFloorplanUserRotationDegRef.current - buildingRotationDeg,
[buildingRotationDeg],
)
// Only sync ref from state when floorplan is open (state is source of truth).
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) {
@@ -6561,6 +6606,33 @@ export function FloorplanPanel({
// so it re-renders per move without re-rendering this panel.
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
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 fittedViewport = useMemo(() => {
// Collect bounds from the legacy polygon arrays first. Most are empty
@@ -6733,6 +6805,136 @@ export function FloorplanPanel({
[],
)
const applyFloorplanNavigationSyncPresentation = useCallback(
(pose: NavigationSyncPose) => {
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
const svg = svgRef.current
if (!(currentViewport && svg)) return
let presentationState = floorplanNavigationSyncPresentationRef.current
if (!presentationState) {
const initialUserRotationDeg = latestFloorplanUserRotationDegRef.current
const initialSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + initialUserRotationDeg - buildingRotationDeg
presentationState = {
initialUserRotationDeg,
initialSceneRotationDeg,
latestUserRotationDeg: initialUserRotationDeg,
latestSceneRotationDeg: initialSceneRotationDeg,
latestViewport: currentViewport,
svg,
svgStyle: {
transform: svg.style.transform,
transformOrigin: svg.style.transformOrigin,
willChange: svg.style.willChange,
},
}
floorplanNavigationSyncPresentationRef.current = presentationState
floorplanViewportInteractionInProgressRef.current = true
svg.style.transformOrigin = 'center'
svg.style.willChange = 'transform'
}
const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(
pose.azimuth,
presentationState.latestUserRotationDeg,
)
const localCenter = worldToFloorplanLocalPoint(
pose.target[0],
pose.target[2],
buildingPosition,
buildingRotationY,
)
const nextWidth = resolveFloorplanViewWidth(
pose.viewWidth,
currentViewport.width,
latestFittedViewportRef.current,
false,
)
const nextSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg
const nextCenterSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg)
const nextViewport = {
centerX: nextCenterSvg.x,
centerY: nextCenterSvg.y,
width: nextWidth,
}
const presentationCenterSvg = rotateSvgPoint(
localCenter,
presentationState.initialSceneRotationDeg,
)
applyFloorplanViewportImperatively({
centerX: presentationCenterSvg.x,
centerY: presentationCenterSvg.y,
width: nextWidth,
})
svg.style.transform = `rotate(${
nextUserRotationDeg - presentationState.initialUserRotationDeg
}deg)`
latestFloorplanUserRotationDegRef.current = nextUserRotationDeg
latestViewportRef.current = nextViewport
presentationState.latestUserRotationDeg = nextUserRotationDeg
presentationState.latestSceneRotationDeg = nextSceneRotationDeg
presentationState.latestViewport = nextViewport
},
[applyFloorplanViewportImperatively, buildingPosition, buildingRotationDeg, buildingRotationY],
)
const commitFloorplanNavigationSyncPresentation = useCallback(
(_pose: NavigationSyncPose) => {
const presentationState = floorplanNavigationSyncPresentationRef.current
if (!presentationState) return
applyFloorplanViewportImperatively(presentationState.latestViewport)
const scene = floorplanSceneRef.current
if (scene) {
if (presentationState.latestSceneRotationDeg === 0) {
scene.removeAttribute('transform')
} else {
scene.setAttribute('transform', `rotate(${presentationState.latestSceneRotationDeg})`)
}
}
restoreFloorplanNavigationSyncPresentation(presentationState)
floorplanNavigationSyncPresentationRef.current = null
floorplanViewportInteractionInProgressRef.current = false
floorplanImperativeViewBoxRef.current = null
setFloorplanUserRotationDeg((current) =>
current === presentationState.latestUserRotationDeg
? current
: presentationState.latestUserRotationDeg,
)
setViewport((current) =>
floorplanViewportEquals(current, presentationState.latestViewport)
? current
: presentationState.latestViewport,
)
},
[applyFloorplanViewportImperatively],
)
applyFloorplanNavigationSyncPresentationRef.current = applyFloorplanNavigationSyncPresentation
commitFloorplanNavigationSyncPresentationRef.current = commitFloorplanNavigationSyncPresentation
if (!floorplanNavigationSyncSchedulerRef.current) {
floorplanNavigationSyncSchedulerRef.current = createFloorplanNavigationSyncScheduler({
applyPresentation: (pose: NavigationSyncPose) =>
applyFloorplanNavigationSyncPresentationRef.current(pose),
commit: (pose: NavigationSyncPose) =>
commitFloorplanNavigationSyncPresentationRef.current(pose),
})
}
const floorplanNavigationSyncScheduler = floorplanNavigationSyncSchedulerRef.current
const discardFloorplanNavigationSyncPresentation = useCallback(() => {
floorplanNavigationSyncScheduler.discard()
const presentationState = floorplanNavigationSyncPresentationRef.current
if (presentationState) {
restoreFloorplanNavigationSyncPresentation(presentationState)
floorplanNavigationSyncPresentationRef.current = null
}
floorplanViewportInteractionInProgressRef.current = false
floorplanImperativeViewBoxRef.current = null
}, [floorplanNavigationSyncScheduler])
const stopFloorplanViewAnimation = useCallback(() => {
if (floorplanViewAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current)
@@ -6862,39 +7064,26 @@ export function FloorplanPanel({
const syncFloorplanViewportToNavigationPose = useCallback(
(pose: NavigationSyncPose) => {
// Skip the viewport sync while the 2D panel is hidden (3D mode). It writes
// React state (`setViewport`) that re-renders the whole floorplan SVG, so
// doing it every camera-zoom frame for an invisible panel was a needless
// per-frame stall. The catch-up effect below re-syncs on reopen.
// The transient camera stream owns refs + imperative SVG presentation.
// React state is committed only by the scheduler after the stream settles.
if (!isFloorplanOpenRef.current) {
return
}
if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) {
const localNavigationInProgress =
floorplanViewportInteractionInProgressRef.current &&
floorplanNavigationSyncPresentationRef.current === null
if (!canApplyFloorplanNavigationSync(localNavigationInProgress)) {
return
}
const nextUserRotationDeg = floorplanRotationFromCameraAzimuth(
pose.azimuth,
latestFloorplanUserRotationDegRef.current,
)
const localCenter = worldToFloorplanLocalPoint(
pose.target[0],
pose.target[2],
buildingPosition,
buildingRotationY,
)
applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth, {
clampViewWidth: false,
})
floorplanNavigationSyncScheduler.update(pose)
},
[applyFloorplanNavigationView, buildingPosition, buildingRotationY],
[floorplanNavigationSyncScheduler],
)
useEffect(() => {
if (!isFloorplanOpen) return
const pose = useEditor.getState().navigationSyncPose
const pose = latestNavigationSyncPoseRef.current
if (!pose) {
return
}
@@ -6905,6 +7094,18 @@ export function FloorplanPanel({
}
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
useEffect(() => {
if (isFloorplanOpen) return
discardFloorplanNavigationSyncPresentation()
}, [discardFloorplanNavigationSyncPresentation, isFloorplanOpen])
useEffect(
() => () => {
discardFloorplanNavigationSyncPresentation()
},
[discardFloorplanNavigationSyncPresentation],
)
const cancelHiddenCompassAnimation = useCallback(() => {
if (hiddenCompassAnimationRef.current !== null) {
cancelAnimationFrame(hiddenCompassAnimationRef.current)
@@ -6912,11 +7113,9 @@ export function FloorplanPanel({
}
}, [])
// Align-north while the panel is hidden publishes a single '2d' pose that
// the 3D camera applies through the echo-suppressed pending-pose path — it
// never publishes '3d' frames back, so the needle must animate itself.
// Same time constant as the 2D view animation and the camera's effective
// smoothTime, so all three stay visually in step.
// Align-north while the panel is hidden publishes one 2D pose through the
// generic camera bridge. Camera application suppresses its own pose stream,
// so the needle animates itself with the same time constant.
const animateHiddenCompassNeedle = useCallback(
(targetDeg: number) => {
cancelHiddenCompassAnimation()
@@ -6947,10 +7146,10 @@ export function FloorplanPanel({
[cancelHiddenCompassAnimation],
)
useEffect(() => {
const unsubscribe = useEditor.subscribe((state) => {
const pose = state.navigationSyncPose
if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
const receiveFloorplanNavigationPose = useCallback(
(pose: NavigationSyncPose) => {
const previousPose = latestNavigationSyncPoseRef.current
if (previousPose?.source === pose.source && previousPose.revision === pose.revision) {
return
}
@@ -6980,16 +7179,34 @@ export function FloorplanPanel({
if (pose.source === '3d') {
syncFloorplanViewportToNavigationPose(pose)
}
})
return () => {
unsubscribe()
cancelHiddenCompassAnimation()
}
}, [
syncFloorplanViewportToNavigationPose,
},
[
animateHiddenCompassNeedle,
cancelHiddenCompassAnimation,
])
syncFloorplanViewportToNavigationPose,
],
)
useEffect(
() => subscribeFloorplanCameraNavigation(receiveFloorplanNavigationPose),
[receiveFloorplanNavigationPose],
)
useEffect(() => {
const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => {
if (pose?.source === '2d') {
receiveFloorplanNavigationPose(pose)
}
}
return subscribeNavigationSyncPose(receiveStoredNavigationPose)
}, [receiveFloorplanNavigationPose])
useEffect(
() => () => {
cancelHiddenCompassAnimation()
},
[cancelHiddenCompassAnimation],
)
// When the panel is hidden the imperative path owns the compass needle.
// React re-renders can overwrite the needle's inline transform with stale
@@ -7945,34 +8162,6 @@ export function FloorplanPanel({
[beginPanelInteraction, panelRect],
)
const applyFloorplanViewportImperatively = useCallback(
(nextViewport: FloorplanViewport) => {
const nextHeight = nextViewport.width / svgAspectRatio
const nextMinX = nextViewport.centerX - nextViewport.width / 2
const nextMinY = nextViewport.centerY - nextHeight / 2
floorplanImperativeViewBoxRef.current = {
minX: nextMinX,
minY: nextMinY,
width: nextViewport.width,
height: nextHeight,
}
hasUserAdjustedViewportRef.current = true
latestViewportRef.current = nextViewport
svgRef.current?.setAttribute(
'viewBox',
`${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`,
)
const background = floorplanBackgroundRef.current
if (background) {
background.setAttribute('x', String(nextMinX))
background.setAttribute('y', String(nextMinY))
background.setAttribute('width', String(nextViewport.width))
background.setAttribute('height', String(nextHeight))
}
},
[svgAspectRatio],
)
const applyFloorplanRotationImperatively = useCallback(
(rotationState: FloorplanRotationState, nextUserRotationDeg: number) => {
const currentViewport = latestViewportRef.current ?? rotationState.latestViewport
@@ -8056,7 +8245,11 @@ export function FloorplanPanel({
if (!localPoint) {
return
}
const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg)
const currentSceneRotationDeg =
FLOORPLAN_VIEW_ROTATION_DEG +
latestFloorplanUserRotationDegRef.current -
buildingRotationDeg
const svgPoint = rotateSvgPoint(localPoint, currentSceneRotationDeg)
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
if (!currentViewport) {
@@ -8085,7 +8278,7 @@ export function FloorplanPanel({
x: nextMinX + nextWidth / 2,
y: nextMinY + nextHeight / 2,
}
const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg)
const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
const nextViewport = {
centerX: nextCenterSvg.x,
centerY: nextCenterSvg.y,
@@ -8108,7 +8301,7 @@ export function FloorplanPanel({
},
[
applyFloorplanViewportImperatively,
floorplanSceneRotationDeg,
buildingRotationDeg,
getSvgPointFromClientPoint,
publishFloorplanNavigationPose,
scheduleFloorplanZoomCommit,
@@ -9177,6 +9370,7 @@ export function FloorplanPanel({
event.preventDefault()
event.stopPropagation()
floorplanNavigationSyncScheduler.flush()
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
stopFloorplanViewAnimation()
floorplanNavigationClickSuppressedRef.current = true
@@ -9207,6 +9401,7 @@ export function FloorplanPanel({
event.preventDefault()
event.stopPropagation()
floorplanNavigationSyncScheduler.flush()
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
stopFloorplanViewAnimation()
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
@@ -9246,6 +9441,7 @@ export function FloorplanPanel({
[
commitFloorplanZoom,
buildingRotationDeg,
floorplanNavigationSyncScheduler,
setFloorplanCursorPosition,
setCursorPoint,
stopFloorplanViewAnimation,
@@ -11318,11 +11514,13 @@ export function FloorplanPanel({
event.preventDefault()
event.stopPropagation()
floorplanNavigationSyncScheduler.flush()
const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015))
zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor)
}
const handleGestureStart = (event: Event) => {
floorplanNavigationSyncScheduler.flush()
const gestureEvent = event as GestureLikeEvent
gestureScaleRef.current = gestureEvent.scale ?? 1
event.preventDefault()
@@ -11369,7 +11567,7 @@ export function FloorplanPanel({
svg.removeEventListener('gesturechange', handleGestureChange)
svg.removeEventListener('gestureend', handleGestureEnd)
}
}, [commitFloorplanZoom, zoomViewportAtClientPoint])
}, [commitFloorplanZoom, floorplanNavigationSyncScheduler, zoomViewportAtClientPoint])
const restoreGroundLevelStructureSelection = useCallback(() => {
const sceneNodes = useScene.getState().nodes
@@ -11817,6 +12015,7 @@ export function FloorplanPanel({
legacy wall hatch kinds that opt into selection hatch
fills reuse this <defs> pattern via fill="url(...)". */}
<FloorplanRenderProvider
getSceneRotationDeg={getFloorplanSceneRotationDeg}
hatchPatternId={wallSelectionHatchId}
palette={floorplanRegistryPalette}
sceneRotationDeg={floorplanSceneRotationDeg}
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test } from 'bun:test'
import type { CameraPose } from '@pascal-app/core'
import { cameraPoseStore, publishCameraPose, subscribeCameraPose } from './camera-pose-store'
const pose: CameraPose = {
position: [3, 4, 5],
projection: 'perspective',
target: [0, 1, 0],
viewWidth: 12,
}
afterEach(() => {
cameraPoseStore.setState({ pose: null })
})
describe('camera pose store', () => {
test('replays the latest pose and streams later poses to focused subscribers', () => {
publishCameraPose(pose)
const received: CameraPose[] = []
const unsubscribe = subscribeCameraPose((nextPose) => received.push(nextPose))
const nextPose = { ...pose, viewWidth: 8 }
publishCameraPose(nextPose)
unsubscribe()
publishCameraPose({ ...pose, viewWidth: 4 })
expect(received).toEqual([pose, nextPose])
})
})
@@ -0,0 +1,27 @@
import type { CameraPose } from '@pascal-app/core'
import { subscribeWithSelector } from 'zustand/middleware'
import { createStore } from 'zustand/vanilla'
type CameraPoseState = {
pose: CameraPose | null
}
export const cameraPoseStore = createStore<CameraPoseState>()(
subscribeWithSelector<CameraPoseState>(() => ({ pose: null })),
)
export function publishCameraPose(pose: CameraPose) {
cameraPoseStore.setState({ pose })
}
export function subscribeCameraPose(listener: (pose: CameraPose) => void) {
const currentPose = cameraPoseStore.getState().pose
if (currentPose) listener(currentPose)
return cameraPoseStore.subscribe(
(state) => state.pose,
(pose) => {
if (pose) listener(pose)
},
)
}
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
navigationSyncPoseStore,
publishNavigationSyncPoseToStore,
subscribeNavigationSyncPose,
} from './navigation-sync-pose-store'
import type { NavigationSyncPose } from './use-editor'
const pose = {
source: '2d' as const,
revision: 1,
target: [1, 2, 3] as [number, number, number],
azimuth: 0.5,
viewWidth: 12,
}
afterEach(() => {
navigationSyncPoseStore.setState({ pose: null })
})
describe('navigation sync pose store', () => {
test('replays the latest pose and streams only later pose writes', () => {
publishNavigationSyncPoseToStore(pose)
const received: NavigationSyncPose[] = []
const unsubscribe = subscribeNavigationSyncPose((nextPose) => received.push(nextPose))
const nextPose = { ...pose, revision: 2, viewWidth: 8 }
publishNavigationSyncPoseToStore(nextPose)
unsubscribe()
publishNavigationSyncPoseToStore({ ...pose, revision: 3 })
expect(received).toEqual([pose, nextPose])
})
})
@@ -0,0 +1,27 @@
import { subscribeWithSelector } from 'zustand/middleware'
import { createStore } from 'zustand/vanilla'
import type { NavigationSyncPose } from './use-editor'
type NavigationSyncPoseState = {
pose: NavigationSyncPose | null
}
export const navigationSyncPoseStore = createStore<NavigationSyncPoseState>()(
subscribeWithSelector<NavigationSyncPoseState>(() => ({ pose: null })),
)
export function publishNavigationSyncPoseToStore(pose: NavigationSyncPose) {
navigationSyncPoseStore.setState({ pose })
}
export function subscribeNavigationSyncPose(listener: (pose: NavigationSyncPose) => void) {
const currentPose = navigationSyncPoseStore.getState().pose
if (currentPose) listener(currentPose)
return navigationSyncPoseStore.subscribe(
(state) => state.pose,
(pose) => {
if (pose) listener(pose)
},
)
}
+7 -5
View File
@@ -69,6 +69,7 @@ import {
snapContextOf,
snappingModesFor,
} from '../lib/snapping-mode'
import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store'
import useInteractionScope from './use-interaction-scope'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
@@ -1152,13 +1153,14 @@ const useEditor = create<EditorState>()(
setRiserOpen: (open) => set({ isRiserOpen: open }),
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
navigationSyncPose: null,
publishNavigationSyncPose: (pose) =>
set((state) => ({
navigationSyncPose: {
publishNavigationSyncPose: (pose) => {
const navigationSyncPose = {
...pose,
revision: (state.navigationSyncPose?.revision ?? 0) + 1,
revision: (get().navigationSyncPose?.revision ?? 0) + 1,
}
publishNavigationSyncPoseToStore(navigationSyncPose)
set({ navigationSyncPose })
},
})),
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,