From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 1/5] 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 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed20..eefaa2a79 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304..112273a41 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e8724081..5563268b8 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 538286580..69a3d5ee3 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635..bac2b78fc 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 2/5] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f85723..d7c86be96 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e9..df67ca169 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca75..a3eccc116 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1dd..b86e426c4 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 5d2d44616c6a0a2c9b9707fd02a1728d7d0a413b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 10:58:15 +0530 Subject: [PATCH 3/5] perf(editor): reduce floorplan annotation navigation work --- .../floorplan-render-context.test.ts | 14 + .../editor-2d/floorplan-render-context.tsx | 83 +++++- .../floorplan-annotation-layout.test.ts | 134 --------- .../renderers/floorplan-annotation-layout.ts | 95 ------ .../floorplan-dimension-renderer.tsx | 10 + .../renderers/floorplan-geometry-renderer.tsx | 6 + .../renderers/floorplan-label-angle.test.ts | 29 +- .../renderers/floorplan-label-angle.ts | 73 +++++ .../renderers/floorplan-registry-layer.tsx | 84 ++++-- .../floorplan-navigation-presentation.test.ts | 29 ++ .../floorplan-navigation-presentation.ts | 57 ++++ .../src/components/editor/floorplan-panel.tsx | 277 ++++++++++++++---- 12 files changed, 584 insertions(+), 307 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/floorplan-render-context.test.ts diff --git a/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts b/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts new file mode 100644 index 000000000..748fc13bd --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { createFloorplanRenderScaleReference } from './floorplan-render-context' + +describe('floorplan render context', () => { + test('updates the live scale without changing the registry-facing reader', () => { + const scale = createFloorplanRenderScaleReference(0.02) + const read = scale.read + + scale.update(0.01) + + expect(scale.read).toBe(read) + expect(read()).toBe(0.01) + }) +}) diff --git a/packages/editor/src/components/editor-2d/floorplan-render-context.tsx b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx index 1b9c8e56f..38b61ebc0 100644 --- a/packages/editor/src/components/editor-2d/floorplan-render-context.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.tsx @@ -1,7 +1,7 @@ 'use client' import type { FloorplanPalette } from '@pascal-app/core' -import { createContext, type ReactNode, useContext, useMemo } from 'react' +import { createContext, type ReactNode, useContext, useMemo, useRef } from 'react' /** * Per-frame render context shared between the legacy `floorplan-panel.tsx` @@ -34,7 +34,34 @@ export type FloorplanRenderContextValue = { sceneRotationDeg: number } -const FloorplanRenderContext = createContext(null) +export type FloorplanStaticRenderContextValue = Omit< + FloorplanRenderContextValue, + 'sceneRotationDeg' | 'unitsPerPixel' +> & { + getSceneRotationDeg: () => number + getUnitsPerPixel: () => number +} + +const FloorplanStaticRenderContext = createContext(null) +const FloorplanSceneRotationContext = createContext(0) +const FloorplanUnitsPerPixelContext = createContext(1) + +export type FloorplanRenderScaleReference = { + read: () => number + update: (unitsPerPixel: number) => void +} + +export function createFloorplanRenderScaleReference( + initialUnitsPerPixel: number, +): FloorplanRenderScaleReference { + let unitsPerPixel = initialUnitsPerPixel + return { + read: () => unitsPerPixel, + update: (nextUnitsPerPixel) => { + unitsPerPixel = nextUnitsPerPixel + }, + } +} export function FloorplanRenderProvider({ children, @@ -42,12 +69,30 @@ export function FloorplanRenderProvider({ palette, hatchPatternId, sceneRotationDeg, -}: FloorplanRenderContextValue & { children: ReactNode }) { - const value = useMemo( - () => ({ unitsPerPixel, palette, hatchPatternId, sceneRotationDeg }), - [unitsPerPixel, palette, hatchPatternId, sceneRotationDeg], + getSceneRotationDeg, +}: FloorplanRenderContextValue & { + children: ReactNode + getSceneRotationDeg: () => number +}) { + const renderScaleReference = useRef(null) + if (!renderScaleReference.current) { + renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel) + } + renderScaleReference.current.update(unitsPerPixel) + const getUnitsPerPixel = renderScaleReference.current.read + const staticValue = useMemo( + () => ({ palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel }), + [palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel], + ) + return ( + + + + {children} + + + ) - return {children} } /** @@ -58,5 +103,27 @@ export function FloorplanRenderProvider({ * whole legacy panel along. */ export function useFloorplanRender(): FloorplanRenderContextValue | null { - return useContext(FloorplanRenderContext) + const staticValue = useContext(FloorplanStaticRenderContext) + const sceneRotationDeg = useContext(FloorplanSceneRotationContext) + const unitsPerPixel = useContext(FloorplanUnitsPerPixelContext) + return useMemo( + () => + staticValue + ? { + unitsPerPixel, + palette: staticValue.palette, + hatchPatternId: staticValue.hatchPatternId, + sceneRotationDeg, + } + : null, + [sceneRotationDeg, staticValue, unitsPerPixel], + ) +} + +export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue | null { + return useContext(FloorplanStaticRenderContext) +} + +export function useFloorplanSceneRotation(): number { + return useContext(FloorplanSceneRotationContext) } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts index a1108ee96..a0c2b5988 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts @@ -3,7 +3,6 @@ import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-exte import { collectAnnotationLayoutPreflightIssues, floorplanAnnotationObstacleMode, - observeSvgAnnotationLayoutChanges, polylineObstacleRectangles, resolveAnnotationLabelRectangles, } from './floorplan-annotation-layout' @@ -332,136 +331,3 @@ describe('resolveAnnotationLabelRectangles', () => { expect(elapsedMs).toBeLessThan(500) }) }) - -describe('observeSvgAnnotationLayoutChanges', () => { - test('requests a fresh collision pass when floor-plan geometry changes after mount', () => { - const OriginalMutationObserver = globalThis.MutationObserver - const originalRequestAnimationFrame = globalThis.requestAnimationFrame - const originalCancelAnimationFrame = globalThis.cancelAnimationFrame - let notify: MutationCallback | undefined - let animationFrames: FrameRequestCallback[] = [] - let disconnected = false - let observedOptions: MutationObserverInit | undefined - - class FakeMutationObserver { - constructor(callback: MutationCallback) { - notify = callback - } - - observe(_target: Node, options?: MutationObserverInit): void { - observedOptions = options - } - - disconnect(): void { - disconnected = true - } - - takeRecords(): MutationRecord[] { - return [] - } - } - - globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver - globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - animationFrames.push(callback) - return animationFrames.length - }) as typeof requestAnimationFrame - globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame - try { - const flushAnimationFrame = () => { - const callbacks = animationFrames - animationFrames = [] - for (const callback of callbacks) callback(0) - } - let layoutPasses = 0 - const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { - layoutPasses += 1 - }) - - notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) - - expect(layoutPasses).toBe(0) - flushAnimationFrame() - expect(layoutPasses).toBe(0) - flushAnimationFrame() - expect(layoutPasses).toBe(1) - expect(observedOptions).toMatchObject({ - attributes: true, - childList: true, - subtree: true, - attributeFilter: expect.any(Array), - }) - - notify?.( - [ - { - attributeName: 'style', - target: { closest: () => ({}) }, - type: 'attributes', - } as unknown as MutationRecord, - ], - {} as MutationObserver, - ) - expect(layoutPasses).toBe(1) - - stop() - expect(disconnected).toBe(true) - } finally { - globalThis.MutationObserver = OriginalMutationObserver - globalThis.requestAnimationFrame = originalRequestAnimationFrame - globalThis.cancelAnimationFrame = originalCancelAnimationFrame - } - }) - - test('waits for a quiet frame instead of resolving on every mutation frame', () => { - const OriginalMutationObserver = globalThis.MutationObserver - const originalRequestAnimationFrame = globalThis.requestAnimationFrame - const originalCancelAnimationFrame = globalThis.cancelAnimationFrame - let notify: MutationCallback | undefined - let animationFrames: FrameRequestCallback[] = [] - - class FakeMutationObserver { - constructor(callback: MutationCallback) { - notify = callback - } - - observe(): void {} - disconnect(): void {} - takeRecords(): MutationRecord[] { - return [] - } - } - - globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver - globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { - animationFrames.push(callback) - return animationFrames.length - }) as typeof requestAnimationFrame - globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame - try { - const flushAnimationFrame = () => { - const callbacks = animationFrames - animationFrames = [] - for (const callback of callbacks) callback(0) - } - let layoutPasses = 0 - const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { - layoutPasses += 1 - }) - - for (let frame = 0; frame < 30; frame += 1) { - notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) - flushAnimationFrame() - } - - expect(layoutPasses).toBe(0) - flushAnimationFrame() - expect(layoutPasses).toBe(1) - stop() - } finally { - globalThis.MutationObserver = OriginalMutationObserver - globalThis.requestAnimationFrame = originalRequestAnimationFrame - globalThis.cancelAnimationFrame = originalCancelAnimationFrame - } - }) -}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts index c6ab37ef0..337c2d94d 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -227,101 +227,6 @@ export function resolveSvgAnnotationCollisions( return preflightIssues } -export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void { - let scheduledFrame: number | null = null - let mutationVersion = 0 - let observedVersion = 0 - const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) - const flushWhenSettled = () => { - if (observedVersion !== mutationVersion) { - observedVersion = mutationVersion - scheduledFrame = requestFrame(flushWhenSettled) - return - } - scheduledFrame = null - onChange() - } - const schedule = () => { - mutationVersion += 1 - if (scheduledFrame !== null) return - observedVersion = mutationVersion - 1 - scheduledFrame = requestFrame(flushWhenSettled) - } - const observer = new MutationObserver((mutations) => { - if (mutations.some(isAnnotationLayoutMutation)) schedule() - }) - observer.observe(target, { - attributes: true, - attributeFilter: [ - 'cx', - 'cy', - 'd', - 'dominant-baseline', - 'font-family', - 'font-size', - 'font-weight', - 'height', - 'points', - 'r', - 'rx', - 'ry', - 'stroke-width', - 'text-anchor', - 'transform', - 'visibility', - 'width', - 'x', - 'x1', - 'x2', - 'y', - 'y1', - 'y2', - ], - characterData: true, - childList: true, - subtree: true, - }) - return () => { - observer.disconnect() - if (scheduledFrame === null) return - if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame) - else clearTimeout(scheduledFrame) - } -} - -function isAnnotationLayoutMutation(mutation: MutationRecord): boolean { - if (mutation.type !== 'attributes') return true - const attribute = mutation.attributeName ?? '' - const target = mutation.target as Element - const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null - - if ( - attribute === 'data-floorplan-annotation-id' || - attribute === 'data-floorplan-annotation-layout-dx' || - attribute === 'data-floorplan-annotation-layout-dy' || - attribute === 'data-floorplan-layout-unresolved' - ) { - return false - } - if ( - closest?.('[data-floorplan-annotation-label]') && - (attribute === 'style' || attribute === 'transform') - ) { - return false - } - if ( - closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') && - (attribute === 'x1' || - attribute === 'x2' || - attribute === 'y1' || - attribute === 'y2' || - attribute === 'visibility') - ) { - return false - } - return true -} - export function collectAnnotationLayoutPreflightIssues( rectangles: readonly AnnotationLabelRectangle[], shifts: readonly AnnotationLabelShift[], diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx index e917a2734..ce953e339 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -259,11 +259,16 @@ export function FloorplanDimensionRenderer({ /> ) : null} ) : null} { test('keeps segment labels readable while preserving their screen direction', () => { @@ -12,4 +16,27 @@ describe('resolveFloorplanLabelAngle', () => { expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90) expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35) }) + + test('ignores sub-degree rotation changes for annotation layout', () => { + expect(shouldUpdateFloorplanLabelRotation(30, 30.9)).toBe(false) + expect(shouldUpdateFloorplanLabelRotation(30, 31)).toBe(true) + expect(shouldUpdateFloorplanLabelRotation(359.5, 0.25)).toBe(false) + }) + + test('updates only label orientation while preserving its layout shift', () => { + expect( + resolveFloorplanAnnotationLabelTransform({ + afterRotation: 'translate(0 -0.2)', + angleRadians: 0, + beforeRotation: 'translate(4 6)', + layoutDx: 0.5, + layoutDy: -0.25, + sceneRotationDeg: 90, + screenUpright: true, + }), + ).toEqual({ + defaultTransform: 'translate(4 6) rotate(-90) translate(0 -0.2)', + transform: 'translate(4 6) rotate(-90) translate(0 -0.2) translate(0.5 -0.25)', + }) + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts index 27ba84538..db6c583b0 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts @@ -12,3 +12,76 @@ export function resolveFloorplanLabelAngle( else if (screenAngleDeg <= -90) localAngleDeg += 180 return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180 } + +export function shouldUpdateFloorplanLabelRotation( + previousRotationDeg: number | null, + nextRotationDeg: number, + minimumDeltaDeg = 1, +): boolean { + if (previousRotationDeg === null) return true + const deltaDeg = ((((nextRotationDeg - previousRotationDeg + 180) % 360) + 360) % 360) - 180 + return Math.abs(deltaDeg) >= minimumDeltaDeg +} + +export function 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( + root: ParentNode, + sceneRotationDeg: number, +): number { + const labels = root.querySelectorAll('[data-floorplan-annotation-angle-radians]') + let updated = 0 + + for (const label of labels) { + const angleRadians = Number(label.dataset.floorplanAnnotationAngleRadians) + if (!Number.isFinite(angleRadians)) continue + + const { defaultTransform, transform } = resolveFloorplanAnnotationLabelTransform({ + angleRadians, + sceneRotationDeg, + screenUpright: label.dataset.floorplanAnnotationScreenUpright === 'true', + beforeRotation: label.dataset.floorplanAnnotationTransformBeforeRotation ?? '', + afterRotation: label.dataset.floorplanAnnotationTransformAfterRotation ?? '', + layoutDx: Number(label.dataset.floorplanAnnotationLayoutDx ?? 0), + layoutDy: Number(label.dataset.floorplanAnnotationLayoutDy ?? 0), + }) + if (label.getAttribute('transform') === transform) continue + + label.dataset.floorplanAnnotationDefaultTransform = defaultTransform + label.setAttribute('transform', transform) + updated += 1 + } + + return updated +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 0bb258999..c2a200f64 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -88,17 +88,20 @@ import { startFloorplanGroupMove, startFloorplanGroupRotate, } from '../floorplan-group-move' -import { useFloorplanRender } from '../floorplan-render-context' +import { useFloorplanSceneRotation, useFloorplanStaticRender } from '../floorplan-render-context' import { floorplanAnnotationObstacleMode, isFloorplanAnnotationObstacleGeometry, - observeSvgAnnotationLayoutChanges, resolveSvgAnnotationCollisions, svgAnnotationLabelId, } from './floorplan-annotation-layout' import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' -import { resolveFloorplanLabelAngle } from './floorplan-label-angle' +import { + resolveFloorplanLabelAngle, + shouldUpdateFloorplanLabelRotation, + updateSvgFloorplanLabelOrientations, +} from './floorplan-label-angle' /** * Registry-driven floor-plan layer. @@ -417,7 +420,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = selectedLevelId ?? ambientLevelId const isAmbient = !selectedLevelId && !!ambientLevelId - const renderCtx = useFloorplanRender() + const renderCtx = useFloorplanStaticRender() + const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) // Door / window placement (both build and move) needs the SVG's @@ -1301,7 +1305,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const entries = floorplanData.entries if (entries.length === 0) return null - const unitsPerPixel = renderCtx?.unitsPerPixel ?? 1 + const unitsPerPixel = renderCtx?.getUnitsPerPixel() ?? 1 const palette = renderCtx?.palette return ( @@ -1360,7 +1364,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { onHoveredIdChange={setHoveredId} palette={palette} pass="base" - sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} + sceneRotationDeg={sceneRotationDeg} selected={selectedIdSet.has(entry.id)} suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)} groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false} @@ -1414,7 +1418,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { onHoveredIdChange={setHoveredId} palette={palette} pass="overlay" - sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} + sceneRotationDeg={sceneRotationDeg} selected={selectedIdSet.has(entry.id)} suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)} groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false} @@ -1446,7 +1450,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) : null} @@ -1456,24 +1460,55 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const markerRef = useRef(null) - const [layoutEpoch, setLayoutEpoch] = useState(0) + const appliedRotationDegRef = useRef(null) const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) + const sceneRotationDeg = useFloorplanSceneRotation() + const sceneNodes = useScene((state) => state.nodes) + const installedPlugins = useScene((state) => state.installedPlugins) + const liveTransforms = useLiveTransforms((state) => state.transforms) + const liveOverrides = useLiveNodeOverrides((state) => state.overrides) + const interactiveElevators = useInteractive((state) => state.elevators) + const annotationRotationDeg = shouldUpdateFloorplanLabelRotation( + appliedRotationDegRef.current, + sceneRotationDeg, + ) + ? sceneRotationDeg + : (appliedRotationDegRef.current ?? sceneRotationDeg) + const drawingType = useDrawingView((state) => state.drawingType) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) + const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) + const wallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.wallDimensionReference, + ) + const explicitLayoutInputs = useMemo( + () => ({ + annotationVisibility, + drawingType, + installedPlugins, + interactiveElevators, + liveOverrides, + liveTransforms, + sceneNodes, + wallDimensionReference, + }), + [ + annotationVisibility, + drawingType, + installedPlugins, + interactiveElevators, + liveOverrides, + liveTransforms, + sceneNodes, + wallDimensionReference, + ], + ) const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) const layoutEnabled = active && interactionIdle useLayoutEffect(() => { - if (!layoutEnabled) return - const registryLayer = markerRef.current?.parentElement - if (!registryLayer) return - return observeSvgAnnotationLayoutChanges(registryLayer, () => { - setLayoutEpoch((epoch) => epoch + 1) - }) - }, [layoutEnabled]) - useLayoutEffect(() => { - // The epoch is only a trigger; collision inputs are measured from the live SVG below. - void layoutEpoch + // Explicit layout inputs replace the former whole-subtree MutationObserver. + void explicitLayoutInputs if (!active) { resetPreflightIssues() return @@ -1482,6 +1517,8 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const svg = markerRef.current?.ownerSVGElement const registryLayer = markerRef.current?.parentElement if (!(svg && registryLayer)) return + updateSvgFloorplanLabelOrientations(registryLayer, annotationRotationDeg) + appliedRotationDegRef.current = annotationRotationDeg const preflightIssues = resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides, }) @@ -1498,9 +1535,10 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { } }, [ active, + annotationRotationDeg, annotationLayoutOverrides, + explicitLayoutInputs, interactionIdle, - layoutEpoch, resetPreflightIssues, setPreflightIssues, ]) @@ -2772,9 +2810,15 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})` return ( { expect(calls).toEqual(['zoom', 'pan', 'rotation:42']) }) + + test('coalesces a camera pose stream into one settled React commit', () => { + const presented: number[] = [] + const committed: number[] = [] + let scheduled: (() => void) | null = null + const scheduler = createFloorplanNavigationSyncScheduler({ + applyPresentation: (pose) => presented.push(pose), + commit: (pose) => committed.push(pose), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + for (let pose = 1; pose <= 30; pose += 1) { + scheduler.update(pose) + } + + expect(presented).toHaveLength(30) + expect(committed).toEqual([]) + + const settle = scheduled as (() => void) | null + settle?.() + expect(committed).toEqual([30]) + }) }) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts index 302958093..b832f62f0 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -21,6 +21,63 @@ export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): return !interactionInProgress } +export type FloorplanNavigationSyncScheduler = { + update: (pose: Pose) => void + flush: () => void + discard: () => void +} + +export function createFloorplanNavigationSyncScheduler({ + applyPresentation, + commit, + settleMs = 120, + schedule = globalThis.setTimeout, + cancel = globalThis.clearTimeout, +}: { + applyPresentation: (pose: Pose) => void + commit: (pose: Pose) => void + settleMs?: number + schedule?: (callback: () => void, delay: number) => ReturnType + cancel?: (timer: ReturnType) => void +}): FloorplanNavigationSyncScheduler { + let latestPose: Pose | null = null + let settleTimer: ReturnType | null = null + + const clearSettleTimer = () => { + if (settleTimer === null) return + cancel(settleTimer) + settleTimer = null + } + + const flush = () => { + clearSettleTimer() + if (latestPose === null) return + const pose = latestPose + latestPose = null + commit(pose) + } + + const update = (pose: Pose) => { + latestPose = pose + applyPresentation(pose) + clearSettleTimer() + settleTimer = schedule(() => { + settleTimer = null + if (latestPose === null) return + const settledPose = latestPose + latestPose = null + commit(settledPose) + }, settleMs) + } + + const discard = () => { + clearSettleTimer() + latestPose = null + } + + return { update, flush, discard } +} + export function finalizeFloorplanNavigation({ zoomPending, panActive, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index fae830cc7..29a991bc4 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -194,6 +194,8 @@ import { resolveFloorplanBackgroundSelection } from './floorplan-background-sele import { canApplyFloorplanNavigationSync, canZoomFloorplanDuringNavigation, + createFloorplanNavigationSyncScheduler, + type FloorplanNavigationSyncScheduler, type FloorplanPresentationViewBox, finalizeFloorplanNavigation, resolveFloorplanPresentationViewBox, @@ -337,12 +339,34 @@ type FloorplanRotationState = { latestViewport: FloorplanViewport } +type FloorplanNavigationSyncPresentationState = { + initialUserRotationDeg: number + initialSceneRotationDeg: number + latestUserRotationDeg: number + latestSceneRotationDeg: number + latestViewport: FloorplanViewport + svg: SVGSVGElement + svgStyle: { + transform: string + transformOrigin: string + willChange: string + } +} + function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) { rotationState.svg.style.transform = rotationState.svgStyle.transform rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin rotationState.svg.style.willChange = rotationState.svgStyle.willChange } +function restoreFloorplanNavigationSyncPresentation( + presentationState: FloorplanNavigationSyncPresentationState, +) { + presentationState.svg.style.transform = presentationState.svgStyle.transform + presentationState.svg.style.transformOrigin = presentationState.svgStyle.transformOrigin + presentationState.svg.style.willChange = presentationState.svgStyle.willChange +} + type FloorplanScreenSelectionState = { pointerId: number startClientX: number @@ -5340,6 +5364,16 @@ export function FloorplanPanel({ const floorplanRenderScaleCommitTimerRef = useRef(null) const floorplanViewportInteractionInProgressRef = useRef(false) const floorplanImperativeViewBoxRef = useRef(null) + const floorplanNavigationSyncPresentationRef = + useRef(null) + const applyFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>( + () => {}, + ) + const commitFloorplanNavigationSyncPresentationRef = useRef<(pose: NavigationSyncPose) => void>( + () => {}, + ) + const floorplanNavigationSyncSchedulerRef = + useRef | null>(null) const latestFloorplanRenderUnitsPerPixelRef = useRef(1) const floorplanZoomPoseRef = useRef<{ localCenter: SvgPoint @@ -5456,6 +5490,11 @@ export function FloorplanPanel({ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg + const getFloorplanSceneRotationDeg = useCallback( + () => + FLOORPLAN_VIEW_ROTATION_DEG + latestFloorplanUserRotationDegRef.current - buildingRotationDeg, + [buildingRotationDeg], + ) // Only sync ref from state when floorplan is open (state is source of truth). // When hidden, the imperative 3D path owns the ref and must not be clobbered. if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) { @@ -6561,6 +6600,33 @@ export function FloorplanPanel({ // so it re-renders per move without re-rendering this panel. const svgAspectRatio = surfaceSize.width / surfaceSize.height || 1 + const applyFloorplanViewportImperatively = useCallback( + (nextViewport: FloorplanViewport) => { + const nextHeight = nextViewport.width / svgAspectRatio + const nextMinX = nextViewport.centerX - nextViewport.width / 2 + const nextMinY = nextViewport.centerY - nextHeight / 2 + floorplanImperativeViewBoxRef.current = { + minX: nextMinX, + minY: nextMinY, + width: nextViewport.width, + height: nextHeight, + } + hasUserAdjustedViewportRef.current = true + latestViewportRef.current = nextViewport + svgRef.current?.setAttribute( + 'viewBox', + `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`, + ) + const background = floorplanBackgroundRef.current + if (background) { + background.setAttribute('x', String(nextMinX)) + background.setAttribute('y', String(nextMinY)) + background.setAttribute('width', String(nextViewport.width)) + background.setAttribute('height', String(nextHeight)) + } + }, + [svgAspectRatio], + ) const fittedViewport = useMemo(() => { // Collect bounds from the legacy polygon arrays first. Most are empty @@ -6733,6 +6799,136 @@ export function FloorplanPanel({ [], ) + const applyFloorplanNavigationSyncPresentation = useCallback( + (pose: NavigationSyncPose) => { + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const svg = svgRef.current + if (!(currentViewport && svg)) return + + let presentationState = floorplanNavigationSyncPresentationRef.current + if (!presentationState) { + const initialUserRotationDeg = latestFloorplanUserRotationDegRef.current + const initialSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + initialUserRotationDeg - buildingRotationDeg + presentationState = { + initialUserRotationDeg, + initialSceneRotationDeg, + latestUserRotationDeg: initialUserRotationDeg, + latestSceneRotationDeg: initialSceneRotationDeg, + latestViewport: currentViewport, + svg, + svgStyle: { + transform: svg.style.transform, + transformOrigin: svg.style.transformOrigin, + willChange: svg.style.willChange, + }, + } + floorplanNavigationSyncPresentationRef.current = presentationState + floorplanViewportInteractionInProgressRef.current = true + svg.style.transformOrigin = 'center' + svg.style.willChange = 'transform' + } + + const nextUserRotationDeg = floorplanRotationFromCameraAzimuth( + pose.azimuth, + presentationState.latestUserRotationDeg, + ) + const localCenter = worldToFloorplanLocalPoint( + pose.target[0], + pose.target[2], + buildingPosition, + buildingRotationY, + ) + const nextWidth = resolveFloorplanViewWidth( + pose.viewWidth, + currentViewport.width, + latestFittedViewportRef.current, + false, + ) + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg + const nextCenterSvg = rotateSvgPoint(localCenter, nextSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: nextWidth, + } + const presentationCenterSvg = rotateSvgPoint( + localCenter, + presentationState.initialSceneRotationDeg, + ) + + applyFloorplanViewportImperatively({ + centerX: presentationCenterSvg.x, + centerY: presentationCenterSvg.y, + width: nextWidth, + }) + svg.style.transform = `rotate(${ + nextUserRotationDeg - presentationState.initialUserRotationDeg + }deg)` + latestFloorplanUserRotationDegRef.current = nextUserRotationDeg + latestViewportRef.current = nextViewport + presentationState.latestUserRotationDeg = nextUserRotationDeg + presentationState.latestSceneRotationDeg = nextSceneRotationDeg + presentationState.latestViewport = nextViewport + }, + [applyFloorplanViewportImperatively, buildingPosition, buildingRotationDeg, buildingRotationY], + ) + + const commitFloorplanNavigationSyncPresentation = useCallback( + (_pose: NavigationSyncPose) => { + const presentationState = floorplanNavigationSyncPresentationRef.current + if (!presentationState) return + + applyFloorplanViewportImperatively(presentationState.latestViewport) + const scene = floorplanSceneRef.current + if (scene) { + if (presentationState.latestSceneRotationDeg === 0) { + scene.removeAttribute('transform') + } else { + scene.setAttribute('transform', `rotate(${presentationState.latestSceneRotationDeg})`) + } + } + restoreFloorplanNavigationSyncPresentation(presentationState) + floorplanNavigationSyncPresentationRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + setFloorplanUserRotationDeg((current) => + current === presentationState.latestUserRotationDeg + ? current + : presentationState.latestUserRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, presentationState.latestViewport) + ? current + : presentationState.latestViewport, + ) + }, + [applyFloorplanViewportImperatively], + ) + + applyFloorplanNavigationSyncPresentationRef.current = applyFloorplanNavigationSyncPresentation + commitFloorplanNavigationSyncPresentationRef.current = commitFloorplanNavigationSyncPresentation + if (!floorplanNavigationSyncSchedulerRef.current) { + floorplanNavigationSyncSchedulerRef.current = createFloorplanNavigationSyncScheduler({ + applyPresentation: (pose: NavigationSyncPose) => + applyFloorplanNavigationSyncPresentationRef.current(pose), + commit: (pose: NavigationSyncPose) => + commitFloorplanNavigationSyncPresentationRef.current(pose), + }) + } + const floorplanNavigationSyncScheduler = floorplanNavigationSyncSchedulerRef.current + const discardFloorplanNavigationSyncPresentation = useCallback(() => { + floorplanNavigationSyncScheduler.discard() + const presentationState = floorplanNavigationSyncPresentationRef.current + if (presentationState) { + restoreFloorplanNavigationSyncPresentation(presentationState) + floorplanNavigationSyncPresentationRef.current = null + } + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + }, [floorplanNavigationSyncScheduler]) + const stopFloorplanViewAnimation = useCallback(() => { if (floorplanViewAnimationFrameRef.current !== null) { window.cancelAnimationFrame(floorplanViewAnimationFrameRef.current) @@ -6869,26 +7065,15 @@ export function FloorplanPanel({ if (!isFloorplanOpenRef.current) { return } - if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) { + const localNavigationInProgress = + floorplanViewportInteractionInProgressRef.current && + floorplanNavigationSyncPresentationRef.current === null + if (!canApplyFloorplanNavigationSync(localNavigationInProgress)) { return } - - const nextUserRotationDeg = floorplanRotationFromCameraAzimuth( - pose.azimuth, - latestFloorplanUserRotationDegRef.current, - ) - const localCenter = worldToFloorplanLocalPoint( - pose.target[0], - pose.target[2], - buildingPosition, - buildingRotationY, - ) - - applyFloorplanNavigationView(localCenter, nextUserRotationDeg, pose.viewWidth, { - clampViewWidth: false, - }) + floorplanNavigationSyncScheduler.update(pose) }, - [applyFloorplanNavigationView, buildingPosition, buildingRotationY], + [floorplanNavigationSyncScheduler], ) useEffect(() => { @@ -6905,6 +7090,18 @@ export function FloorplanPanel({ } }, [syncFloorplanViewportToNavigationPose, isFloorplanOpen]) + useEffect(() => { + if (isFloorplanOpen) return + discardFloorplanNavigationSyncPresentation() + }, [discardFloorplanNavigationSyncPresentation, isFloorplanOpen]) + + useEffect( + () => () => { + discardFloorplanNavigationSyncPresentation() + }, + [discardFloorplanNavigationSyncPresentation], + ) + const cancelHiddenCompassAnimation = useCallback(() => { if (hiddenCompassAnimationRef.current !== null) { cancelAnimationFrame(hiddenCompassAnimationRef.current) @@ -7945,34 +8142,6 @@ export function FloorplanPanel({ [beginPanelInteraction, panelRect], ) - const applyFloorplanViewportImperatively = useCallback( - (nextViewport: FloorplanViewport) => { - const nextHeight = nextViewport.width / svgAspectRatio - const nextMinX = nextViewport.centerX - nextViewport.width / 2 - const nextMinY = nextViewport.centerY - nextHeight / 2 - floorplanImperativeViewBoxRef.current = { - minX: nextMinX, - minY: nextMinY, - width: nextViewport.width, - height: nextHeight, - } - hasUserAdjustedViewportRef.current = true - latestViewportRef.current = nextViewport - svgRef.current?.setAttribute( - 'viewBox', - `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`, - ) - const background = floorplanBackgroundRef.current - if (background) { - background.setAttribute('x', String(nextMinX)) - background.setAttribute('y', String(nextMinY)) - background.setAttribute('width', String(nextViewport.width)) - background.setAttribute('height', String(nextHeight)) - } - }, - [svgAspectRatio], - ) - const applyFloorplanRotationImperatively = useCallback( (rotationState: FloorplanRotationState, nextUserRotationDeg: number) => { const currentViewport = latestViewportRef.current ?? rotationState.latestViewport @@ -8056,7 +8225,11 @@ export function FloorplanPanel({ if (!localPoint) { return } - const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg) + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + + latestFloorplanUserRotationDegRef.current - + buildingRotationDeg + const svgPoint = rotateSvgPoint(localPoint, currentSceneRotationDeg) const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current if (!currentViewport) { @@ -8085,7 +8258,7 @@ export function FloorplanPanel({ x: nextMinX + nextWidth / 2, y: nextMinY + nextHeight / 2, } - const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) const nextViewport = { centerX: nextCenterSvg.x, centerY: nextCenterSvg.y, @@ -8108,7 +8281,7 @@ export function FloorplanPanel({ }, [ applyFloorplanViewportImperatively, - floorplanSceneRotationDeg, + buildingRotationDeg, getSvgPointFromClientPoint, publishFloorplanNavigationPose, scheduleFloorplanZoomCommit, @@ -9177,6 +9350,7 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + floorplanNavigationSyncScheduler.flush() if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() stopFloorplanViewAnimation() floorplanNavigationClickSuppressedRef.current = true @@ -9207,6 +9381,7 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + floorplanNavigationSyncScheduler.flush() if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() stopFloorplanViewAnimation() const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current @@ -9246,6 +9421,7 @@ export function FloorplanPanel({ [ commitFloorplanZoom, buildingRotationDeg, + floorplanNavigationSyncScheduler, setFloorplanCursorPosition, setCursorPoint, stopFloorplanViewAnimation, @@ -11318,11 +11494,13 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + floorplanNavigationSyncScheduler.flush() const widthFactor = Math.exp(event.deltaY * (event.ctrlKey ? 0.003 : 0.0015)) zoomViewportAtClientPoint(event.clientX, event.clientY, widthFactor) } const handleGestureStart = (event: Event) => { + floorplanNavigationSyncScheduler.flush() const gestureEvent = event as GestureLikeEvent gestureScaleRef.current = gestureEvent.scale ?? 1 event.preventDefault() @@ -11369,7 +11547,7 @@ export function FloorplanPanel({ svg.removeEventListener('gesturechange', handleGestureChange) svg.removeEventListener('gestureend', handleGestureEnd) } - }, [commitFloorplanZoom, zoomViewportAtClientPoint]) + }, [commitFloorplanZoom, floorplanNavigationSyncScheduler, zoomViewportAtClientPoint]) const restoreGroundLevelStructureSelection = useCallback(() => { const sceneNodes = useScene.getState().nodes @@ -11817,6 +11995,7 @@ export function FloorplanPanel({ legacy wall hatch — kinds that opt into selection hatch fills reuse this pattern via fill="url(...)". */} Date: Fri, 24 Jul 2026 12:57:26 +0530 Subject: [PATCH 4/5] perf(editor): reduce floorplan camera update work --- .../floorplan-annotation-layout.test.ts | 13 + .../renderers/floorplan-annotation-layout.ts | 9 +- .../renderers/floorplan-label-angle.test.ts | 47 ++++ .../renderers/floorplan-label-angle.ts | 23 +- .../renderers/floorplan-registry-layer.tsx | 62 +++-- .../editor/custom-camera-controls.tsx | 203 +-------------- .../editor/floorplan-camera-sync.test.ts | 217 ++++++++++++++++ .../editor/floorplan-camera-sync.ts | 233 ++++++++++++++++++ .../src/components/editor/floorplan-panel.tsx | 66 +++-- 9 files changed, 627 insertions(+), 246 deletions(-) create mode 100644 packages/editor/src/components/editor/floorplan-camera-sync.test.ts create mode 100644 packages/editor/src/components/editor/floorplan-camera-sync.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts index a0c2b5988..1546d80b7 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts @@ -5,6 +5,7 @@ import { floorplanAnnotationObstacleMode, polylineObstacleRectangles, resolveAnnotationLabelRectangles, + resolveSvgAnnotationCollisions, } from './floorplan-annotation-layout' describe('floorplanAnnotationObstacleMode', () => { @@ -331,3 +332,15 @@ describe('resolveAnnotationLabelRectangles', () => { expect(elapsedMs).toBeLessThan(500) }) }) + +describe('resolveSvgAnnotationCollisions', () => { + test('uses captured label references instead of querying for them again', () => { + const svg = { + querySelectorAll: () => { + throw new Error('labels were rediscovered') + }, + } as unknown as SVGSVGElement + + expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toEqual([]) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts index 337c2d94d..d31ac3018 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -127,9 +127,14 @@ export function resolveAnnotationLabelRectangles( export function resolveSvgAnnotationCollisions( svg: SVGSVGElement, - options: { layoutOverrides?: AnnotationLayoutOverrides } = {}, + options: { + labels?: readonly SVGGElement[] + layoutOverrides?: AnnotationLayoutOverrides + } = {}, ): AnnotationPreflightIssue[] { - const labels = Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]')) + const labels = + options.labels ?? + Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]')) if (labels.length === 0) return [] for (const label of labels) { diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.test.ts index a93d06a8b..9eacf0858 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test' import { resolveFloorplanAnnotationLabelTransform, + resolveFloorplanAnnotationUpdate, resolveFloorplanLabelAngle, shouldUpdateFloorplanLabelRotation, + updateSvgFloorplanLabelOrientations, } from './floorplan-label-angle' describe('resolveFloorplanLabelAngle', () => { @@ -39,4 +41,49 @@ describe('resolveFloorplanLabelAngle', () => { transform: 'translate(4 6) rotate(-90) translate(0 -0.2) translate(0.5 -0.25)', }) }) + + test('keeps a rotation-only update out of the full collision layout path', () => { + expect( + resolveFloorplanAnnotationUpdate({ + layoutInputsChanged: false, + nextRotationDeg: 45, + previousRotationDeg: 30, + }), + ).toEqual({ + resolveCollisions: false, + updateLabelPresentation: true, + }) + }) + + test('runs collision layout when floor-plan scene inputs change', () => { + expect( + resolveFloorplanAnnotationUpdate({ + layoutInputsChanged: true, + nextRotationDeg: 30, + previousRotationDeg: 30, + }), + ).toEqual({ + resolveCollisions: true, + updateLabelPresentation: true, + }) + }) + + test('updates captured label references without rediscovering the DOM', () => { + const attributes = new Map([['transform', 'translate(4 6) rotate(0)']]) + const label = { + dataset: { + floorplanAnnotationAngleRadians: '0', + floorplanAnnotationLayoutDx: '0.5', + floorplanAnnotationLayoutDy: '-0.25', + floorplanAnnotationScreenUpright: 'true', + floorplanAnnotationTransformAfterRotation: '', + floorplanAnnotationTransformBeforeRotation: 'translate(4 6)', + }, + getAttribute: (name: string) => attributes.get(name) ?? null, + setAttribute: (name: string, value: string) => attributes.set(name, value), + } as unknown as SVGGElement + + expect(updateSvgFloorplanLabelOrientations([label], 90)).toBe(1) + expect(attributes.get('transform')).toBe('translate(4 6) rotate(-90) translate(0.5 -0.25)') + }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts index db6c583b0..360020ea3 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-label-angle.ts @@ -23,6 +23,26 @@ export function shouldUpdateFloorplanLabelRotation( 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, @@ -57,10 +77,9 @@ export function resolveFloorplanAnnotationLabelTransform({ } export function updateSvgFloorplanLabelOrientations( - root: ParentNode, + labels: Iterable, sceneRotationDeg: number, ): number { - const labels = root.querySelectorAll('[data-floorplan-annotation-angle-radians]') let updated = 0 for (const label of labels) { diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index c2a200f64..cfd2a368e 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -98,8 +98,8 @@ import { import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { + resolveFloorplanAnnotationUpdate, resolveFloorplanLabelAngle, - shouldUpdateFloorplanLabelRotation, updateSvgFloorplanLabelOrientations, } from './floorplan-label-angle' @@ -1460,7 +1460,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const markerRef = useRef(null) + const collisionLabelElementsRef = useRef([]) + const registryLabelElementsRef = useRef([]) const appliedRotationDegRef = useRef(null) + const appliedLayoutInputsRef = useRef(null) const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) const sceneRotationDeg = useFloorplanSceneRotation() const sceneNodes = useScene((state) => state.nodes) @@ -1468,21 +1471,16 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const liveTransforms = useLiveTransforms((state) => state.transforms) const liveOverrides = useLiveNodeOverrides((state) => state.overrides) const interactiveElevators = useInteractive((state) => state.elevators) - const annotationRotationDeg = shouldUpdateFloorplanLabelRotation( - appliedRotationDegRef.current, - sceneRotationDeg, - ) - ? sceneRotationDeg - : (appliedRotationDegRef.current ?? sceneRotationDeg) const drawingType = useDrawingView((state) => state.drawingType) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) const wallDimensionReference = useFloorplanAnnotationVisibility( (state) => state.wallDimensionReference, ) - const explicitLayoutInputs = useMemo( + const layoutInputs = useMemo( () => ({ annotationVisibility, + annotationLayoutOverrides, drawingType, installedPlugins, interactiveElevators, @@ -1493,6 +1491,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { }), [ annotationVisibility, + annotationLayoutOverrides, drawingType, installedPlugins, interactiveElevators, @@ -1508,25 +1507,44 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const layoutEnabled = active && interactionIdle useLayoutEffect(() => { // Explicit layout inputs replace the former whole-subtree MutationObserver. - void explicitLayoutInputs if (!active) { + appliedLayoutInputsRef.current = null resetPreflightIssues() return } if (!interactionIdle) return - const svg = markerRef.current?.ownerSVGElement const registryLayer = markerRef.current?.parentElement - if (!(svg && registryLayer)) return - updateSvgFloorplanLabelOrientations(registryLayer, annotationRotationDeg) - appliedRotationDegRef.current = annotationRotationDeg + if (!registryLayer) return + const update = resolveFloorplanAnnotationUpdate({ + layoutInputsChanged: appliedLayoutInputsRef.current !== layoutInputs, + nextRotationDeg: sceneRotationDeg, + previousRotationDeg: appliedRotationDegRef.current, + }) + if (update.resolveCollisions) { + const svg = markerRef.current?.ownerSVGElement + if (!svg) return + collisionLabelElementsRef.current = Array.from( + svg.querySelectorAll('[data-floorplan-annotation-label]'), + ) + registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) => + registryLayer.contains(label), + ) + } + const labels = registryLabelElementsRef.current + if (update.updateLabelPresentation) { + updateSvgFloorplanLabelOrientations(labels, sceneRotationDeg) + appliedRotationDegRef.current = sceneRotationDeg + } + if (!update.resolveCollisions) return + const svg = markerRef.current?.ownerSVGElement + if (!svg) return const preflightIssues = resolveSvgAnnotationCollisions(svg, { + labels: collisionLabelElementsRef.current, layoutOverrides: annotationLayoutOverrides, }) setPreflightIssues(preflightIssues) + appliedLayoutInputsRef.current = layoutInputs - const labels = Array.from( - registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), - ) for (const [index, label] of labels.entries()) { const id = svgAnnotationLabelId(label, index) label.dataset.floorplanAnnotationId = id @@ -1535,11 +1553,11 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { } }, [ active, - annotationRotationDeg, annotationLayoutOverrides, - explicitLayoutInputs, interactionIdle, + layoutInputs, resetPreflightIssues, + sceneRotationDeg, setPreflightIssues, ]) @@ -1557,9 +1575,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { } const labelId = (label: SVGGElement): string => { - const labels = Array.from( - registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), - ) + const labels = registryLabelElementsRef.current return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label))) } @@ -1651,9 +1667,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { cancelActivePointerDrag?.() registryLayer.removeEventListener('pointerdown', onPointerDown) registryLayer.removeEventListener('dblclick', onDoubleClick) - for (const label of registryLayer.querySelectorAll( - '[data-floorplan-annotation-label]', - )) { + for (const label of registryLabelElementsRef.current) { label.style.pointerEvents = '' label.style.cursor = '' } diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 1b4ce7e30..f6c515e14 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -46,14 +46,9 @@ const tempSize = new Vector3() const tempTarget = new Vector3() const transitionFreezePosition = new Vector3() const transitionFreezeTarget = new Vector3() -const syncTarget = new Vector3() -const syncSpherical = new Spherical() const keyboardPanSpherical = new Spherical() const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 -const NAVIGATION_SYNC_POSITION_EPSILON = 0.001 -const NAVIGATION_SYNC_AZIMUTH_EPSILON = 0.0005 -const NAVIGATION_SYNC_VIEW_WIDTH_EPSILON = 0.001 const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65 const KEYBOARD_PAN_MIN_SPEED = 2 const KEYBOARD_PAN_MAX_SPEED = 55 @@ -63,14 +58,6 @@ type CameraPoseSnapshot = { position: [number, number, number] target: [number, number, number] } -type NavigationCameraPoseSnapshot = { - target: [number, number, number] - azimuth: number - viewWidth: number -} -type PendingNavigationCameraPoseSnapshot = NavigationCameraPoseSnapshot & { - publishOnComplete: boolean -} type CameraViewWidthUpdate = | { type: 'distance'; distance: number; viewWidth: number } | { type: 'zoom'; viewWidth: number; zoom: number } @@ -201,14 +188,6 @@ function getCameraViewWidth(camera: Camera, distance: number, size: CameraViewpo return Math.max(0.001, distance) } -function getAngleDeltaRadians(a: number, b: number) { - return Math.atan2(Math.sin(a - b), Math.cos(a - b)) -} - -function nearestEquivalentRadians(angle: number, reference: number) { - return reference + getAngleDeltaRadians(angle, reference) -} - function clampFinite(value: number, min: number, max: number) { const resolvedMin = Number.isFinite(min) ? min : Number.NEGATIVE_INFINITY const resolvedMax = Number.isFinite(max) ? max : Number.POSITIVE_INFINITY @@ -233,21 +212,6 @@ function clampCameraControlZoom(control: CameraControlsImpl, zoom: number) { ) } -function isCameraAtNavigationPose( - pose: NavigationCameraPoseSnapshot, - target: Vector3, - azimuth: number, - viewWidth: number, -) { - return ( - Math.abs(pose.target[0] - target.x) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(pose.target[1] - target.y) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(pose.target[2] - target.z) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(getAngleDeltaRadians(pose.azimuth, azimuth)) < NAVIGATION_SYNC_AZIMUTH_EPSILON && - Math.abs(pose.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON - ) -} - function getCameraDistanceForViewWidth( camera: Camera, viewWidth: number, @@ -397,7 +361,6 @@ export const CustomCameraControls = () => { const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) - const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) const selection = useViewer((s) => s.selection) const cameraMode = useViewer((state) => state.cameraMode) const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( @@ -407,19 +370,8 @@ export const CustomCameraControls = () => { ) const currentLevelId = selection.levelId const firstLoad = useRef(true) - const lastPublishedNavigationSync = useRef(null) - const pendingFloorplanNavigationPose = useRef(null) - const lastApplied2dNavigationRevision = useRef(0) - const savedSmoothTimeRef = useRef(null) const maxPolarAngle = !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE - const clearPendingFloorplanNavigationPose = useCallback(() => { - pendingFloorplanNavigationPose.current = null - if (savedSmoothTimeRef.current !== null && controls.current) { - controls.current.smoothTime = savedSmoothTimeRef.current - savedSmoothTimeRef.current = null - } - }, []) const camera = useThree((state) => state.camera) const gl = useThree((state) => state.gl) @@ -514,12 +466,10 @@ export const CustomCameraControls = () => { } } - clearPendingFloorplanNavigationPose() activePoseInterpolation.current = { camera, control, plan: appliedPlan } }, [ camera, cancelPoseApplication, - clearPendingFloorplanNavigationPose, freezeActivePoseInterpolation, isFirstPersonMode, viewportSize, @@ -584,19 +534,11 @@ export const CustomCameraControls = () => { if (!controls.current) return if (firstLoad.current) { firstLoad.current = false - clearPendingFloorplanNavigationPose() controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) } controls.current.getTarget(currentTarget) - clearPendingFloorplanNavigationPose() controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) - }, [ - clearPendingFloorplanNavigationPose, - currentLevelId, - isPreviewMode, - isFirstPersonMode, - isRestoringFirstPersonPose, - ]) + }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) useEffect(() => { if (isFirstPersonMode || !controls.current) return @@ -624,7 +566,6 @@ export const CustomCameraControls = () => { controls.current.getTarget(tempTarget) tempDelta.copy(tempCenter).sub(tempTarget) - clearPendingFloorplanNavigationPose() controls.current.setLookAt( tempPosition.x + tempDelta.x, tempPosition.y + tempDelta.y, @@ -635,106 +576,9 @@ export const CustomCameraControls = () => { true, ) }, - [clearPendingFloorplanNavigationPose, isPreviewMode, isFirstPersonMode], + [isPreviewMode, isFirstPersonMode], ) - useEffect(() => { - if (isFirstPersonMode) return - - return useEditor.subscribe((state) => { - const pose = state.navigationSyncPose - if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return - - const control = controls.current - if (!control) return - - lastApplied2dNavigationRevision.current = pose.revision - const targetAzimuth = nearestEquivalentRadians(pose.azimuth, control.azimuthAngle) - const viewWidthUpdate = resolveCameraViewWidthUpdate( - control, - camera, - pose.viewWidth, - viewportSize, - ) - pendingFloorplanNavigationPose.current = { - target: [...pose.target], - azimuth: targetAzimuth, - viewWidth: viewWidthUpdate.viewWidth, - publishOnComplete: - Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >= - NAVIGATION_SYNC_VIEW_WIDTH_EPSILON, - } - // Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's - // effective time constant is smoothTime/2, so smoothTime=0.18 gives - // τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel. - if (savedSmoothTimeRef.current === null) { - savedSmoothTimeRef.current = control.smoothTime - } - control.smoothTime = 0.18 - control.moveTo(pose.target[0], pose.target[1], pose.target[2], true) - control.rotateTo(targetAzimuth, control.polarAngle, true) - applyCameraViewWidth(control, viewWidthUpdate) - }) - }, [camera, isFirstPersonMode, viewportSize]) - - const publishCurrentNavigationPose = useCallback(() => { - if (isFirstPersonMode || !controls.current) return - - controls.current.getTarget(syncTarget, false) - controls.current.getSpherical(syncSpherical, false) - const viewWidth = getCameraViewWidth(camera, syncSpherical.radius, viewportSize) - - const pendingFloorplanPose = pendingFloorplanNavigationPose.current - if (pendingFloorplanPose) { - // The camera is still damping toward a 2D-originated pose; do not echo - // intermediate 3D poses back into the floorplan. - if ( - isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth) - ) { - lastPublishedNavigationSync.current = pendingFloorplanPose - clearPendingFloorplanNavigationPose() - if (pendingFloorplanPose.publishOnComplete) { - useEditor.getState().publishNavigationSyncPose({ - source: '3d', - target: [ - pendingFloorplanPose.target[0], - pendingFloorplanPose.target[1], - pendingFloorplanPose.target[2], - ], - azimuth: pendingFloorplanPose.azimuth, - viewWidth: pendingFloorplanPose.viewWidth, - }) - } - } - return - } - - const previous = lastPublishedNavigationSync.current - if ( - previous && - Math.abs(previous.target[0] - syncTarget.x) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(previous.target[1] - syncTarget.y) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(previous.target[2] - syncTarget.z) < NAVIGATION_SYNC_POSITION_EPSILON && - Math.abs(getAngleDeltaRadians(previous.azimuth, syncSpherical.theta)) < - NAVIGATION_SYNC_AZIMUTH_EPSILON && - Math.abs(previous.viewWidth - viewWidth) < NAVIGATION_SYNC_VIEW_WIDTH_EPSILON - ) { - return - } - - lastPublishedNavigationSync.current = { - target: [syncTarget.x, syncTarget.y, syncTarget.z], - azimuth: syncSpherical.theta, - viewWidth, - } - useEditor.getState().publishNavigationSyncPose({ - source: '3d', - target: [syncTarget.x, syncTarget.y, syncTarget.z], - azimuth: syncSpherical.theta, - viewWidth, - }) - }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize]) - const publishCurrentPose = useCallback(() => { if (isFirstPersonMode || suppressPoseEvents.current || !controls.current) return @@ -761,22 +605,8 @@ export const CustomCameraControls = () => { }, [camera, isFirstPersonMode, viewportSize]) const handleCameraUpdate = useCallback(() => { - publishCurrentNavigationPose() publishCurrentPose() - }, [publishCurrentNavigationPose, publishCurrentPose]) - - useEffect(() => { - if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return - - const frame = requestAnimationFrame(() => { - lastPublishedNavigationSync.current = null - publishCurrentNavigationPose() - }) - - return () => { - cancelAnimationFrame(frame) - } - }, [currentLevelId, isFirstPersonMode, isFloorplanOpen, publishCurrentNavigationPose]) + }, [publishCurrentPose]) useFrame((_, delta) => { if (isFirstPersonMode || !controls.current) return @@ -839,7 +669,6 @@ export const CustomCameraControls = () => { ) const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) - clearPendingFloorplanNavigationPose() if (horizontal !== 0) control.truck(horizontal * step, 0, true) if (vertical !== 0) control.forward(vertical * step, true) }, 0) @@ -992,7 +821,6 @@ export const CustomCameraControls = () => { ) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true) if (changed) beginLocalCameraInteraction() - clearPendingFloorplanNavigationPose() event.preventDefault() event.stopPropagation() } @@ -1058,7 +886,6 @@ export const CustomCameraControls = () => { const onPointerDown = (event: PointerEvent) => { if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return - clearPendingFloorplanNavigationPose() if (event.button !== 1 && !(event.button === 0 && keyState.space)) return panPointerId = event.pointerId @@ -1069,7 +896,6 @@ export const CustomCameraControls = () => { const onWheel = () => { beginLocalCameraInteraction() cameraDraggingLifecycle.scheduleEnd() - clearPendingFloorplanNavigationPose() } const onPointerUp = (event: PointerEvent) => { @@ -1120,25 +946,18 @@ export const CustomCameraControls = () => { gl, isPreviewMode, isFirstPersonMode, - clearPendingFloorplanNavigationPose, ]) - // Cancel any in-progress 2D-origin navigation pose when the user starts - // dragging (right-click orbit, middle-click pan, touch). `controlstart` - // fires only for user pointer interactions — not for programmatic - // moveTo/rotateTo which emit `transitionstart` instead. It also fires for - // pointerdowns whose button is mapped to ACTION.NONE (plain left click in - // edit mode); those must not flag the camera as dragging — no rest/sleep - // ever follows to clear the flag, which would leave canvas clicks - // (selection, placement) suppressed until the next real camera move. + // `controlstart` fires only for user pointer interactions. Pointerdowns + // mapped to ACTION.NONE must not flag the camera as dragging because no + // rest/sleep event follows to clear the flag. const handleControlStart = useCallback(() => { - clearPendingFloorplanNavigationPose() beginLocalCameraInteraction({ dragging: controls.current ? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE : false, }) - }, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose]) + }, [beginLocalCameraInteraction]) // Preview mode: auto-navigate camera to selected node (viewer behavior) const previewTargetNodeId = isPreviewMode @@ -1345,7 +1164,6 @@ export const CustomCameraControls = () => { if (!node?.camera) return const { position, target } = node.camera - clearPendingFloorplanNavigationPose() controls.current.setLookAt( position[0], position[1], @@ -1366,7 +1184,6 @@ export const CustomCameraControls = () => { // Otherwise, go to top view (0°) const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 - clearPendingFloorplanNavigationPose() controls.current.rotatePolarTo(targetAngle, true) } @@ -1379,7 +1196,6 @@ export const CustomCameraControls = () => { const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) const target = rounded - Math.PI / 2 - clearPendingFloorplanNavigationPose() controls.current.rotateTo(target, currentPolar, true) } @@ -1392,7 +1208,6 @@ export const CustomCameraControls = () => { const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) const target = rounded + Math.PI / 2 - clearPendingFloorplanNavigationPose() controls.current.rotateTo(target, currentPolar, true) } @@ -1404,7 +1219,6 @@ export const CustomCameraControls = () => { if (isFirstPersonMode || !controls.current || isPreviewMode) return if (!bounds) { // Restore default framing pose when no bounds were computed. - clearPendingFloorplanNavigationPose() controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) return } @@ -1415,7 +1229,6 @@ export const CustomCameraControls = () => { const maxExtent = Math.max(w, d) const distance = Math.max(maxExtent * 1.4, 15) const height = Math.max(maxExtent * 0.8, 10) - clearPendingFloorplanNavigationPose() controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true) } @@ -1436,7 +1249,7 @@ export const CustomCameraControls = () => { emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:fit-scene', handleFitScene) } - }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) + }, [focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { cameraDraggingLifecycle.begin() diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts new file mode 100644 index 000000000..03e62e6ea --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from 'bun:test' +import type { CameraPose } from '@pascal-app/core' +import type { NavigationSyncPose } from '../../store/use-editor' +import { + cameraPoseToFloorplanNavigationPose, + createFloorplanCameraNavigationChannel, + createFloorplanCameraSyncBridge, + floorplanNavigationPoseToCameraPose, +} from './floorplan-camera-sync' + +const cameraPose: CameraPose = { + fov: 50, + position: [3, 4, 4], + projection: 'perspective', + target: [0, 1, 0], + viewWidth: 12, +} + +function cameraPoseAtAzimuth( + azimuth: number, + target: [number, number, number] = [0, 1, 0], +): CameraPose { + const horizontalDistance = 5 + return { + ...cameraPose, + position: [ + target[0] + Math.sin(azimuth) * horizontalDistance, + 4, + target[2] + Math.cos(azimuth) * horizontalDistance, + ], + target, + } +} + +describe('floorplan camera sync', () => { + test('derives the floor-plan navigation pose from the generic camera pose', () => { + expect( + cameraPoseToFloorplanNavigationPose({ + position: [10, 5, 0], + projection: 'perspective', + target: [0, 0, 0], + viewWidth: 20, + }), + ).toEqual({ + source: '3d', + target: [0, 0, 0], + azimuth: Math.PI / 2, + viewWidth: 20, + }) + }) + + test('applies a floor-plan pose while preserving the generic camera elevation and projection', () => { + expect( + floorplanNavigationPoseToCameraPose( + { + source: '2d', + revision: 4, + target: [10, 2, 20], + azimuth: Math.PI / 2, + viewWidth: 8, + }, + cameraPose, + ), + ).toEqual({ + fov: 50, + position: [15, 5, 20], + projection: 'perspective', + target: [10, 2, 20], + viewWidth: 8, + }) + }) + + test('owns two-way synchronization without echoing tiny camera changes', () => { + const published: Array> = [] + const applied: CameraPose[] = [] + const bridge = createFloorplanCameraSyncBridge({ + applyCameraPose: (pose) => applied.push(pose), + publishNavigationPose: (pose) => published.push(pose), + }) + + bridge.receiveCameraPose(cameraPose) + bridge.receiveCameraPose({ + ...cameraPose, + position: [3.0001, 4, 4], + }) + bridge.receiveNavigationPose({ + source: '2d', + revision: 1, + target: [10, 2, 20], + azimuth: Math.PI / 2, + viewWidth: 8, + }) + + expect(published).toHaveLength(1) + expect(published[0]?.source).toBe('3d') + expect(applied).toHaveLength(1) + expect(applied[0]).toMatchObject({ + fov: 50, + projection: 'perspective', + target: [10, 2, 20], + viewWidth: 8, + }) + expect(applied[0]?.position[0]).toBeCloseTo(15) + expect(applied[0]?.position[1]).toBeCloseTo(5) + expect(applied[0]?.position[2]).toBeCloseTo(20) + }) + + test('does not publish floor-plan rotation below one degree', () => { + const published: Array> = [] + const bridge = createFloorplanCameraSyncBridge({ + applyCameraPose: () => {}, + publishNavigationPose: (pose) => published.push(pose), + }) + + bridge.receiveCameraPose(cameraPoseAtAzimuth(0)) + bridge.receiveCameraPose(cameraPoseAtAzimuth((0.9 * Math.PI) / 180)) + expect(published).toHaveLength(1) + + bridge.receiveCameraPose(cameraPoseAtAzimuth(Math.PI / 180)) + expect(published).toHaveLength(2) + expect(published[1]?.azimuth).toBeCloseTo(Math.PI / 180) + }) + + test('keeps the previous floor-plan angle when a sub-degree rotation arrives with a pan', () => { + const published: Array> = [] + const bridge = createFloorplanCameraSyncBridge({ + applyCameraPose: () => {}, + publishNavigationPose: (pose) => published.push(pose), + }) + + bridge.receiveCameraPose(cameraPoseAtAzimuth(0)) + bridge.receiveCameraPose(cameraPoseAtAzimuth((0.5 * Math.PI) / 180, [1, 1, 0])) + + expect(published).toHaveLength(2) + expect(published[1]).toMatchObject({ + target: [1, 1, 0], + azimuth: 0, + }) + }) + + test('does no floor-plan synchronization in 3D-only mode and catches up once when visible', () => { + const published: Array> = [] + const applied: CameraPose[] = [] + const bridge = createFloorplanCameraSyncBridge({ + active: false, + applyCameraPose: (pose) => applied.push(pose), + publishNavigationPose: (pose) => published.push(pose), + }) + const latestPose = cameraPoseAtAzimuth(Math.PI / 2, [8, 1, 4]) + + bridge.receiveCameraPose(cameraPoseAtAzimuth(0)) + bridge.receiveCameraPose(latestPose) + bridge.receiveNavigationPose({ + source: '2d', + revision: 1, + target: [10, 2, 20], + azimuth: Math.PI / 2, + viewWidth: 8, + }) + + expect(published).toEqual([]) + expect(applied).toEqual([]) + + bridge.setActive(true) + expect(published).toEqual([ + { + source: '3d', + target: latestPose.target, + azimuth: Math.PI / 2, + viewWidth: 12, + }, + ]) + expect(applied).toEqual([]) + }) + + test('waits for a generic camera reference before applying an early 2D pose', () => { + const applied: CameraPose[] = [] + const bridge = createFloorplanCameraSyncBridge({ + applyCameraPose: (pose) => applied.push(pose), + publishNavigationPose: () => {}, + }) + + bridge.receiveNavigationPose({ + source: '2d', + revision: 1, + target: [10, 2, 20], + azimuth: Math.PI / 2, + viewWidth: 8, + }) + expect(applied).toEqual([]) + + bridge.receiveCameraPose(cameraPose) + expect(applied).toHaveLength(1) + }) + + test('delivers the live camera stream through transient subscribers', () => { + const channel = createFloorplanCameraNavigationChannel() + const received: NavigationSyncPose[] = [] + const unsubscribe = channel.subscribe((pose) => received.push(pose)) + const input = { + source: '3d' as const, + target: [0, 1, 2] as [number, number, number], + azimuth: 0.5, + viewWidth: 12, + } + + channel.publish(input) + channel.publish({ ...input, azimuth: 0.75 }) + unsubscribe() + channel.publish({ ...input, azimuth: 1 }) + + expect(received).toEqual([ + { ...input, revision: 1 }, + { ...input, azimuth: 0.75, revision: 2 }, + ]) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.ts b/packages/editor/src/components/editor/floorplan-camera-sync.ts new file mode 100644 index 000000000..5793a6855 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-camera-sync.ts @@ -0,0 +1,233 @@ +'use client' + +import { type CameraPose, emitter } from '@pascal-app/core' +import { useEffect, useRef } from 'react' +import useEditor, { + type NavigationSyncPose, + type NavigationSyncPoseInput, +} from '../../store/use-editor' + +const POSITION_EPSILON = 0.001 +const AZIMUTH_EPSILON = Math.PI / 180 +const VIEW_WIDTH_EPSILON = 0.001 + +type FloorplanNavigationSnapshot = Omit + +function angleDeltaRadians(a: number, b: number) { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function normalizeNearZero(value: number) { + return Math.abs(value) < Number.EPSILON * 10 ? 0 : value +} + +function navigationSnapshot(pose: NavigationSyncPoseInput): FloorplanNavigationSnapshot { + return { + target: [...pose.target], + azimuth: pose.azimuth, + viewWidth: pose.viewWidth, + } +} + +function navigationSnapshotsEqual( + previous: FloorplanNavigationSnapshot, + next: FloorplanNavigationSnapshot, +) { + return ( + Math.abs(previous.target[0] - next.target[0]) < POSITION_EPSILON && + Math.abs(previous.target[1] - next.target[1]) < POSITION_EPSILON && + Math.abs(previous.target[2] - next.target[2]) < POSITION_EPSILON && + Math.abs(angleDeltaRadians(previous.azimuth, next.azimuth)) < AZIMUTH_EPSILON && + Math.abs(previous.viewWidth - next.viewWidth) < VIEW_WIDTH_EPSILON + ) +} + +export function cameraPoseToFloorplanNavigationPose( + pose: CameraPose, +): NavigationSyncPoseInput | null { + if (!(pose.viewWidth !== undefined && Number.isFinite(pose.viewWidth) && pose.viewWidth > 0)) { + return null + } + + return { + source: '3d', + target: [...pose.target], + azimuth: Math.atan2(pose.position[0] - pose.target[0], pose.position[2] - pose.target[2]), + viewWidth: pose.viewWidth, + } +} + +export function floorplanNavigationPoseToCameraPose( + navigationPose: NavigationSyncPose, + cameraPose: CameraPose, +): CameraPose { + const offsetX = cameraPose.position[0] - cameraPose.target[0] + const offsetY = cameraPose.position[1] - cameraPose.target[1] + const offsetZ = cameraPose.position[2] - cameraPose.target[2] + const horizontalDistance = Math.hypot(offsetX, offsetZ) + const horizontalX = normalizeNearZero(Math.sin(navigationPose.azimuth) * horizontalDistance) + const horizontalZ = normalizeNearZero(Math.cos(navigationPose.azimuth) * horizontalDistance) + const target: [number, number, number] = [...navigationPose.target] + + return { + ...(cameraPose.fov === undefined ? {} : { fov: cameraPose.fov }), + position: [target[0] + horizontalX, target[1] + offsetY, target[2] + horizontalZ], + projection: cameraPose.projection, + target, + viewWidth: navigationPose.viewWidth, + } +} + +export type FloorplanCameraSyncBridge = { + receiveCameraPose: (pose: CameraPose) => void + receiveNavigationPose: (pose: NavigationSyncPose | null) => void + setActive: (active: boolean) => void +} + +export type FloorplanCameraNavigationChannel = { + publish: (pose: NavigationSyncPoseInput) => void + subscribe: (listener: (pose: NavigationSyncPose) => void) => () => void +} + +export function createFloorplanCameraNavigationChannel(): FloorplanCameraNavigationChannel { + const listeners = new Set<(pose: NavigationSyncPose) => void>() + let revision = 0 + + return { + publish: (pose) => { + revision += 1 + const revisedPose = { ...pose, revision } + for (const listener of listeners) { + listener(revisedPose) + } + }, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +const liveCameraNavigation = createFloorplanCameraNavigationChannel() + +export function subscribeFloorplanCameraNavigation(listener: (pose: NavigationSyncPose) => void) { + return liveCameraNavigation.subscribe(listener) +} + +export function createFloorplanCameraSyncBridge({ + active: initialActive = true, + applyCameraPose, + publishNavigationPose, +}: { + active?: boolean + applyCameraPose: (pose: CameraPose) => void + publishNavigationPose: (pose: NavigationSyncPoseInput) => void +}): FloorplanCameraSyncBridge { + let active = initialActive + let latestCameraPose: CameraPose | null = null + let pendingNavigationPose: NavigationSyncPose | null = null + let lastAppliedNavigationRevision = 0 + let lastPublishedNavigation: FloorplanNavigationSnapshot | null = null + + const applyPendingNavigationPose = () => { + if (!(latestCameraPose && pendingNavigationPose)) return false + if (pendingNavigationPose.revision === lastAppliedNavigationRevision) { + pendingNavigationPose = null + return false + } + + const appliedPose = floorplanNavigationPoseToCameraPose(pendingNavigationPose, latestCameraPose) + const appliedNavigationPose = cameraPoseToFloorplanNavigationPose(appliedPose) + latestCameraPose = appliedPose + lastAppliedNavigationRevision = pendingNavigationPose.revision + pendingNavigationPose = null + if (appliedNavigationPose) { + lastPublishedNavigation = navigationSnapshot(appliedNavigationPose) + } + applyCameraPose(appliedPose) + return true + } + + const publishCameraNavigationPose = (pose: CameraPose) => { + let navigationPose = cameraPoseToFloorplanNavigationPose(pose) + if (!navigationPose) return + if ( + lastPublishedNavigation && + Math.abs(angleDeltaRadians(lastPublishedNavigation.azimuth, navigationPose.azimuth)) < + AZIMUTH_EPSILON + ) { + navigationPose = { + ...navigationPose, + azimuth: lastPublishedNavigation.azimuth, + } + } + const nextSnapshot = navigationSnapshot(navigationPose) + if ( + lastPublishedNavigation && + navigationSnapshotsEqual(lastPublishedNavigation, nextSnapshot) + ) { + return + } + + lastPublishedNavigation = nextSnapshot + publishNavigationPose(navigationPose) + } + + return { + receiveCameraPose: (pose) => { + latestCameraPose = pose + if (!active) return + if (applyPendingNavigationPose()) return + + publishCameraNavigationPose(pose) + }, + receiveNavigationPose: (pose) => { + if ( + !active || + pose?.source !== '2d' || + pose.revision === lastAppliedNavigationRevision || + pose.revision === pendingNavigationPose?.revision + ) { + return + } + + pendingNavigationPose = pose + applyPendingNavigationPose() + }, + setActive: (nextActive) => { + if (active === nextActive) return + active = nextActive + if (!active) { + pendingNavigationPose = null + return + } + if (latestCameraPose) publishCameraNavigationPose(latestCameraPose) + }, + } +} + +export function useFloorplanCameraSyncBridge() { + const active = useEditor((state) => state.viewMode !== '3d') + const bridgeRef = useRef(null) + if (!bridgeRef.current) { + bridgeRef.current = createFloorplanCameraSyncBridge({ + active, + applyCameraPose: (pose) => emitter.emit('camera-controls:apply-pose', pose), + publishNavigationPose: (pose) => liveCameraNavigation.publish(pose), + }) + } + const bridge = bridgeRef.current + + useEffect(() => bridge.setActive(active), [active, bridge]) + + useEffect(() => { + const receiveCameraPose = (pose: CameraPose) => bridge.receiveCameraPose(pose) + emitter.on('camera-controls:pose', receiveCameraPose) + return () => emitter.off('camera-controls:pose', receiveCameraPose) + }, [bridge]) + + useEffect(() => { + bridge.receiveNavigationPose(useEditor.getState().navigationSyncPose) + return useEditor.subscribe((state) => bridge.receiveNavigationPose(state.navigationSyncPose)) + }, [bridge]) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 29a991bc4..f05aead08 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -191,6 +191,10 @@ import { import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { + subscribeFloorplanCameraNavigation, + useFloorplanCameraSyncBridge, +} from './floorplan-camera-sync' import { canApplyFloorplanNavigationSync, canZoomFloorplanDuringNavigation, @@ -5330,6 +5334,7 @@ export function FloorplanPanel({ compassHost?: HTMLElement | null floorplanSceneSlot?: ReactNode }) { + useFloorplanCameraSyncBridge() const viewportHostRef = useRef(null) const svgRef = useRef(null) const floorplanBackgroundRef = useRef(null) @@ -7058,10 +7063,8 @@ export function FloorplanPanel({ const syncFloorplanViewportToNavigationPose = useCallback( (pose: NavigationSyncPose) => { - // Skip the viewport sync while the 2D panel is hidden (3D mode). It writes - // React state (`setViewport`) that re-renders the whole floorplan SVG, so - // doing it every camera-zoom frame for an invisible panel was a needless - // per-frame stall. The catch-up effect below re-syncs on reopen. + // The transient camera stream owns refs + imperative SVG presentation. + // React state is committed only by the scheduler after the stream settles. if (!isFloorplanOpenRef.current) { return } @@ -7079,7 +7082,7 @@ export function FloorplanPanel({ useEffect(() => { if (!isFloorplanOpen) return - const pose = useEditor.getState().navigationSyncPose + const pose = latestNavigationSyncPoseRef.current if (!pose) { return } @@ -7109,11 +7112,9 @@ export function FloorplanPanel({ } }, []) - // Align-north while the panel is hidden publishes a single '2d' pose that - // the 3D camera applies through the echo-suppressed pending-pose path — it - // never publishes '3d' frames back, so the needle must animate itself. - // Same time constant as the 2D view animation and the camera's effective - // smoothTime, so all three stay visually in step. + // Align-north while the panel is hidden publishes one 2D pose through the + // generic camera bridge. Camera application suppresses its own pose stream, + // so the needle animates itself with the same time constant. const animateHiddenCompassNeedle = useCallback( (targetDeg: number) => { cancelHiddenCompassAnimation() @@ -7144,10 +7145,10 @@ export function FloorplanPanel({ [cancelHiddenCompassAnimation], ) - useEffect(() => { - const unsubscribe = useEditor.subscribe((state) => { - const pose = state.navigationSyncPose - if (!pose || latestNavigationSyncPoseRef.current?.revision === pose.revision) { + const receiveFloorplanNavigationPose = useCallback( + (pose: NavigationSyncPose) => { + const previousPose = latestNavigationSyncPoseRef.current + if (previousPose?.source === pose.source && previousPose.revision === pose.revision) { return } @@ -7177,16 +7178,35 @@ export function FloorplanPanel({ if (pose.source === '3d') { syncFloorplanViewportToNavigationPose(pose) } - }) - return () => { - unsubscribe() - cancelHiddenCompassAnimation() + }, + [ + animateHiddenCompassNeedle, + cancelHiddenCompassAnimation, + syncFloorplanViewportToNavigationPose, + ], + ) + + useEffect( + () => subscribeFloorplanCameraNavigation(receiveFloorplanNavigationPose), + [receiveFloorplanNavigationPose], + ) + + useEffect(() => { + const receiveStoredNavigationPose = (state: ReturnType) => { + if (state.navigationSyncPose?.source === '2d') { + receiveFloorplanNavigationPose(state.navigationSyncPose) + } } - }, [ - syncFloorplanViewportToNavigationPose, - animateHiddenCompassNeedle, - cancelHiddenCompassAnimation, - ]) + receiveStoredNavigationPose(useEditor.getState()) + return useEditor.subscribe(receiveStoredNavigationPose) + }, [receiveFloorplanNavigationPose]) + + useEffect( + () => () => { + cancelHiddenCompassAnimation() + }, + [cancelHiddenCompassAnimation], + ) // When the panel is hidden the imperative path owns the compass needle. // React re-renders can overwrite the needle's inline transform with stale From 999baaa4aaf6904d8d336ee49117e9849f0c2709 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 15:11:40 +0530 Subject: [PATCH 5/5] perf(editor): isolate floorplan pose updates --- packages/core/src/events/bus.ts | 1 - .../renderers/floorplan-registry-layer.tsx | 58 ++++++++++++++----- .../editor/custom-camera-controls.tsx | 3 +- .../editor/floorplan-camera-sync.ts | 13 ++--- .../src/components/editor/floorplan-panel.tsx | 10 ++-- .../src/store/camera-pose-store.test.ts | 29 ++++++++++ .../editor/src/store/camera-pose-store.ts | 27 +++++++++ .../store/navigation-sync-pose-store.test.ts | 34 +++++++++++ .../src/store/navigation-sync-pose-store.ts | 27 +++++++++ packages/editor/src/store/use-editor.tsx | 16 ++--- 10 files changed, 180 insertions(+), 38 deletions(-) create mode 100644 packages/editor/src/store/camera-pose-store.test.ts create mode 100644 packages/editor/src/store/camera-pose-store.ts create mode 100644 packages/editor/src/store/navigation-sync-pose-store.test.ts create mode 100644 packages/editor/src/store/navigation-sync-pose-store.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 25aed8e77..ddb0ea38a 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -222,7 +222,6 @@ type CameraControlEvents = { 'camera-controls:orbit-ccw': undefined 'camera-controls:fit-scene': CameraControlFitSceneEvent 'camera-controls:generate-thumbnail': ThumbnailGenerateEvent - 'camera-controls:pose': CameraPose 'camera-controls:apply-pose': CameraPose 'camera-controls:cancel-pose': undefined 'camera-controls:interaction-start': undefined diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index cfd2a368e..1b530e16a 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1466,11 +1466,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const appliedLayoutInputsRef = useRef(null) const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) const sceneRotationDeg = useFloorplanSceneRotation() - const sceneNodes = useScene((state) => state.nodes) - const installedPlugins = useScene((state) => state.installedPlugins) - const liveTransforms = useLiveTransforms((state) => state.transforms) - const liveOverrides = useLiveNodeOverrides((state) => state.overrides) - const interactiveElevators = useInteractive((state) => state.elevators) + const [settledLayoutEpoch, setSettledLayoutEpoch] = useState(0) const drawingType = useDrawingView((state) => state.drawingType) const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) @@ -1482,22 +1478,16 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { annotationVisibility, annotationLayoutOverrides, drawingType, - installedPlugins, - interactiveElevators, - liveOverrides, - liveTransforms, - sceneNodes, + interactionIdle, + settledLayoutEpoch, wallDimensionReference, }), [ annotationVisibility, annotationLayoutOverrides, drawingType, - installedPlugins, - interactiveElevators, - liveOverrides, - liveTransforms, - sceneNodes, + interactionIdle, + settledLayoutEpoch, wallDimensionReference, ], ) @@ -1505,6 +1495,44 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) const layoutEnabled = active && interactionIdle + + useEffect(() => { + if (!layoutEnabled) return + let frame: number | null = null + const invalidateLayout = () => { + if (frame !== null) return + frame = window.requestAnimationFrame(() => { + frame = null + setSettledLayoutEpoch((epoch) => epoch + 1) + }) + } + const unsubscribeScene = useScene.subscribe((state, previousState) => { + if ( + state.nodes !== previousState.nodes || + state.installedPlugins !== previousState.installedPlugins + ) { + invalidateLayout() + } + }) + const unsubscribeTransforms = useLiveTransforms.subscribe((state, previousState) => { + if (state.transforms !== previousState.transforms) invalidateLayout() + }) + const unsubscribeOverrides = useLiveNodeOverrides.subscribe((state, previousState) => { + if (state.overrides !== previousState.overrides) invalidateLayout() + }) + const unsubscribeInteractive = useInteractive.subscribe((state, previousState) => { + if (state.elevators !== previousState.elevators) invalidateLayout() + }) + + return () => { + unsubscribeScene() + unsubscribeTransforms() + unsubscribeOverrides() + unsubscribeInteractive() + if (frame !== null) window.cancelAnimationFrame(frame) + } + }, [layoutEnabled]) + useLayoutEffect(() => { // Explicit layout inputs replace the former whole-subtree MutationObserver. if (!active) { diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index f6c515e14..0452929e1 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -29,6 +29,7 @@ import { withCameraPoseDistance, } from '../../lib/camera-pose' import { EDITOR_LAYER } from '../../lib/constants' +import { publishCameraPose } from '../../store/camera-pose-store' import useEditor from '../../store/use-editor' import { useActiveHandleDrag, @@ -600,7 +601,7 @@ export const CustomCameraControls = () => { ...(isPerspectiveCamera(camera) ? { fov: camera.fov } : {}), }) if (pose) { - emitter.emit('camera-controls:pose', pose) + publishCameraPose(pose) } }, [camera, isFirstPersonMode, viewportSize]) diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.ts b/packages/editor/src/components/editor/floorplan-camera-sync.ts index 5793a6855..3e7c37b5f 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.ts @@ -2,6 +2,8 @@ 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, @@ -220,14 +222,7 @@ export function useFloorplanCameraSyncBridge() { useEffect(() => bridge.setActive(active), [active, bridge]) - useEffect(() => { - const receiveCameraPose = (pose: CameraPose) => bridge.receiveCameraPose(pose) - emitter.on('camera-controls:pose', receiveCameraPose) - return () => emitter.off('camera-controls:pose', receiveCameraPose) - }, [bridge]) + useEffect(() => subscribeCameraPose(bridge.receiveCameraPose), [bridge]) - useEffect(() => { - bridge.receiveNavigationPose(useEditor.getState().navigationSyncPose) - return useEditor.subscribe((state) => bridge.receiveNavigationPose(state.navigationSyncPose)) - }, [bridge]) + useEffect(() => subscribeNavigationSyncPose(bridge.receiveNavigationPose), [bridge]) } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index f05aead08..41f277c7d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -95,6 +95,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' +import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store' import useAlignmentGuides from '../../store/use-alignment-guides' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor, { @@ -7192,13 +7193,12 @@ export function FloorplanPanel({ ) useEffect(() => { - const receiveStoredNavigationPose = (state: ReturnType) => { - if (state.navigationSyncPose?.source === '2d') { - receiveFloorplanNavigationPose(state.navigationSyncPose) + const receiveStoredNavigationPose = (pose: NavigationSyncPose | null) => { + if (pose?.source === '2d') { + receiveFloorplanNavigationPose(pose) } } - receiveStoredNavigationPose(useEditor.getState()) - return useEditor.subscribe(receiveStoredNavigationPose) + return subscribeNavigationSyncPose(receiveStoredNavigationPose) }, [receiveFloorplanNavigationPose]) useEffect( diff --git a/packages/editor/src/store/camera-pose-store.test.ts b/packages/editor/src/store/camera-pose-store.test.ts new file mode 100644 index 000000000..42cad113f --- /dev/null +++ b/packages/editor/src/store/camera-pose-store.test.ts @@ -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]) + }) +}) diff --git a/packages/editor/src/store/camera-pose-store.ts b/packages/editor/src/store/camera-pose-store.ts new file mode 100644 index 000000000..a6e7f3d26 --- /dev/null +++ b/packages/editor/src/store/camera-pose-store.ts @@ -0,0 +1,27 @@ +import type { CameraPose } from '@pascal-app/core' +import { subscribeWithSelector } from 'zustand/middleware' +import { createStore } from 'zustand/vanilla' + +type CameraPoseState = { + pose: CameraPose | null +} + +export const cameraPoseStore = createStore()( + subscribeWithSelector(() => ({ pose: null })), +) + +export function publishCameraPose(pose: CameraPose) { + cameraPoseStore.setState({ pose }) +} + +export function subscribeCameraPose(listener: (pose: CameraPose) => void) { + const currentPose = cameraPoseStore.getState().pose + if (currentPose) listener(currentPose) + + return cameraPoseStore.subscribe( + (state) => state.pose, + (pose) => { + if (pose) listener(pose) + }, + ) +} diff --git a/packages/editor/src/store/navigation-sync-pose-store.test.ts b/packages/editor/src/store/navigation-sync-pose-store.test.ts new file mode 100644 index 000000000..221ae165b --- /dev/null +++ b/packages/editor/src/store/navigation-sync-pose-store.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + navigationSyncPoseStore, + publishNavigationSyncPoseToStore, + subscribeNavigationSyncPose, +} from './navigation-sync-pose-store' +import type { NavigationSyncPose } from './use-editor' + +const pose = { + source: '2d' as const, + revision: 1, + target: [1, 2, 3] as [number, number, number], + azimuth: 0.5, + viewWidth: 12, +} + +afterEach(() => { + navigationSyncPoseStore.setState({ pose: null }) +}) + +describe('navigation sync pose store', () => { + test('replays the latest pose and streams only later pose writes', () => { + publishNavigationSyncPoseToStore(pose) + const received: NavigationSyncPose[] = [] + const unsubscribe = subscribeNavigationSyncPose((nextPose) => received.push(nextPose)) + const nextPose = { ...pose, revision: 2, viewWidth: 8 } + + publishNavigationSyncPoseToStore(nextPose) + unsubscribe() + publishNavigationSyncPoseToStore({ ...pose, revision: 3 }) + + expect(received).toEqual([pose, nextPose]) + }) +}) diff --git a/packages/editor/src/store/navigation-sync-pose-store.ts b/packages/editor/src/store/navigation-sync-pose-store.ts new file mode 100644 index 000000000..caa2ac806 --- /dev/null +++ b/packages/editor/src/store/navigation-sync-pose-store.ts @@ -0,0 +1,27 @@ +import { subscribeWithSelector } from 'zustand/middleware' +import { createStore } from 'zustand/vanilla' +import type { NavigationSyncPose } from './use-editor' + +type NavigationSyncPoseState = { + pose: NavigationSyncPose | null +} + +export const navigationSyncPoseStore = createStore()( + subscribeWithSelector(() => ({ pose: null })), +) + +export function publishNavigationSyncPoseToStore(pose: NavigationSyncPose) { + navigationSyncPoseStore.setState({ pose }) +} + +export function subscribeNavigationSyncPose(listener: (pose: NavigationSyncPose) => void) { + const currentPose = navigationSyncPoseStore.getState().pose + if (currentPose) listener(currentPose) + + return navigationSyncPoseStore.subscribe( + (state) => state.pose, + (pose) => { + if (pose) listener(pose) + }, + ) +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f6a0734b2..c9ec6e3f6 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -69,6 +69,7 @@ import { snapContextOf, snappingModesFor, } from '../lib/snapping-mode' +import { publishNavigationSyncPoseToStore } from './navigation-sync-pose-store' import useInteractionScope from './use-interaction-scope' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'build' @@ -1152,13 +1153,14 @@ const useEditor = create()( setRiserOpen: (open) => set({ isRiserOpen: open }), toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })), navigationSyncPose: null, - publishNavigationSyncPose: (pose) => - set((state) => ({ - navigationSyncPose: { - ...pose, - revision: (state.navigationSyncPose?.revision ?? 0) + 1, - }, - })), + publishNavigationSyncPose: (pose) => { + const navigationSyncPose = { + ...pose, + revision: (get().navigationSyncPose?.revision ?? 0) + 1, + } + publishNavigationSyncPoseToStore(navigationSyncPose) + set({ navigationSyncPose }) + }, floorplanSelectionTool: 'click' as FloorplanSelectionTool, setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,