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:
co-authored by
Claude Opus 4.6
parent
28ab27f5ec
commit
3b70deb837
@@ -222,7 +222,6 @@ type CameraControlEvents = {
|
|||||||
'camera-controls:orbit-ccw': undefined
|
'camera-controls:orbit-ccw': undefined
|
||||||
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
'camera-controls:fit-scene': CameraControlFitSceneEvent
|
||||||
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
|
||||||
'camera-controls:pose': CameraPose
|
|
||||||
'camera-controls:apply-pose': CameraPose
|
'camera-controls:apply-pose': CameraPose
|
||||||
'camera-controls:cancel-pose': undefined
|
'camera-controls:cancel-pose': undefined
|
||||||
'camera-controls:interaction-start': 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'
|
'use client'
|
||||||
|
|
||||||
import type { FloorplanPalette } from '@pascal-app/core'
|
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`
|
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
||||||
@@ -34,7 +34,34 @@ export type FloorplanRenderContextValue = {
|
|||||||
sceneRotationDeg: number
|
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({
|
export function FloorplanRenderProvider({
|
||||||
children,
|
children,
|
||||||
@@ -42,12 +69,30 @@ export function FloorplanRenderProvider({
|
|||||||
palette,
|
palette,
|
||||||
hatchPatternId,
|
hatchPatternId,
|
||||||
sceneRotationDeg,
|
sceneRotationDeg,
|
||||||
}: FloorplanRenderContextValue & { children: ReactNode }) {
|
getSceneRotationDeg,
|
||||||
const value = useMemo<FloorplanRenderContextValue>(
|
}: FloorplanRenderContextValue & {
|
||||||
() => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }),
|
children: ReactNode
|
||||||
[unitsPerPixel, palette, hatchPatternId, sceneRotationDeg],
|
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.
|
* whole legacy panel along.
|
||||||
*/
|
*/
|
||||||
export function useFloorplanRender(): FloorplanRenderContextValue | null {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-130
@@ -3,9 +3,9 @@ import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-exte
|
|||||||
import {
|
import {
|
||||||
collectAnnotationLayoutPreflightIssues,
|
collectAnnotationLayoutPreflightIssues,
|
||||||
floorplanAnnotationObstacleMode,
|
floorplanAnnotationObstacleMode,
|
||||||
observeSvgAnnotationLayoutChanges,
|
|
||||||
polylineObstacleRectangles,
|
polylineObstacleRectangles,
|
||||||
resolveAnnotationLabelRectangles,
|
resolveAnnotationLabelRectangles,
|
||||||
|
resolveSvgAnnotationCollisions,
|
||||||
} from './floorplan-annotation-layout'
|
} from './floorplan-annotation-layout'
|
||||||
|
|
||||||
describe('floorplanAnnotationObstacleMode', () => {
|
describe('floorplanAnnotationObstacleMode', () => {
|
||||||
@@ -333,135 +333,14 @@ describe('resolveAnnotationLabelRectangles', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('observeSvgAnnotationLayoutChanges', () => {
|
describe('resolveSvgAnnotationCollisions', () => {
|
||||||
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
|
test('uses captured label references instead of querying for them again', () => {
|
||||||
const OriginalMutationObserver = globalThis.MutationObserver
|
const svg = {
|
||||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
querySelectorAll: () => {
|
||||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
|
throw new Error('labels were rediscovered')
|
||||||
let notify: MutationCallback | undefined
|
},
|
||||||
let animationFrames: FrameRequestCallback[] = []
|
} as unknown as SVGSVGElement
|
||||||
let disconnected = false
|
|
||||||
let observedOptions: MutationObserverInit | undefined
|
|
||||||
|
|
||||||
class FakeMutationObserver {
|
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toEqual([])
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,9 +127,14 @@ export function resolveAnnotationLabelRectangles(
|
|||||||
|
|
||||||
export function resolveSvgAnnotationCollisions(
|
export function resolveSvgAnnotationCollisions(
|
||||||
svg: SVGSVGElement,
|
svg: SVGSVGElement,
|
||||||
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
|
options: {
|
||||||
|
labels?: readonly SVGGElement[]
|
||||||
|
layoutOverrides?: AnnotationLayoutOverrides
|
||||||
|
} = {},
|
||||||
): AnnotationPreflightIssue[] {
|
): 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 []
|
if (labels.length === 0) return []
|
||||||
|
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
@@ -227,101 +232,6 @@ export function resolveSvgAnnotationCollisions(
|
|||||||
return preflightIssues
|
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(
|
export function collectAnnotationLayoutPreflightIssues(
|
||||||
rectangles: readonly AnnotationLabelRectangle[],
|
rectangles: readonly AnnotationLabelRectangle[],
|
||||||
shifts: readonly AnnotationLabelShift[],
|
shifts: readonly AnnotationLabelShift[],
|
||||||
|
|||||||
@@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<g
|
<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-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||||
geometry.offsetDistance,
|
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-label-placement={layout.labelPlacement}
|
||||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||||
@@ -429,11 +434,16 @@ export function FloorplanDimensionStringRenderer({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<g
|
<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-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
|
||||||
geometry.offsetDistance,
|
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-label-placement={layout.labelPlacement}
|
||||||
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
|
||||||
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
|
||||||
|
|||||||
@@ -487,9 +487,15 @@ function renderNode(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
data-floorplan-annotation-angle-radians={g.angle}
|
||||||
data-floorplan-annotation-default-transform={labelTransform}
|
data-floorplan-annotation-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority="20"
|
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}
|
key={keyHint}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
transform={labelTransform}
|
transform={labelTransform}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
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', () => {
|
describe('resolveFloorplanLabelAngle', () => {
|
||||||
test('keeps segment labels readable while preserving their screen direction', () => {
|
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(0, 90, true)).toBe(-90)
|
||||||
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
|
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
|
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
||||||
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 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,
|
startFloorplanGroupMove,
|
||||||
startFloorplanGroupRotate,
|
startFloorplanGroupRotate,
|
||||||
} from '../floorplan-group-move'
|
} from '../floorplan-group-move'
|
||||||
import { useFloorplanRender } from '../floorplan-render-context'
|
import { useFloorplanSceneRotation, useFloorplanStaticRender } from '../floorplan-render-context'
|
||||||
import {
|
import {
|
||||||
floorplanAnnotationObstacleMode,
|
floorplanAnnotationObstacleMode,
|
||||||
isFloorplanAnnotationObstacleGeometry,
|
isFloorplanAnnotationObstacleGeometry,
|
||||||
observeSvgAnnotationLayoutChanges,
|
|
||||||
resolveSvgAnnotationCollisions,
|
resolveSvgAnnotationCollisions,
|
||||||
svgAnnotationLabelId,
|
svgAnnotationLabelId,
|
||||||
} from './floorplan-annotation-layout'
|
} from './floorplan-annotation-layout'
|
||||||
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
|
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
|
||||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-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.
|
* Registry-driven floor-plan layer.
|
||||||
@@ -417,7 +420,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
|
|
||||||
const levelId = selectedLevelId ?? ambientLevelId
|
const levelId = selectedLevelId ?? ambientLevelId
|
||||||
const isAmbient = !selectedLevelId && !!ambientLevelId
|
const isAmbient = !selectedLevelId && !!ambientLevelId
|
||||||
const renderCtx = useFloorplanRender()
|
const renderCtx = useFloorplanStaticRender()
|
||||||
|
const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
|
||||||
// Door / window placement (both build and move) needs the SVG's
|
// 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
|
const entries = floorplanData.entries
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
|
|
||||||
const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1
|
const unitsPerPixel = renderCtx?.getUnitsPerPixel() ?? 1
|
||||||
const palette = renderCtx?.palette
|
const palette = renderCtx?.palette
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1360,7 +1364,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
onHoveredIdChange={setHoveredId}
|
onHoveredIdChange={setHoveredId}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
pass="base"
|
pass="base"
|
||||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
sceneRotationDeg={sceneRotationDeg}
|
||||||
selected={selectedIdSet.has(entry.id)}
|
selected={selectedIdSet.has(entry.id)}
|
||||||
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
||||||
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
||||||
@@ -1414,7 +1418,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
onHoveredIdChange={setHoveredId}
|
onHoveredIdChange={setHoveredId}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
pass="overlay"
|
pass="overlay"
|
||||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
sceneRotationDeg={sceneRotationDeg}
|
||||||
selected={selectedIdSet.has(entry.id)}
|
selected={selectedIdSet.has(entry.id)}
|
||||||
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
||||||
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
||||||
@@ -1446,7 +1450,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
<RotationAngleOverlay
|
<RotationAngleOverlay
|
||||||
overlay={rotationOverlay}
|
overlay={rotationOverlay}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
sceneRotationDeg={sceneRotationDeg}
|
||||||
unitsPerPixel={unitsPerPixel}
|
unitsPerPixel={unitsPerPixel}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1456,40 +1460,119 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
|
|
||||||
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||||
const markerRef = useRef<SVGGElement>(null)
|
const markerRef = useRef<SVGGElement>(null)
|
||||||
const [layoutEpoch, setLayoutEpoch] = useState(0)
|
const 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 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 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 setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
|
||||||
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
|
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
|
||||||
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
|
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
|
||||||
const layoutEnabled = active && interactionIdle
|
const layoutEnabled = active && interactionIdle
|
||||||
useLayoutEffect(() => {
|
|
||||||
|
useEffect(() => {
|
||||||
if (!layoutEnabled) return
|
if (!layoutEnabled) return
|
||||||
const registryLayer = markerRef.current?.parentElement
|
let frame: number | null = null
|
||||||
if (!registryLayer) return
|
const invalidateLayout = () => {
|
||||||
return observeSvgAnnotationLayoutChanges(registryLayer, () => {
|
if (frame !== null) return
|
||||||
setLayoutEpoch((epoch) => epoch + 1)
|
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])
|
}, [layoutEnabled])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// The epoch is only a trigger; collision inputs are measured from the live SVG below.
|
// Explicit layout inputs replace the former whole-subtree MutationObserver.
|
||||||
void layoutEpoch
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
|
appliedLayoutInputsRef.current = null
|
||||||
resetPreflightIssues()
|
resetPreflightIssues()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!interactionIdle) return
|
if (!interactionIdle) return
|
||||||
const svg = markerRef.current?.ownerSVGElement
|
|
||||||
const registryLayer = markerRef.current?.parentElement
|
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, {
|
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
|
||||||
|
labels: collisionLabelElementsRef.current,
|
||||||
layoutOverrides: annotationLayoutOverrides,
|
layoutOverrides: annotationLayoutOverrides,
|
||||||
})
|
})
|
||||||
setPreflightIssues(preflightIssues)
|
setPreflightIssues(preflightIssues)
|
||||||
|
appliedLayoutInputsRef.current = layoutInputs
|
||||||
|
|
||||||
const labels = Array.from(
|
|
||||||
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
|
|
||||||
)
|
|
||||||
for (const [index, label] of labels.entries()) {
|
for (const [index, label] of labels.entries()) {
|
||||||
const id = svgAnnotationLabelId(label, index)
|
const id = svgAnnotationLabelId(label, index)
|
||||||
label.dataset.floorplanAnnotationId = id
|
label.dataset.floorplanAnnotationId = id
|
||||||
@@ -1500,8 +1583,9 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
|||||||
active,
|
active,
|
||||||
annotationLayoutOverrides,
|
annotationLayoutOverrides,
|
||||||
interactionIdle,
|
interactionIdle,
|
||||||
layoutEpoch,
|
layoutInputs,
|
||||||
resetPreflightIssues,
|
resetPreflightIssues,
|
||||||
|
sceneRotationDeg,
|
||||||
setPreflightIssues,
|
setPreflightIssues,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1519,9 +1603,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const labelId = (label: SVGGElement): string => {
|
const labelId = (label: SVGGElement): string => {
|
||||||
const labels = Array.from(
|
const labels = registryLabelElementsRef.current
|
||||||
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
|
|
||||||
)
|
|
||||||
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
|
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1613,9 +1695,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
|||||||
cancelActivePointerDrag?.()
|
cancelActivePointerDrag?.()
|
||||||
registryLayer.removeEventListener('pointerdown', onPointerDown)
|
registryLayer.removeEventListener('pointerdown', onPointerDown)
|
||||||
registryLayer.removeEventListener('dblclick', onDoubleClick)
|
registryLayer.removeEventListener('dblclick', onDoubleClick)
|
||||||
for (const label of registryLayer.querySelectorAll<SVGGElement>(
|
for (const label of registryLabelElementsRef.current) {
|
||||||
'[data-floorplan-annotation-label]',
|
|
||||||
)) {
|
|
||||||
label.style.pointerEvents = ''
|
label.style.pointerEvents = ''
|
||||||
label.style.cursor = ''
|
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})`
|
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
data-floorplan-annotation-angle-radians={g.angle}
|
||||||
data-floorplan-annotation-default-transform={labelTransform}
|
data-floorplan-annotation-default-transform={labelTransform}
|
||||||
data-floorplan-annotation-label=""
|
data-floorplan-annotation-label=""
|
||||||
data-floorplan-annotation-priority="20"
|
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}
|
key={keyHint}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
transform={labelTransform}
|
transform={labelTransform}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
withCameraPoseDistance,
|
withCameraPoseDistance,
|
||||||
} from '../../lib/camera-pose'
|
} from '../../lib/camera-pose'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
|
import { publishCameraPose } from '../../store/camera-pose-store'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import {
|
import {
|
||||||
useActiveHandleDrag,
|
useActiveHandleDrag,
|
||||||
@@ -46,14 +47,9 @@ const tempSize = new Vector3()
|
|||||||
const tempTarget = new Vector3()
|
const tempTarget = new Vector3()
|
||||||
const transitionFreezePosition = new Vector3()
|
const transitionFreezePosition = new Vector3()
|
||||||
const transitionFreezeTarget = new Vector3()
|
const transitionFreezeTarget = new Vector3()
|
||||||
const syncTarget = new Vector3()
|
|
||||||
const syncSpherical = new Spherical()
|
|
||||||
const keyboardPanSpherical = new Spherical()
|
const keyboardPanSpherical = new Spherical()
|
||||||
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1
|
||||||
const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
|
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_VIEW_WIDTH_PER_SECOND = 0.65
|
||||||
const KEYBOARD_PAN_MIN_SPEED = 2
|
const KEYBOARD_PAN_MIN_SPEED = 2
|
||||||
const KEYBOARD_PAN_MAX_SPEED = 55
|
const KEYBOARD_PAN_MAX_SPEED = 55
|
||||||
@@ -63,14 +59,6 @@ type CameraPoseSnapshot = {
|
|||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
target: [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 CameraViewWidthUpdate =
|
||||||
| { type: 'distance'; distance: number; viewWidth: number }
|
| { type: 'distance'; distance: number; viewWidth: number }
|
||||||
| { type: 'zoom'; viewWidth: number; zoom: 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)
|
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) {
|
function clampFinite(value: number, min: number, max: number) {
|
||||||
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY
|
||||||
const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_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(
|
function getCameraDistanceForViewWidth(
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
viewWidth: number,
|
viewWidth: number,
|
||||||
@@ -397,7 +362,6 @@ export const CustomCameraControls = () => {
|
|||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
|
||||||
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
|
||||||
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
|
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const cameraMode = useViewer((state) => state.cameraMode)
|
const cameraMode = useViewer((state) => state.cameraMode)
|
||||||
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore(
|
||||||
@@ -407,19 +371,8 @@ export const CustomCameraControls = () => {
|
|||||||
)
|
)
|
||||||
const currentLevelId = selection.levelId
|
const currentLevelId = selection.levelId
|
||||||
const firstLoad = useRef(true)
|
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 =
|
const maxPolarAngle =
|
||||||
!isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
|
!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 camera = useThree((state) => state.camera)
|
||||||
const gl = useThree((state) => state.gl)
|
const gl = useThree((state) => state.gl)
|
||||||
@@ -514,12 +467,10 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
|
activePoseInterpolation.current = { camera, control, plan: appliedPlan }
|
||||||
}, [
|
}, [
|
||||||
camera,
|
camera,
|
||||||
cancelPoseApplication,
|
cancelPoseApplication,
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
freezeActivePoseInterpolation,
|
freezeActivePoseInterpolation,
|
||||||
isFirstPersonMode,
|
isFirstPersonMode,
|
||||||
viewportSize,
|
viewportSize,
|
||||||
@@ -584,19 +535,11 @@ export const CustomCameraControls = () => {
|
|||||||
if (!controls.current) return
|
if (!controls.current) return
|
||||||
if (firstLoad.current) {
|
if (firstLoad.current) {
|
||||||
firstLoad.current = false
|
firstLoad.current = false
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
}
|
}
|
||||||
controls.current.getTarget(currentTarget)
|
controls.current.getTarget(currentTarget)
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
|
||||||
}, [
|
}, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose])
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
currentLevelId,
|
|
||||||
isPreviewMode,
|
|
||||||
isFirstPersonMode,
|
|
||||||
isRestoringFirstPersonPose,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstPersonMode || !controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
@@ -624,7 +567,6 @@ export const CustomCameraControls = () => {
|
|||||||
controls.current.getTarget(tempTarget)
|
controls.current.getTarget(tempTarget)
|
||||||
tempDelta.copy(tempCenter).sub(tempTarget)
|
tempDelta.copy(tempCenter).sub(tempTarget)
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
tempPosition.x + tempDelta.x,
|
tempPosition.x + tempDelta.x,
|
||||||
tempPosition.y + tempDelta.y,
|
tempPosition.y + tempDelta.y,
|
||||||
@@ -635,106 +577,9 @@ export const CustomCameraControls = () => {
|
|||||||
true,
|
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(() => {
|
const publishCurrentPose = useCallback(() => {
|
||||||
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
|
if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return
|
||||||
|
|
||||||
@@ -756,27 +601,13 @@ export const CustomCameraControls = () => {
|
|||||||
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
|
...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}),
|
||||||
})
|
})
|
||||||
if (pose) {
|
if (pose) {
|
||||||
emitter.emit('camera-controls:pose', pose)
|
publishCameraPose(pose)
|
||||||
}
|
}
|
||||||
}, [camera, isFirstPersonMode, viewportSize])
|
}, [camera, isFirstPersonMode, viewportSize])
|
||||||
|
|
||||||
const handleCameraUpdate = useCallback(() => {
|
const handleCameraUpdate = useCallback(() => {
|
||||||
publishCurrentNavigationPose()
|
|
||||||
publishCurrentPose()
|
publishCurrentPose()
|
||||||
}, [publishCurrentNavigationPose, publishCurrentPose])
|
}, [publishCurrentPose])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
|
|
||||||
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
lastPublishedNavigationSync.current = null
|
|
||||||
publishCurrentNavigationPose()
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame)
|
|
||||||
}
|
|
||||||
}, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose])
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (isFirstPersonMode || !controls.current) return
|
if (isFirstPersonMode || !controls.current) return
|
||||||
@@ -839,7 +670,6 @@ export const CustomCameraControls = () => {
|
|||||||
)
|
)
|
||||||
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
if (horizontal !== 0) control.truck(horizontal * step, 0, true)
|
||||||
if (vertical !== 0) control.forward(vertical * step, true)
|
if (vertical !== 0) control.forward(vertical * step, true)
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -992,7 +822,6 @@ export const CustomCameraControls = () => {
|
|||||||
) {
|
) {
|
||||||
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
|
||||||
if (changed) beginLocalCameraInteraction()
|
if (changed) beginLocalCameraInteraction()
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -1058,7 +887,6 @@ export const CustomCameraControls = () => {
|
|||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
|
||||||
|
|
||||||
panPointerId = event.pointerId
|
panPointerId = event.pointerId
|
||||||
@@ -1069,7 +897,6 @@ export const CustomCameraControls = () => {
|
|||||||
const onWheel = () => {
|
const onWheel = () => {
|
||||||
beginLocalCameraInteraction()
|
beginLocalCameraInteraction()
|
||||||
cameraDraggingLifecycle.scheduleEnd()
|
cameraDraggingLifecycle.scheduleEnd()
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerUp = (event: PointerEvent) => {
|
const onPointerUp = (event: PointerEvent) => {
|
||||||
@@ -1120,25 +947,18 @@ export const CustomCameraControls = () => {
|
|||||||
gl,
|
gl,
|
||||||
isPreviewMode,
|
isPreviewMode,
|
||||||
isFirstPersonMode,
|
isFirstPersonMode,
|
||||||
clearPendingFloorplanNavigationPose,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
// Cancel any in-progress 2D-origin navigation pose when the user starts
|
// `controlstart` fires only for user pointer interactions. Pointerdowns
|
||||||
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
|
// mapped to ACTION.NONE must not flag the camera as dragging because no
|
||||||
// fires only for user pointer interactions — not for programmatic
|
// rest/sleep event follows to clear the flag.
|
||||||
// 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.
|
|
||||||
const handleControlStart = useCallback(() => {
|
const handleControlStart = useCallback(() => {
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
beginLocalCameraInteraction({
|
beginLocalCameraInteraction({
|
||||||
dragging: controls.current
|
dragging: controls.current
|
||||||
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
|
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
|
||||||
: false,
|
: false,
|
||||||
})
|
})
|
||||||
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
|
}, [beginLocalCameraInteraction])
|
||||||
|
|
||||||
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
// Preview mode: auto-navigate camera to selected node (viewer behavior)
|
||||||
const previewTargetNodeId = isPreviewMode
|
const previewTargetNodeId = isPreviewMode
|
||||||
@@ -1345,7 +1165,6 @@ export const CustomCameraControls = () => {
|
|||||||
if (!node?.camera) return
|
if (!node?.camera) return
|
||||||
const { position, target } = node.camera
|
const { position, target } = node.camera
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(
|
controls.current.setLookAt(
|
||||||
position[0],
|
position[0],
|
||||||
position[1],
|
position[1],
|
||||||
@@ -1366,7 +1185,6 @@ export const CustomCameraControls = () => {
|
|||||||
// Otherwise, go to top view (0°)
|
// Otherwise, go to top view (0°)
|
||||||
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotatePolarTo(targetAngle, true)
|
controls.current.rotatePolarTo(targetAngle, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,7 +1197,6 @@ export const CustomCameraControls = () => {
|
|||||||
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
const target = rounded - Math.PI / 2
|
const target = rounded - Math.PI / 2
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
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 rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2)
|
||||||
const target = rounded + Math.PI / 2
|
const target = rounded + Math.PI / 2
|
||||||
|
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.rotateTo(target, currentPolar, true)
|
controls.current.rotateTo(target, currentPolar, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1404,7 +1220,6 @@ export const CustomCameraControls = () => {
|
|||||||
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
if (isFirstPersonMode || !controls.current || isPreviewMode) return
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
// Restore default framing pose when no bounds were computed.
|
// Restore default framing pose when no bounds were computed.
|
||||||
clearPendingFloorplanNavigationPose()
|
|
||||||
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1415,7 +1230,6 @@ export const CustomCameraControls = () => {
|
|||||||
const maxExtent = Math.max(w, d)
|
const maxExtent = Math.max(w, d)
|
||||||
const distance = Math.max(maxExtent * 1.4, 15)
|
const distance = Math.max(maxExtent * 1.4, 15)
|
||||||
const height = Math.max(maxExtent * 0.8, 10)
|
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)
|
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:orbit-ccw', handleOrbitCCW)
|
||||||
emitter.off('camera-controls:fit-scene', handleFitScene)
|
emitter.off('camera-controls:fit-scene', handleFitScene)
|
||||||
}
|
}
|
||||||
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
|
}, [focusNode, isPreviewMode, isFirstPersonMode])
|
||||||
|
|
||||||
const onTransitionStart = useCallback(() => {
|
const onTransitionStart = useCallback(() => {
|
||||||
cameraDraggingLifecycle.begin()
|
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 {
|
import {
|
||||||
canApplyFloorplanNavigationSync,
|
canApplyFloorplanNavigationSync,
|
||||||
canZoomFloorplanDuringNavigation,
|
canZoomFloorplanDuringNavigation,
|
||||||
|
createFloorplanNavigationSyncScheduler,
|
||||||
finalizeFloorplanNavigation,
|
finalizeFloorplanNavigation,
|
||||||
resolveFloorplanPresentationViewBox,
|
resolveFloorplanPresentationViewBox,
|
||||||
} from './floorplan-navigation-presentation'
|
} from './floorplan-navigation-presentation'
|
||||||
@@ -44,4 +45,32 @@ describe('floorplan navigation presentation', () => {
|
|||||||
|
|
||||||
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
|
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
|
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>({
|
export function finalizeFloorplanNavigation<RotationState>({
|
||||||
zoomPending,
|
zoomPending,
|
||||||
panActive,
|
panActive,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
|||||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||||
|
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
@@ -191,9 +192,15 @@ import {
|
|||||||
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
||||||
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
||||||
|
import {
|
||||||
|
subscribeFloorplanCameraNavigation,
|
||||||
|
useFloorplanCameraSyncBridge,
|
||||||
|
} from './floorplan-camera-sync'
|
||||||
import {
|
import {
|
||||||
canApplyFloorplanNavigationSync,
|
canApplyFloorplanNavigationSync,
|
||||||
canZoomFloorplanDuringNavigation,
|
canZoomFloorplanDuringNavigation,
|
||||||
|
createFloorplanNavigationSyncScheduler,
|
||||||
|
type FloorplanNavigationSyncScheduler,
|
||||||
type FloorplanPresentationViewBox,
|
type FloorplanPresentationViewBox,
|
||||||
finalizeFloorplanNavigation,
|
finalizeFloorplanNavigation,
|
||||||
resolveFloorplanPresentationViewBox,
|
resolveFloorplanPresentationViewBox,
|
||||||
@@ -337,12 +344,34 @@ type FloorplanRotationState = {
|
|||||||
latestViewport: FloorplanViewport
|
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) {
|
function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) {
|
||||||
rotationState.svg.style.transform = rotationState.svgStyle.transform
|
rotationState.svg.style.transform = rotationState.svgStyle.transform
|
||||||
rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin
|
rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin
|
||||||
rotationState.svg.style.willChange = rotationState.svgStyle.willChange
|
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 = {
|
type FloorplanScreenSelectionState = {
|
||||||
pointerId: number
|
pointerId: number
|
||||||
startClientX: number
|
startClientX: number
|
||||||
@@ -5306,6 +5335,7 @@ export function FloorplanPanel({
|
|||||||
compassHost?: HTMLElement | null
|
compassHost?: HTMLElement | null
|
||||||
floorplanSceneSlot?: ReactNode
|
floorplanSceneSlot?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
useFloorplanCameraSyncBridge()
|
||||||
const viewportHostRef = useRef<HTMLDivElement>(null)
|
const viewportHostRef = useRef<HTMLDivElement>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const floorplanBackgroundRef = useRef<SVGRectElement>(null)
|
const floorplanBackgroundRef = useRef<SVGRectElement>(null)
|
||||||
@@ -5340,6 +5370,16 @@ export function FloorplanPanel({
|
|||||||
const floorplanRenderScaleCommitTimerRef = useRef<number | null>(null)
|
const floorplanRenderScaleCommitTimerRef = useRef<number | null>(null)
|
||||||
const floorplanViewportInteractionInProgressRef = useRef(false)
|
const floorplanViewportInteractionInProgressRef = useRef(false)
|
||||||
const floorplanImperativeViewBoxRef = useRef<FloorplanPresentationViewBox | null>(null)
|
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 latestFloorplanRenderUnitsPerPixelRef = useRef(1)
|
||||||
const floorplanZoomPoseRef = useRef<{
|
const floorplanZoomPoseRef = useRef<{
|
||||||
localCenter: SvgPoint
|
localCenter: SvgPoint
|
||||||
@@ -5456,6 +5496,11 @@ export function FloorplanPanel({
|
|||||||
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
||||||
const floorplanSceneRotationDeg =
|
const floorplanSceneRotationDeg =
|
||||||
FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg
|
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).
|
// Only sync ref from state when floorplan is open (state is source of truth).
|
||||||
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
|
// When hidden, the imperative 3D path owns the ref and must not be clobbered.
|
||||||
if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) {
|
if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) {
|
||||||
@@ -6561,6 +6606,33 @@ export function FloorplanPanel({
|
|||||||
// so it re-renders per move without re-rendering this panel.
|
// so it re-renders per move without re-rendering this panel.
|
||||||
|
|
||||||
const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1
|
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(() => {
|
const fittedViewport = useMemo(() => {
|
||||||
// Collect bounds from the legacy polygon arrays first. Most are empty
|
// 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(() => {
|
const stopFloorplanViewAnimation = useCallback(() => {
|
||||||
if (floorplanViewAnimationFrameRef.current !== null) {
|
if (floorplanViewAnimationFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current)
|
window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current)
|
||||||
@@ -6862,39 +7064,26 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
const syncFloorplanViewportToNavigationPose = useCallback(
|
const syncFloorplanViewportToNavigationPose = useCallback(
|
||||||
(pose: NavigationSyncPose) => {
|
(pose: NavigationSyncPose) => {
|
||||||
// Skip the viewport sync while the 2D panel is hidden (3D mode). It writes
|
// The transient camera stream owns refs + imperative SVG presentation.
|
||||||
// React state (`setViewport`) that re-renders the whole floorplan SVG, so
|
// React state is committed only by the scheduler after the stream settles.
|
||||||
// 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.
|
|
||||||
if (!isFloorplanOpenRef.current) {
|
if (!isFloorplanOpenRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) {
|
const localNavigationInProgress =
|
||||||
|
floorplanViewportInteractionInProgressRef.current &&
|
||||||
|
floorplanNavigationSyncPresentationRef.current === null
|
||||||
|
if (!canApplyFloorplanNavigationSync(localNavigationInProgress)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
floorplanNavigationSyncScheduler.update(pose)
|
||||||
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,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[applyFloorplanNavigationView, buildingPosition, buildingRotationY],
|
[floorplanNavigationSyncScheduler],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFloorplanOpen) return
|
if (!isFloorplanOpen) return
|
||||||
|
|
||||||
const pose = useEditor.getState().navigationSyncPose
|
const pose = latestNavigationSyncPoseRef.current
|
||||||
if (!pose) {
|
if (!pose) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -6905,6 +7094,18 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
|
}, [syncFloorplanViewportToNavigationPose, isFloorplanOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFloorplanOpen) return
|
||||||
|
discardFloorplanNavigationSyncPresentation()
|
||||||
|
}, [discardFloorplanNavigationSyncPresentation, isFloorplanOpen])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
discardFloorplanNavigationSyncPresentation()
|
||||||
|
},
|
||||||
|
[discardFloorplanNavigationSyncPresentation],
|
||||||
|
)
|
||||||
|
|
||||||
const cancelHiddenCompassAnimation = useCallback(() => {
|
const cancelHiddenCompassAnimation = useCallback(() => {
|
||||||
if (hiddenCompassAnimationRef.current !== null) {
|
if (hiddenCompassAnimationRef.current !== null) {
|
||||||
cancelAnimationFrame(hiddenCompassAnimationRef.current)
|
cancelAnimationFrame(hiddenCompassAnimationRef.current)
|
||||||
@@ -6912,11 +7113,9 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Align-north while the panel is hidden publishes a single '2d' pose that
|
// Align-north while the panel is hidden publishes one 2D pose through the
|
||||||
// the 3D camera applies through the echo-suppressed pending-pose path — it
|
// generic camera bridge. Camera application suppresses its own pose stream,
|
||||||
// never publishes '3d' frames back, so the needle must animate itself.
|
// so the needle animates itself with the same time constant.
|
||||||
// Same time constant as the 2D view animation and the camera's effective
|
|
||||||
// smoothTime, so all three stay visually in step.
|
|
||||||
const animateHiddenCompassNeedle = useCallback(
|
const animateHiddenCompassNeedle = useCallback(
|
||||||
(targetDeg: number) => {
|
(targetDeg: number) => {
|
||||||
cancelHiddenCompassAnimation()
|
cancelHiddenCompassAnimation()
|
||||||
@@ -6947,10 +7146,10 @@ export function FloorplanPanel({
|
|||||||
[cancelHiddenCompassAnimation],
|
[cancelHiddenCompassAnimation],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const receiveFloorplanNavigationPose = useCallback(
|
||||||
const unsubscribe = useEditor.subscribe((state) => {
|
(pose: NavigationSyncPose) => {
|
||||||
const pose = state.navigationSyncPose
|
const previousPose = latestNavigationSyncPoseRef.current
|
||||||
if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) {
|
if (previousPose?.source === pose.source && previousPose.revision === pose.revision) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6980,16 +7179,34 @@ export function FloorplanPanel({
|
|||||||
if (pose.source === '3d') {
|
if (pose.source === '3d') {
|
||||||
syncFloorplanViewportToNavigationPose(pose)
|
syncFloorplanViewportToNavigationPose(pose)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
return () => {
|
[
|
||||||
unsubscribe()
|
|
||||||
cancelHiddenCompassAnimation()
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
syncFloorplanViewportToNavigationPose,
|
|
||||||
animateHiddenCompassNeedle,
|
animateHiddenCompassNeedle,
|
||||||
cancelHiddenCompassAnimation,
|
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.
|
// When the panel is hidden the imperative path owns the compass needle.
|
||||||
// React re-renders can overwrite the needle's inline transform with stale
|
// React re-renders can overwrite the needle's inline transform with stale
|
||||||
@@ -7945,34 +8162,6 @@ export function FloorplanPanel({
|
|||||||
[beginPanelInteraction, panelRect],
|
[beginPanelInteraction, panelRect],
|
||||||
)
|
)
|
||||||
|
|
||||||
const applyFloorplanViewportImperatively = useCallback(
|
|
||||||
(nextViewport: FloorplanViewport) => {
|
|
||||||
const nextHeight = nextViewport.width / svgAspectRatio
|
|
||||||
const nextMinX = nextViewport.centerX - nextViewport.width / 2
|
|
||||||
const nextMinY = nextViewport.centerY - nextHeight / 2
|
|
||||||
floorplanImperativeViewBoxRef.current = {
|
|
||||||
minX: nextMinX,
|
|
||||||
minY: nextMinY,
|
|
||||||
width: nextViewport.width,
|
|
||||||
height: nextHeight,
|
|
||||||
}
|
|
||||||
hasUserAdjustedViewportRef.current = true
|
|
||||||
latestViewportRef.current = nextViewport
|
|
||||||
svgRef.current?.setAttribute(
|
|
||||||
'viewBox',
|
|
||||||
`${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`,
|
|
||||||
)
|
|
||||||
const background = floorplanBackgroundRef.current
|
|
||||||
if (background) {
|
|
||||||
background.setAttribute('x', String(nextMinX))
|
|
||||||
background.setAttribute('y', String(nextMinY))
|
|
||||||
background.setAttribute('width', String(nextViewport.width))
|
|
||||||
background.setAttribute('height', String(nextHeight))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[svgAspectRatio],
|
|
||||||
)
|
|
||||||
|
|
||||||
const applyFloorplanRotationImperatively = useCallback(
|
const applyFloorplanRotationImperatively = useCallback(
|
||||||
(rotationState: FloorplanRotationState, nextUserRotationDeg: number) => {
|
(rotationState: FloorplanRotationState, nextUserRotationDeg: number) => {
|
||||||
const currentViewport = latestViewportRef.current ?? rotationState.latestViewport
|
const currentViewport = latestViewportRef.current ?? rotationState.latestViewport
|
||||||
@@ -8056,7 +8245,11 @@ export function FloorplanPanel({
|
|||||||
if (!localPoint) {
|
if (!localPoint) {
|
||||||
return
|
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
|
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
|
||||||
if (!currentViewport) {
|
if (!currentViewport) {
|
||||||
@@ -8085,7 +8278,7 @@ export function FloorplanPanel({
|
|||||||
x: nextMinX + nextWidth / 2,
|
x: nextMinX + nextWidth / 2,
|
||||||
y: nextMinY + nextHeight / 2,
|
y: nextMinY + nextHeight / 2,
|
||||||
}
|
}
|
||||||
const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg)
|
const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg)
|
||||||
const nextViewport = {
|
const nextViewport = {
|
||||||
centerX: nextCenterSvg.x,
|
centerX: nextCenterSvg.x,
|
||||||
centerY: nextCenterSvg.y,
|
centerY: nextCenterSvg.y,
|
||||||
@@ -8108,7 +8301,7 @@ export function FloorplanPanel({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyFloorplanViewportImperatively,
|
applyFloorplanViewportImperatively,
|
||||||
floorplanSceneRotationDeg,
|
buildingRotationDeg,
|
||||||
getSvgPointFromClientPoint,
|
getSvgPointFromClientPoint,
|
||||||
publishFloorplanNavigationPose,
|
publishFloorplanNavigationPose,
|
||||||
scheduleFloorplanZoomCommit,
|
scheduleFloorplanZoomCommit,
|
||||||
@@ -9177,6 +9370,7 @@ export function FloorplanPanel({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
|
floorplanNavigationSyncScheduler.flush()
|
||||||
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
|
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
|
||||||
stopFloorplanViewAnimation()
|
stopFloorplanViewAnimation()
|
||||||
floorplanNavigationClickSuppressedRef.current = true
|
floorplanNavigationClickSuppressedRef.current = true
|
||||||
@@ -9207,6 +9401,7 @@ export function FloorplanPanel({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
|
floorplanNavigationSyncScheduler.flush()
|
||||||
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
|
if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom()
|
||||||
stopFloorplanViewAnimation()
|
stopFloorplanViewAnimation()
|
||||||
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
|
const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current
|
||||||
@@ -9246,6 +9441,7 @@ export function FloorplanPanel({
|
|||||||
[
|
[
|
||||||
commitFloorplanZoom,
|
commitFloorplanZoom,
|
||||||
buildingRotationDeg,
|
buildingRotationDeg,
|
||||||
|
floorplanNavigationSyncScheduler,
|
||||||
setFloorplanCursorPosition,
|
setFloorplanCursorPosition,
|
||||||
setCursorPoint,
|
setCursorPoint,
|
||||||
stopFloorplanViewAnimation,
|
stopFloorplanViewAnimation,
|
||||||
@@ -11318,11 +11514,13 @@ export function FloorplanPanel({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
|
floorplanNavigationSyncScheduler.flush()
|
||||||
const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015))
|
const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015))
|
||||||
zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor)
|
zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGestureStart = (event: Event) => {
|
const handleGestureStart = (event: Event) => {
|
||||||
|
floorplanNavigationSyncScheduler.flush()
|
||||||
const gestureEvent = event as GestureLikeEvent
|
const gestureEvent = event as GestureLikeEvent
|
||||||
gestureScaleRef.current = gestureEvent.scale ?? 1
|
gestureScaleRef.current = gestureEvent.scale ?? 1
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -11369,7 +11567,7 @@ export function FloorplanPanel({
|
|||||||
svg.removeEventListener('gesturechange', handleGestureChange)
|
svg.removeEventListener('gesturechange', handleGestureChange)
|
||||||
svg.removeEventListener('gestureend', handleGestureEnd)
|
svg.removeEventListener('gestureend', handleGestureEnd)
|
||||||
}
|
}
|
||||||
}, [commitFloorplanZoom, zoomViewportAtClientPoint])
|
}, [commitFloorplanZoom, floorplanNavigationSyncScheduler, zoomViewportAtClientPoint])
|
||||||
|
|
||||||
const restoreGroundLevelStructureSelection = useCallback(() => {
|
const restoreGroundLevelStructureSelection = useCallback(() => {
|
||||||
const sceneNodes = useScene.getState().nodes
|
const sceneNodes = useScene.getState().nodes
|
||||||
@@ -11817,6 +12015,7 @@ export function FloorplanPanel({
|
|||||||
legacy wall hatch — kinds that opt into selection hatch
|
legacy wall hatch — kinds that opt into selection hatch
|
||||||
fills reuse this <defs> pattern via fill="url(...)". */}
|
fills reuse this <defs> pattern via fill="url(...)". */}
|
||||||
<FloorplanRenderProvider
|
<FloorplanRenderProvider
|
||||||
|
getSceneRotationDeg={getFloorplanSceneRotationDeg}
|
||||||
hatchPatternId={wallSelectionHatchId}
|
hatchPatternId={wallSelectionHatchId}
|
||||||
palette={floorplanRegistryPalette}
|
palette={floorplanRegistryPalette}
|
||||||
sceneRotationDeg={floorplanSceneRotationDeg}
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
snapContextOf,
|
snapContextOf,
|
||||||
snappingModesFor,
|
snappingModesFor,
|
||||||
} from '../lib/snapping-mode'
|
} from '../lib/snapping-mode'
|
||||||
|
import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store'
|
||||||
import useInteractionScope from './use-interaction-scope'
|
import useInteractionScope from './use-interaction-scope'
|
||||||
|
|
||||||
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
|
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build'
|
||||||
@@ -1152,13 +1153,14 @@ const useEditor = create<EditorState>()(
|
|||||||
setRiserOpen: (open) => set({ isRiserOpen: open }),
|
setRiserOpen: (open) => set({ isRiserOpen: open }),
|
||||||
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
|
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
|
||||||
navigationSyncPose: null,
|
navigationSyncPose: null,
|
||||||
publishNavigationSyncPose: (pose) =>
|
publishNavigationSyncPose: (pose) => {
|
||||||
set((state) => ({
|
const navigationSyncPose = {
|
||||||
navigationSyncPose: {
|
|
||||||
...pose,
|
...pose,
|
||||||
revision: (state.navigationSyncPose?.revision ?? 0) + 1,
|
revision: (get().navigationSyncPose?.revision ?? 0) + 1,
|
||||||
|
}
|
||||||
|
publishNavigationSyncPoseToStore(navigationSyncPose)
|
||||||
|
set({ navigationSyncPose })
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
|
floorplanSelectionTool: 'click' as FloorplanSelectionTool,
|
||||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||||
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
|
|||||||
Reference in New Issue
Block a user