diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 25aed8e77..ddb0ea38a 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -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 diff --git a/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts b/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts new file mode 100644 index 000000000..748fc13bd --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts @@ -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) + }) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-render-context.tsx b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx index 1b9c8e56f..38b61ebc0 100644 --- a/packages/editor/src/components/editor-2d/floorplan-render-context.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx @@ -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(null) +export type FloorplanStaticRenderContextValue = Omit< + FloorplanRenderContextValue, + 'sceneRotationDeg' | 'unitsPerPixel' +> & { + getSceneRotationDeg: () => number + getUnitsPerPixel: () => number +} + +const FloorplanStaticRenderContext = createContext(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( - () => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }), - [unitsPerPixel, palette, hatchPatternId, sceneRotationDeg], + getSceneRotationDeg, +}: FloorplanRenderContextValue & { + children: ReactNode + getSceneRotationDeg: () => number +}) { + const renderScaleReference = useRef(null) + if (!renderScaleReference.current) { + renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel) + } + renderScaleReference.current.update(unitsPerPixel) + const getUnitsPerPixel = renderScaleReference.current.read + const staticValue = useMemo( + () => ({ palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel }), + [palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel], + ) + return ( + + + + {children} + + + ) - return {children} } /** @@ -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) } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts index a1108ee96..1546d80b7 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts @@ -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 - - 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() - } +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 - 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([]) }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts index c6ab37ef0..d31ac3018 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -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('[data-floorplan-annotation-label]')) + const labels = + options.labels ?? + Array.from(svg.querySelectorAll('[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[], diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx index e917a2734..ce953e339 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({ /> ) : null} ) : null} { 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([['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)') + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts index 27ba84538..360020ea3 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts @@ -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, + 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 +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 0bb258999..1b530e16a 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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() { ) : null} @@ -1456,40 +1460,119 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const markerRef = useRef(null) - const [layoutEpoch, setLayoutEpoch] = useState(0) + const collisionLabelElementsRef = useRef([]) + const registryLabelElementsRef = useRef([]) + const appliedRotationDegRef = useRef(null) + const appliedLayoutInputsRef = useRef(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('[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('[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('[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( - '[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 ( { 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(null) - const pendingFloorplanNavigationPose = useRef(null) - const lastApplied2dNavigationRevision = useRef(0) - const savedSmoothTimeRef = useRef(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() diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts new file mode 100644 index 000000000..03e62e6ea --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts @@ -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> = [] + 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> = [] + 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> = [] + 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> = [] + 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 }, + ]) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.ts b/packages/editor/src/components/editor/floorplan-camera-sync.ts new file mode 100644 index 000000000..3e7c37b5f --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-camera-sync.ts @@ -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 + +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(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]) +} diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts index b114d9eab..130722e77 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -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({ + applyPresentation: (pose) => presented.push(pose), + commit: (pose) => committed.push(pose), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + 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]) + }) }) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts index 302958093..b832f62f0 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -21,6 +21,63 @@ export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): return !interactionInProgress } +export type FloorplanNavigationSyncScheduler = { + update: (pose: Pose) => void + flush: () => void + discard: () => void +} + +export function createFloorplanNavigationSyncScheduler({ + 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 + cancel?: (timer: ReturnType) => void +}): FloorplanNavigationSyncScheduler { + let latestPose: Pose | null = null + let settleTimer: ReturnType | 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({ zoomPending, panActive, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index fae830cc7..41f277c7d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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(null) const svgRef = useRef(null) const floorplanBackgroundRef = useRef(null) @@ -5340,6 +5370,16 @@ export function FloorplanPanel({ const floorplanRenderScaleCommitTimerRef = useRef(null) const floorplanViewportInteractionInProgressRef = useRef(false) const floorplanImperativeViewBoxRef = useRef(null) + const floorplanNavigationSyncPresentationRef = + useRef(null) + const applyFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>( + () => {}, + ) + const commitFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>( + () => {}, + ) + const floorplanNavigationSyncSchedulerRef = + useRef | 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() + }, + [ + animateHiddenCompassNeedle, + cancelHiddenCompassAnimation, + syncFloorplanViewportToNavigationPose, + ], + ) + + useEffect( + () => subscribeFloorplanCameraNavigation(receiveFloorplanNavigationPose), + [receiveFloorplanNavigationPose], + ) + + useEffect(() => { + const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => { + if (pose?.source === '2d') { + receiveFloorplanNavigationPose(pose) + } } - }, [ - syncFloorplanViewportToNavigationPose, - animateHiddenCompassNeedle, - cancelHiddenCompassAnimation, - ]) + 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 pattern via fill="url(...)". */} { + 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]) + }) +}) diff --git a/packages/editor/src/store/camera-pose-store.ts b/packages/editor/src/store/camera-pose-store.ts new file mode 100644 index 000000000..a6e7f3d26 --- /dev/null +++ b/packages/editor/src/store/camera-pose-store.ts @@ -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()( + subscribeWithSelector(() => ({ 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) + }, + ) +} diff --git a/packages/editor/src/store/navigation-sync-pose-store.test.ts b/packages/editor/src/store/navigation-sync-pose-store.test.ts new file mode 100644 index 000000000..221ae165b --- /dev/null +++ b/packages/editor/src/store/navigation-sync-pose-store.test.ts @@ -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]) + }) +}) diff --git a/packages/editor/src/store/navigation-sync-pose-store.ts b/packages/editor/src/store/navigation-sync-pose-store.ts new file mode 100644 index 000000000..caa2ac806 --- /dev/null +++ b/packages/editor/src/store/navigation-sync-pose-store.ts @@ -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()( + subscribeWithSelector(() => ({ 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) + }, + ) +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f6a0734b2..c9ec6e3f6 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( setRiserOpen: (open) => set({ isRiserOpen: open }), toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })), navigationSyncPose: null, - publishNavigationSyncPose: (pose) => - set((state) => ({ - navigationSyncPose: { - ...pose, - revision: (state.navigationSyncPose?.revision ?? 0) + 1, - }, - })), + publishNavigationSyncPose: (pose) => { + const navigationSyncPose = { + ...pose, + 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,