From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 1/7] 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/7] 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 321b7103f57c770c1563b13710b3afb6d63cace2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 19:04:42 +0530 Subject: [PATCH 3/7] fix(viewer): keep outlines during camera movement --- .../src/components/viewer/post-processing.tsx | 1 - .../viewer/src/lib/merged-outline-node.test.ts | 14 ++++++++------ packages/viewer/src/lib/merged-outline-node.ts | 9 ++------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 74f2f5092..b4e83eaf7 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -526,7 +526,6 @@ const PostProcessingPasses = ({ let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { - enabled: () => !useViewer.getState().cameraDragging, primaryObjects: outliner.selectedObjects, secondaryObjects: outliner.hoveredObjects, primaryEdgeThickness: uniform(1), diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts index 34a81b5bc..3b22768f0 100644 --- a/packages/viewer/src/lib/merged-outline-node.test.ts +++ b/packages/viewer/src/lib/merged-outline-node.test.ts @@ -5,18 +5,20 @@ import { Object3D, PerspectiveCamera, Scene } from 'three' import { mergedOutline } from './merged-outline-node' describe('merged outline rendering', () => { - test('skips outline work while the pass is disabled', () => { - const outline = mergedOutline(new Scene(), new PerspectiveCamera(), { - enabled: () => false, + test('keeps selected outlines active during camera interaction', () => { + const cameraInteractionActive = true + const params = { + enabled: () => !cameraInteractionActive, primaryObjects: [new Object3D()], - }) + } + const outline = mergedOutline(new Scene(), new PerspectiveCamera(), params) const frame = { get renderer(): never { - throw new Error('outline renderer should not be touched') + throw new Error('outline renderer was reached') }, } - expect(() => outline.updateBefore(frame)).not.toThrow() + expect(() => outline.updateBefore(frame)).toThrow('outline renderer was reached') outline.dispose() }) }) diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index e7dbdcd84..4c621ffb8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -125,7 +125,6 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlowNode: any secondaryEdgeGlowNode: any downSampleRatio: number - enabled: () => boolean updateBeforeType: string private readonly _depthRT: RenderTarget @@ -190,7 +189,6 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow?: any secondaryEdgeGlow?: any downSampleRatio?: number - enabled?: () => boolean } = {}, ) { super('vec4') @@ -203,7 +201,6 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow = float(0), secondaryEdgeGlow = float(0), downSampleRatio = 2, - enabled = () => true, } = params this.scene = scene @@ -215,7 +212,6 @@ export class MergedOutlineNode extends TempNode { this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) this.downSampleRatio = downSampleRatio - this.enabled = enabled this.updateBeforeType = NodeUpdateType.FRAME this._depthRT = new RenderTarget() @@ -305,9 +301,8 @@ export class MergedOutlineNode extends TempNode { } updateBefore(frame: any) { - const enabled = this.enabled() - const hasPrimary = enabled && this.primaryObjects.length > 0 - const hasSecondary = enabled && this.secondaryObjects.length > 0 + const hasPrimary = this.primaryObjects.length > 0 + const hasSecondary = this.secondaryObjects.length > 0 const hasAny = hasPrimary || hasSecondary // Fast-path: nothing to render and nothing was rendered last frame either, From 81d48320dfc5d6837faba634bcf41ae2359c5c6a Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 19:29:53 +0530 Subject: [PATCH 4/7] fix(editor): prevent floorplan clipping during rotation --- .../floorplan-navigation-presentation.test.ts | 27 +++++++++++++++++ .../floorplan-navigation-presentation.ts | 15 ++++++++++ .../src/components/editor/floorplan-panel.tsx | 29 ++++++++++++------- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts index 130722e77..29b21b961 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -4,10 +4,37 @@ import { canZoomFloorplanDuringNavigation, createFloorplanNavigationSyncScheduler, finalizeFloorplanNavigation, + getFloorplanRotationOverscanViewBox, resolveFloorplanPresentationViewBox, } from './floorplan-navigation-presentation' describe('floorplan navigation presentation', () => { + test('keeps the viewport inside the painted floorplan surface throughout rotation', () => { + const viewport = { minX: -80, minY: -45, width: 160, height: 90 } + const overscan = getFloorplanRotationOverscanViewBox(viewport) + const viewportCorners: Array<[number, number]> = [ + [-80, -45], + [80, -45], + [80, 45], + [-80, 45], + ] + + for (let degrees = 0; degrees < 360; degrees += 1) { + const radians = (-degrees * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + + for (const [x, y] of viewportCorners) { + const localX = x * cos - y * sin + const localY = x * sin + y * cos + expect(localX).toBeGreaterThanOrEqual(overscan.minX - 1e-10) + expect(localX).toBeLessThanOrEqual(overscan.minX + overscan.width + 1e-10) + expect(localY).toBeGreaterThanOrEqual(overscan.minY - 1e-10) + expect(localY).toBeLessThanOrEqual(overscan.minY + overscan.height + 1e-10) + } + } + }) + test('keeps the imperative viewBox authoritative during navigation', () => { const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 } const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 } diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts index b832f62f0..9a341d673 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -5,6 +5,21 @@ export type FloorplanPresentationViewBox = { height: number } +export function getFloorplanRotationOverscanViewBox( + viewBox: FloorplanPresentationViewBox, +): FloorplanPresentationViewBox { + const size = Math.hypot(viewBox.width, viewBox.height) + const centerX = viewBox.minX + viewBox.width / 2 + const centerY = viewBox.minY + viewBox.height / 2 + + return { + minX: centerX - size / 2, + minY: centerY - size / 2, + width: size, + height: size, + } +} + export function resolveFloorplanPresentationViewBox( reactViewBox: FloorplanPresentationViewBox, imperativeViewBox: FloorplanPresentationViewBox | null, diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 41f277c7d..4a19e388b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -203,6 +203,7 @@ import { type FloorplanNavigationSyncScheduler, type FloorplanPresentationViewBox, finalizeFloorplanNavigation, + getFloorplanRotationOverscanViewBox, resolveFloorplanPresentationViewBox, } from './floorplan-navigation-presentation' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' @@ -6611,12 +6612,14 @@ export function FloorplanPanel({ const nextHeight = nextViewport.width / svgAspectRatio const nextMinX = nextViewport.centerX - nextViewport.width / 2 const nextMinY = nextViewport.centerY - nextHeight / 2 - floorplanImperativeViewBoxRef.current = { + const nextViewBox = { minX: nextMinX, minY: nextMinY, width: nextViewport.width, height: nextHeight, } + const nextPaintViewBox = getFloorplanRotationOverscanViewBox(nextViewBox) + floorplanImperativeViewBoxRef.current = nextViewBox hasUserAdjustedViewportRef.current = true latestViewportRef.current = nextViewport svgRef.current?.setAttribute( @@ -6625,10 +6628,10 @@ export function FloorplanPanel({ ) 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)) + background.setAttribute('x', String(nextPaintViewBox.minX)) + background.setAttribute('y', String(nextPaintViewBox.minY)) + background.setAttribute('width', String(nextPaintViewBox.width)) + background.setAttribute('height', String(nextPaintViewBox.height)) } }, [svgAspectRatio], @@ -7328,6 +7331,7 @@ export function FloorplanPanel({ floorplanImperativeViewBoxRef.current, floorplanViewportInteractionInProgressRef.current, ) + const presentationPaintViewBox = getFloorplanRotationOverscanViewBox(presentationViewBox) const floorplanWorldUnitsPerPixel = useMemo(() => { const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1) @@ -7603,7 +7607,11 @@ export function FloorplanPanel({ [surfaceSize.width, viewBox.width], ) const gridBounds = useMemo( - () => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg), + () => + getRotatedViewBoxBounds( + getFloorplanRotationOverscanViewBox(viewBox), + floorplanSceneRotationDeg, + ), [floorplanSceneRotationDeg, viewBox], ) @@ -11877,6 +11885,7 @@ export function FloorplanPanel({ style={{ cursor: floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR), + overflow: 'visible', }} viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`} > @@ -11916,11 +11925,11 @@ export function FloorplanPanel({ Date: Fri, 24 Jul 2026 19:47:43 +0530 Subject: [PATCH 5/7] fix(editor): keep compass rotation in sync Stream live headings in 2D and 3D, and defer restoring the compositor rotation preview until the committed floor-plan state is ready to paint so pointer release cannot snap back. --- .../editor/floorplan-camera-sync.test.ts | 27 ++++++------ .../editor/floorplan-camera-sync.ts | 17 ++------ .../floorplan-navigation-presentation.test.ts | 39 +++++++++++++++++ .../floorplan-navigation-presentation.ts | 42 +++++++++++++++++++ .../src/components/editor/floorplan-panel.tsx | 32 +++++++------- 5 files changed, 116 insertions(+), 41 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts index 03e62e6ea..c1dd19e21 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts @@ -105,7 +105,7 @@ describe('floorplan camera sync', () => { expect(applied[0]?.position[2]).toBeCloseTo(20) }) - test('does not publish floor-plan rotation below one degree', () => { + test('publishes sub-degree camera rotation for a real-time compass', () => { const published: Array> = [] const bridge = createFloorplanCameraSyncBridge({ applyCameraPose: () => {}, @@ -114,14 +114,11 @@ describe('floorplan camera sync', () => { 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) + expect(published[1]?.azimuth).toBeCloseTo((0.9 * Math.PI) / 180) }) - test('keeps the previous floor-plan angle when a sub-degree rotation arrives with a pan', () => { + test('keeps a sub-degree camera rotation when it arrives with a pan', () => { const published: Array> = [] const bridge = createFloorplanCameraSyncBridge({ applyCameraPose: () => {}, @@ -134,11 +131,11 @@ describe('floorplan camera sync', () => { expect(published).toHaveLength(2) expect(published[1]).toMatchObject({ target: [1, 1, 0], - azimuth: 0, }) + expect(published[1]?.azimuth).toBeCloseTo((0.5 * Math.PI) / 180) }) - test('does no floor-plan synchronization in 3D-only mode and catches up once when visible', () => { + test('keeps streaming live camera headings in 3D-only mode', () => { const published: Array> = [] const applied: CameraPose[] = [] const bridge = createFloorplanCameraSyncBridge({ @@ -158,11 +155,13 @@ describe('floorplan camera sync', () => { viewWidth: 8, }) - expect(published).toEqual([]) - expect(applied).toEqual([]) - - bridge.setActive(true) expect(published).toEqual([ + { + source: '3d', + target: cameraPose.target, + azimuth: 0, + viewWidth: 12, + }, { source: '3d', target: latestPose.target, @@ -171,6 +170,10 @@ describe('floorplan camera sync', () => { }, ]) expect(applied).toEqual([]) + + bridge.setActive(true) + expect(published).toHaveLength(2) + expect(applied).toEqual([]) }) test('waits for a generic camera reference before applying an early 2D pose', () => { diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.ts b/packages/editor/src/components/editor/floorplan-camera-sync.ts index 3e7c37b5f..80ff42950 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.ts @@ -10,7 +10,7 @@ import useEditor, { } from '../../store/use-editor' const POSITION_EPSILON = 0.001 -const AZIMUTH_EPSILON = Math.PI / 180 +const AZIMUTH_EPSILON = 1e-4 const VIEW_WIDTH_EPSILON = 0.001 type FloorplanNavigationSnapshot = Omit @@ -151,18 +151,8 @@ export function createFloorplanCameraSyncBridge({ } const publishCameraNavigationPose = (pose: CameraPose) => { - let navigationPose = cameraPoseToFloorplanNavigationPose(pose) + const 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 && @@ -178,8 +168,7 @@ export function createFloorplanCameraSyncBridge({ return { receiveCameraPose: (pose) => { latestCameraPose = pose - if (!active) return - if (applyPendingNavigationPose()) return + if (active && applyPendingNavigationPose()) return publishCameraNavigationPose(pose) }, diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts index 29b21b961..39017d042 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -4,8 +4,11 @@ import { canZoomFloorplanDuringNavigation, createFloorplanNavigationSyncScheduler, finalizeFloorplanNavigation, + flushFloorplanRotationPresentationRestore, getFloorplanRotationOverscanViewBox, + queueFloorplanRotationPresentationRestore, resolveFloorplanPresentationViewBox, + setFloorplanCompassRotation, } from './floorplan-navigation-presentation' describe('floorplan navigation presentation', () => { @@ -57,6 +60,42 @@ describe('floorplan navigation presentation', () => { expect(canApplyFloorplanNavigationSync(false)).toBe(true) }) + test('updates the compass presentation on every live rotation frame', () => { + const compass = { style: { transform: '' } } + + setFloorplanCompassRotation(compass, 0.25) + expect(compass.style.transform).toBe('rotate(0.25deg)') + + setFloorplanCompassRotation(compass, 0.5) + expect(compass.style.transform).toBe('rotate(0.5deg)') + }) + + test('keeps the rotation preview in place until the committed state is ready to paint', () => { + const pending = { current: null } + const svg = { + style: { + transform: 'rotate(42deg)', + transformOrigin: 'center', + willChange: 'transform', + }, + } + const presentation = { + svg, + svgStyle: { + transform: '', + transformOrigin: '', + willChange: '', + }, + } + + queueFloorplanRotationPresentationRestore(pending, presentation) + expect(svg.style.transform).toBe('rotate(42deg)') + + flushFloorplanRotationPresentationRestore(pending) + expect(svg.style.transform).toBe('') + expect(pending.current).toBeNull() + }) + test('commits every active navigation channel before teardown', () => { const calls: string[] = [] const rotationState = { angle: 42 } diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts index 9a341d673..96a743ff7 100644 --- a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -5,6 +5,48 @@ export type FloorplanPresentationViewBox = { height: number } +export function setFloorplanCompassRotation( + compass: { style: { transform: string } } | null, + rotationDeg: number, +): void { + if (compass) { + compass.style.transform = `rotate(${rotationDeg}deg)` + } +} + +type FloorplanRotationPresentation = { + svg: { + style: { + transform: string + transformOrigin: string + willChange: string + } + } + svgStyle: { + transform: string + transformOrigin: string + willChange: string + } +} + +export function queueFloorplanRotationPresentationRestore< + Presentation extends FloorplanRotationPresentation, +>(pending: { current: Presentation | null }, presentation: Presentation): void { + pending.current = presentation +} + +export function flushFloorplanRotationPresentationRestore< + Presentation extends FloorplanRotationPresentation, +>(pending: { current: Presentation | null }): void { + const presentation = pending.current + if (!presentation) return + + presentation.svg.style.transform = presentation.svgStyle.transform + presentation.svg.style.transformOrigin = presentation.svgStyle.transformOrigin + presentation.svg.style.willChange = presentation.svgStyle.willChange + pending.current = null +} + export function getFloorplanRotationOverscanViewBox( viewBox: FloorplanPresentationViewBox, ): FloorplanPresentationViewBox { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 4a19e388b..07739f78b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -203,8 +203,11 @@ import { type FloorplanNavigationSyncScheduler, type FloorplanPresentationViewBox, finalizeFloorplanNavigation, + flushFloorplanRotationPresentationRestore, getFloorplanRotationOverscanViewBox, + queueFloorplanRotationPresentationRestore, resolveFloorplanPresentationViewBox, + setFloorplanCompassRotation, } from './floorplan-navigation-presentation' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' @@ -359,12 +362,6 @@ type FloorplanNavigationSyncPresentationState = { } } -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, ) { @@ -5344,6 +5341,7 @@ export function FloorplanPanel({ const floorplanContentRef = useRef(null) const panStateRef = useRef(null) const floorplanRotationStateRef = useRef(null) + const pendingFloorplanRotationRestoreRef = useRef(null) const floorplanSpacePanPressedRef = useRef(false) const floorplanNavigationClickSuppressedRef = useRef(false) const guideInteractionRef = useRef(null) @@ -7137,9 +7135,7 @@ export function FloorplanPanel({ nextDeg = targetDeg } latestFloorplanUserRotationDegRef.current = nextDeg - if (compassNeedleRef.current) { - compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)` - } + setFloorplanCompassRotation(compassNeedleRef.current, nextDeg) if (nextDeg !== targetDeg) { hiddenCompassAnimationRef.current = requestAnimationFrame(tick) } @@ -7170,9 +7166,7 @@ export function FloorplanPanel({ // owns the needle, so any local animation yields to it. cancelHiddenCompassAnimation() latestFloorplanUserRotationDegRef.current = nextDeg - if (compassNeedleRef.current) { - compassNeedleRef.current.style.transform = `rotate(${nextDeg}deg)` - } + setFloorplanCompassRotation(compassNeedleRef.current, nextDeg) } else { animateHiddenCompassNeedle(nextDeg) } @@ -7216,8 +7210,11 @@ export function FloorplanPanel({ // state; this layout effect restores the authoritative ref value before // the browser paints so the needle never visibly snaps to a stale angle. useLayoutEffect(() => { - if (!isFloorplanOpen && compassNeedleRef.current) { - compassNeedleRef.current.style.transform = `rotate(${latestFloorplanUserRotationDegRef.current}deg)` + if (!isFloorplanOpen) { + setFloorplanCompassRotation( + compassNeedleRef.current, + latestFloorplanUserRotationDegRef.current, + ) } }) @@ -8185,6 +8182,7 @@ export function FloorplanPanel({ hasUserAdjustedViewportRef.current = true latestFloorplanUserRotationDegRef.current = nextUserRotationDeg latestViewportRef.current = nextViewport + setFloorplanCompassRotation(compassNeedleRef.current, nextUserRotationDeg) // Transform the already-painted SVG as one compositor layer. Mutating the // scene rotation/viewBox here forces the heavy vector plan to rerasterize. rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)` @@ -8356,7 +8354,7 @@ export function FloorplanPanel({ (rotationState: FloorplanRotationState) => { floorplanViewportInteractionInProgressRef.current = false floorplanImperativeViewBoxRef.current = null - restoreFloorplanRotationPresentation(rotationState) + queueFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef, rotationState) setFloorplanUserRotationDeg((current) => current === rotationState.latestUserRotationDeg ? current @@ -8376,6 +8374,10 @@ export function FloorplanPanel({ [publishFloorplanNavigationPose], ) + useLayoutEffect(() => { + flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef) + }) + // Finalize imperative navigation when the floorplan closes so reopening // restores the last visible pose instead of stale React state. useEffect(() => { From dd98a761e0eee826bb2e40089269004d7b63fe97 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 20:30:28 +0530 Subject: [PATCH 6/7] fix(editor): stabilize floorplan and camera sync --- .../floorplan-render-context.test.ts | 13 +++++ .../editor-2d/floorplan-render-context.tsx | 46 +++++++++++++++-- .../floorplan-registry-layer.test.ts | 12 +++++ .../renderers/floorplan-registry-layer.tsx | 26 ++++++---- .../editor/custom-camera-controls.tsx | 10 +++- .../editor/floorplan-camera-sync.test.ts | 5 +- .../src/components/tools/tool-manager.tsx | 23 +++++++-- .../tools/wall/wall-drafting.test.ts | 2 +- packages/editor/src/lib/camera-pose.test.ts | 24 +++++++++ packages/editor/src/lib/camera-pose.ts | 12 +++++ .../lib/interaction/overlay-policy.test.ts | 2 +- packages/editor/src/lib/interaction/scope.ts | 41 ++++++++++++--- .../src/store/use-interaction-scope.test.ts | 25 ++++++++-- .../editor/src/store/use-interaction-scope.ts | 8 +++ .../src/wall/floorplan-affordances.test.ts | 50 +++++++++++++++++++ 15 files changed, 262 insertions(+), 37 deletions(-) create mode 100644 packages/nodes/src/wall/floorplan-affordances.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 index 748fc13bd..1c182b76b 100644 --- a/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts +++ b/packages/editor/src/components/editor-2d/floorplan-render-context.test.ts @@ -11,4 +11,17 @@ describe('floorplan render context', () => { expect(scale.read).toBe(read) expect(read()).toBe(0.01) }) + + test('notifies selected-handle renderers when a hidden floorplan becomes visible', () => { + const scale = createFloorplanRenderScaleReference(30) + let renderedCurveHandleRadius = 8 * scale.read() + const unsubscribe = scale.subscribe(() => { + renderedCurveHandleRadius = 8 * scale.read() + }) + + scale.update(0.02) + + expect(renderedCurveHandleRadius).toBe(0.16) + unsubscribe() + }) }) 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 38b61ebc0..93c54d116 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,15 @@ 'use client' import type { FloorplanPalette } from '@pascal-app/core' -import { createContext, type ReactNode, useContext, useMemo, useRef } from 'react' +import { + createContext, + type ReactNode, + useContext, + useLayoutEffect, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' /** * Per-frame render context shared between the legacy `floorplan-panel.tsx` @@ -40,6 +48,7 @@ export type FloorplanStaticRenderContextValue = Omit< > & { getSceneRotationDeg: () => number getUnitsPerPixel: () => number + subscribeUnitsPerPixel: (listener: () => void) => () => void } const FloorplanStaticRenderContext = createContext(null) @@ -48,6 +57,7 @@ const FloorplanUnitsPerPixelContext = createContext(1) export type FloorplanRenderScaleReference = { read: () => number + subscribe: (listener: () => void) => () => void update: (unitsPerPixel: number) => void } @@ -55,10 +65,17 @@ export function createFloorplanRenderScaleReference( initialUnitsPerPixel: number, ): FloorplanRenderScaleReference { let unitsPerPixel = initialUnitsPerPixel + const listeners = new Set<() => void>() return { read: () => unitsPerPixel, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, update: (nextUnitsPerPixel) => { + if (Object.is(unitsPerPixel, nextUnitsPerPixel)) return unitsPerPixel = nextUnitsPerPixel + for (const listener of listeners) listener() }, } } @@ -78,11 +95,20 @@ export function FloorplanRenderProvider({ if (!renderScaleReference.current) { renderScaleReference.current = createFloorplanRenderScaleReference(unitsPerPixel) } - renderScaleReference.current.update(unitsPerPixel) const getUnitsPerPixel = renderScaleReference.current.read + const subscribeUnitsPerPixel = renderScaleReference.current.subscribe + useLayoutEffect(() => { + renderScaleReference.current?.update(unitsPerPixel) + }, [unitsPerPixel]) const staticValue = useMemo( - () => ({ palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel }), - [palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel], + () => ({ + palette, + hatchPatternId, + getSceneRotationDeg, + getUnitsPerPixel, + subscribeUnitsPerPixel, + }), + [palette, hatchPatternId, getSceneRotationDeg, getUnitsPerPixel, subscribeUnitsPerPixel], ) return ( @@ -124,6 +150,18 @@ export function useFloorplanStaticRender(): FloorplanStaticRenderContextValue | return useContext(FloorplanStaticRenderContext) } +const subscribeToNoFloorplanScale = () => () => {} +const readDefaultFloorplanScale = () => 1 + +export function useFloorplanStaticUnitsPerPixel(): number { + const staticValue = useContext(FloorplanStaticRenderContext) + return useSyncExternalStore( + staticValue?.subscribeUnitsPerPixel ?? subscribeToNoFloorplanScale, + staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale, + staticValue?.getUnitsPerPixel ?? readDefaultFloorplanScale, + ) +} + export function useFloorplanSceneRotation(): number { return useContext(FloorplanSceneRotationContext) } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index c8ef7df1f..ff9acbcc6 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -19,12 +19,24 @@ import { collectFloorplanDependencyNodes, collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, + floorplanAffordanceReshapeScope, floorplanHandleDoubleClickAffordance, InteractiveGeometry, splitFloorplanOverlay, subscribeFloorplanAffordanceToolCancel, } from './floorplan-registry-layer' +describe('floorplan affordance ownership', () => { + test('keeps the wall center curve drag owned by the floorplan dispatcher', () => { + expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({ + kind: 'reshaping', + nodeId: 'wall_1', + reshape: 'curve', + driver: 'floorplan', + }) + }) +}) + function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') { return { id, 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 1b530e16a..7487442f6 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,7 +88,11 @@ import { startFloorplanGroupMove, startFloorplanGroupRotate, } from '../floorplan-group-move' -import { useFloorplanSceneRotation, useFloorplanStaticRender } from '../floorplan-render-context' +import { + useFloorplanSceneRotation, + useFloorplanStaticRender, + useFloorplanStaticUnitsPerPixel, +} from '../floorplan-render-context' import { floorplanAnnotationObstacleMode, isFloorplanAnnotationObstacleGeometry, @@ -235,7 +239,7 @@ export function subscribeFloorplanAffordanceToolCancel( // affordances return `null` (no polygon/wall snapping chip). Keyed off the // affordance name the kinds register (`move-vertex` / `move-edge` / `add-vertex` // / `curve` / `move-endpoint`). -function affordanceReshapeScope( +export function floorplanAffordanceReshapeScope( affordance: string, nodeId: string, payload: unknown, @@ -243,30 +247,30 @@ function affordanceReshapeScope( if (affordance.includes('vertex') || affordance.includes('edge')) { const holeIndex = (payload as { holeIndex?: number } | undefined)?.holeIndex return holeIndex !== undefined - ? holeEditScope({ nodeId, holeIndex }) - : boundaryReshapeScope(nodeId) + ? holeEditScope({ nodeId, holeIndex, driver: 'floorplan' }) + : boundaryReshapeScope(nodeId, 'floorplan') } if (affordance.includes('curve')) { - return curveReshapeScope(nodeId) + return curveReshapeScope(nodeId, 'floorplan') } if (affordance.includes('control-point')) { const index = (payload as { index?: number } | undefined)?.index ?? 0 - return controlPointReshapeScope(nodeId, index) + return controlPointReshapeScope(nodeId, index, 'floorplan') } if (affordance.includes('tangent')) { const target = payload as { index?: number; side?: 'in' | 'out' } | undefined - return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out') + return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out', 'floorplan') } if (affordance.includes('endpoint')) { const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end' - return endpointReshapeScope(nodeId, endpoint) + return endpointReshapeScope(nodeId, endpoint, 'floorplan') } // Roof-segment width/depth resize — a no-angle dimension edit, so the // no-angle 'polygon' snap set (grid / lines / off) via a boundary scope. // Matched exactly so a still-legacy `*-resize` affordance on another kind // doesn't get a chip its snap math can't honour yet. if (affordance === 'roof-segment-resize') { - return boundaryReshapeScope(nodeId) + return boundaryReshapeScope(nodeId, 'floorplan') } // 2D corner rotate-arrow (column / elevator / roof-segment / shelf / spawn / // stair). Begin the same handle-drag scope the 3D rotate gizmo uses, label- @@ -421,6 +425,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = selectedLevelId ?? ambientLevelId const isAmbient = !selectedLevelId && !!ambientLevelId const renderCtx = useFloorplanStaticRender() + const unitsPerPixel = useFloorplanStaticUnitsPerPixel() const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) @@ -1059,7 +1064,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // the right chip during the edit AND `getActiveSnapContext()` resolves the // polygon / wall mode-set the affordance's snap math reads. Torn down on // release / cancel below. `null` for resize / rotate (no snapping chip). - const reshapeScope = affordanceReshapeScope(affordance, nodeId, payload) + const reshapeScope = floorplanAffordanceReshapeScope(affordance, nodeId, payload) if (reshapeScope) { useInteractionScope.getState().begin(reshapeScope) } @@ -1305,7 +1310,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const entries = floorplanData.entries if (entries.length === 0) return null - const unitsPerPixel = renderCtx?.getUnitsPerPixel() ?? 1 const palette = renderCtx?.palette return ( diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0452929e1..fcbb802ff 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -25,6 +25,8 @@ import { type CameraPoseApplicationPlan, normalizeCameraPose, planCameraPoseApplication, + publishInitialCameraPose, + releaseCameraPoseEventSuppression, stepCameraPoseInterpolation, withCameraPoseDistance, } from '../../lib/camera-pose' @@ -609,6 +611,10 @@ export const CustomCameraControls = () => { publishCurrentPose() }, [publishCurrentPose]) + useEffect(() => { + publishInitialCameraPose(publishCurrentPose) + }, [publishCurrentPose]) + useFrame((_, delta) => { if (isFirstPersonMode || !controls.current) return @@ -645,12 +651,12 @@ export const CustomCameraControls = () => { } catch { if (activePoseInterpolation.current === activePose) { activePoseInterpolation.current = null - suppressPoseEvents.current = false + releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose) } } if (step.settled && activePoseInterpolation.current === activePose) { activePoseInterpolation.current = null - suppressPoseEvents.current = false + releaseCameraPoseEventSuppression(suppressPoseEvents, publishCurrentPose) } } } diff --git a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts index c1dd19e21..41381c8ed 100644 --- a/packages/editor/src/components/editor/floorplan-camera-sync.test.ts +++ b/packages/editor/src/components/editor/floorplan-camera-sync.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { CameraPose } from '@pascal-app/core' +import { publishInitialCameraPose } from '../../lib/camera-pose' import type { NavigationSyncPose } from '../../store/use-editor' import { cameraPoseToFloorplanNavigationPose, @@ -176,7 +177,7 @@ describe('floorplan camera sync', () => { expect(applied).toEqual([]) }) - test('waits for a generic camera reference before applying an early 2D pose', () => { + test('applies 2D-first navigation on initial load without a 3D interaction', () => { const applied: CameraPose[] = [] const bridge = createFloorplanCameraSyncBridge({ applyCameraPose: (pose) => applied.push(pose), @@ -192,7 +193,7 @@ describe('floorplan camera sync', () => { }) expect(applied).toEqual([]) - bridge.receiveCameraPose(cameraPose) + publishInitialCameraPose(() => bridge.receiveCameraPose(cameraPose)) expect(applied).toHaveLength(1) }) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d3b0c5233..80d0a8d7e 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -17,6 +17,8 @@ import { useEditingHole, useEndpointReshape, useIsCurveReshape, + useIsFloorplanDrivenReshape, + useIsToolDrivenReshape, useMovingNode, useReshapingNode, useTangentReshape, @@ -103,6 +105,8 @@ export const ToolManager: React.FC = () => { const controlPointReshape = useControlPointReshape() const tangentReshape = useTangentReshape() const isCurveReshape = useIsCurveReshape() + const isToolDrivenReshape = useIsToolDrivenReshape() + const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape() const reshapingNode = useReshapingNode() // The endpoint affordance tool's `target` is kind-specific // (`{ wall | fence, endpoint }`); rebuild it from the (frozen) reshaped node + @@ -173,6 +177,7 @@ export const ToolManager: React.FC = () => { mode === 'select' && isSoleSelection && selectedSlabId !== undefined && + !isFloorplanDrivenReshape && !editingSlabHoleIsManual // Show slab hole editor when editing a hole on the selected slab @@ -180,6 +185,7 @@ export const ToolManager: React.FC = () => { selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId && + !isFloorplanDrivenReshape && editingSlabHoleIsManual // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole) @@ -188,13 +194,15 @@ export const ToolManager: React.FC = () => { mode === 'select' && isSoleSelection && selectedCeilingId !== undefined && + !isFloorplanDrivenReshape && (!editingHole || editingHole.nodeId !== selectedCeilingId) // Show ceiling hole editor when editing a hole on the selected ceiling const showCeilingHoleEditor = selectedCeilingId !== undefined && editingHole !== null && - editingHole.nodeId === selectedCeilingId + editingHole.nodeId === selectedCeilingId && + !isFloorplanDrivenReshape // Show zone boundary editor when in structure/select mode with a zone selected // Hide when editing a slab or ceiling to avoid overlapping handles @@ -202,6 +210,7 @@ export const ToolManager: React.FC = () => { phase === 'structure' && mode === 'select' && selectedZoneId !== null && + !isFloorplanDrivenReshape && !showSlabBoundaryEditor && !showCeilingBoundaryEditor @@ -301,7 +310,8 @@ export const ToolManager: React.FC = () => { ) : null })()} - {endpointTarget && + {isToolDrivenReshape && + endpointTarget && reshapingNode && (() => { const RegistryAffordance = getRegistryAffordanceTool( @@ -314,7 +324,8 @@ export const ToolManager: React.FC = () => { ) : null })()} - {isCurveReshape && + {isToolDrivenReshape && + isCurveReshape && reshapingNode && (() => { const RegistryAffordance = getRegistryAffordanceTool(reshapingNode.type, 'curve') @@ -324,7 +335,8 @@ export const ToolManager: React.FC = () => { ) : null })()} - {controlPointTarget && + {isToolDrivenReshape && + controlPointTarget && (() => { const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point') return RegistryAffordance ? ( @@ -333,7 +345,8 @@ export const ToolManager: React.FC = () => { ) : null })()} - {tangentTarget && + {isToolDrivenReshape && + tangentTarget && (() => { const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent') return RegistryAffordance ? ( diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 611d3c8f5..40703ca04 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -86,7 +86,7 @@ describe('createWallOnCurrentLevel', () => { useEditor.getState().setSnappingMode('wall', 'lines') useInteractionScope .getState() - .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' }) + .begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint', driver: 'tool' }) seedLevel([makeWall([0, 0], [4, 0], 'wall_a')]) useScene.temporal.getState().clear() useScene.temporal.getState().resume() diff --git a/packages/editor/src/lib/camera-pose.test.ts b/packages/editor/src/lib/camera-pose.test.ts index 946b1c4e3..0ba49fffe 100644 --- a/packages/editor/src/lib/camera-pose.test.ts +++ b/packages/editor/src/lib/camera-pose.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import { normalizeCameraPose, planCameraPoseApplication, + publishInitialCameraPose, + releaseCameraPoseEventSuppression, stepCameraPoseInterpolation, withCameraPoseDistance, } from './camera-pose' @@ -163,6 +165,28 @@ describe('camera pose', () => { }) }) + test('publishes the initial pose before any camera control update event', () => { + let publishCount = 0 + + publishInitialCameraPose(() => { + publishCount += 1 + }) + + expect(publishCount).toBe(1) + }) + + test('publishes the settled pose when programmatic interpolation releases suppression', () => { + const suppression = { current: true } + let publishCount = 0 + + releaseCameraPoseEventSuppression(suppression, () => { + publishCount += 1 + }) + + expect(suppression.current).toBe(false) + expect(publishCount).toBe(1) + }) + test('retargets the bounded interpolation owner to the latest pose', () => { const first = stepCameraPoseInterpolation( [0, 0, 0], diff --git a/packages/editor/src/lib/camera-pose.ts b/packages/editor/src/lib/camera-pose.ts index d4bd7158e..57961d195 100644 --- a/packages/editor/src/lib/camera-pose.ts +++ b/packages/editor/src/lib/camera-pose.ts @@ -17,6 +17,18 @@ export type CameraPoseInterpolationStep = { target: [number, number, number] } +export function publishInitialCameraPose(publishCurrentPose: () => void): void { + publishCurrentPose() +} + +export function releaseCameraPoseEventSuppression( + suppression: { current: boolean }, + publishCurrentPose: () => void, +): void { + suppression.current = false + publishCurrentPose() +} + function finiteVectorTuple(value: unknown): [number, number, number] | null { if ( !( diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts index 13912aa18..aa7860767 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.test.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -17,7 +17,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [ { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' }, { kind: 'handle-drag', nodeId: 'w1', handle: 'height' }, { kind: 'drafting', tool: 'wall' }, - { kind: 'reshaping', nodeId: 's1', reshape: 'hole', holeIndex: 0 }, + { kind: 'reshaping', nodeId: 's1', reshape: 'hole', driver: 'tool', holeIndex: 0 }, { kind: 'box-select' }, { kind: 'painting' }, ] diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 796fdcea0..a8a8cd5fb 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -13,6 +13,7 @@ import type { AnyNode } from '@pascal-app/core' export type InteractionView = '2d' | '3d' +export type ReshapeDriver = 'tool' | 'floorplan' // Endpoint/curve/hole/boundary edits are all "reshape the selected node" — one // node, one in-flight reshape. Grouping them as sub-states of `reshaping` @@ -47,6 +48,8 @@ export type InteractionScope = kind: 'reshaping' nodeId: string reshape: ReshapeKind + // Floorplan affordances own their commit and must not mount a second 3D reshape tool. + driver: ReshapeDriver holeIndex?: number endpoint?: 'start' | 'end' index?: number @@ -126,12 +129,14 @@ export function editingHoleInfo( export function holeEditScope(target: { nodeId: string holeIndex: number + driver?: ReshapeDriver }): ActiveInteractionScope { return { kind: 'reshaping', nodeId: target.nodeId, reshape: 'hole', holeIndex: target.holeIndex, + driver: target.driver ?? 'tool', } } @@ -142,6 +147,14 @@ export function isCurveReshape(scope: InteractionScope): boolean { return scope.kind === 'reshaping' && scope.reshape === 'curve' } +export function isToolDrivenReshape(scope: InteractionScope): boolean { + return scope.kind === 'reshaping' && scope.driver === 'tool' +} + +export function isFloorplanDrivenReshape(scope: InteractionScope): boolean { + return scope.kind === 'reshaping' && scope.driver === 'floorplan' +} + // The legacy `movingWallEndpoint` / `movingFenceEndpoint` flags minus the node // itself (consumers fetch the node from the scene by `nodeId`; it is stable for // the duration of the drag). @@ -181,31 +194,43 @@ export function reshapingNodeId(scope: InteractionScope): string | null { } // Builders so producers don't re-spell the discriminator at every call site. -export function curveReshapeScope(nodeId: string): ActiveInteractionScope { - return { kind: 'reshaping', nodeId, reshape: 'curve' } +export function curveReshapeScope( + nodeId: string, + driver: ReshapeDriver = 'tool', +): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'curve', driver } } export function endpointReshapeScope( nodeId: string, endpoint: 'start' | 'end', + driver: ReshapeDriver = 'tool', ): ActiveInteractionScope { - return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } + return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint, driver } } -export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope { - return { kind: 'reshaping', nodeId, reshape: 'control-point', index } +export function controlPointReshapeScope( + nodeId: string, + index: number, + driver: ReshapeDriver = 'tool', +): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'control-point', index, driver } } export function tangentReshapeScope( nodeId: string, index: number, side: 'in' | 'out', + driver: ReshapeDriver = 'tool', ): ActiveInteractionScope { - return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side } + return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side, driver } } // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen. -export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope { - return { kind: 'reshaping', nodeId, reshape: 'boundary' } +export function boundaryReshapeScope( + nodeId: string, + driver: ReshapeDriver = 'tool', +): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'boundary', driver } } diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 9afc74066..594e77c57 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -2,10 +2,12 @@ import { afterEach, describe, expect, test } from 'bun:test' import type { AnyNode } from '@pascal-app/core' import { type ActiveInteractionScope, + curveReshapeScope, editingHoleInfo, handleDragInfo, isActive, isIdle, + isToolDrivenReshape, scopeNodeId, selectionEnabled, } from '../lib/interaction/scope' @@ -137,7 +139,13 @@ describe('derived flag views are leak-free (no parallel flags)', () => { test('editingHoleInfo mirrors a hole reshape and clears on end', () => { const s = useInteractionScope.getState() - s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 2 }) + s.begin({ + kind: 'reshaping', + nodeId: 'slab_1', + reshape: 'hole', + driver: 'tool', + holeIndex: 2, + }) expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 2 }) s.end() expect(editingHoleInfo(scope())).toBeNull() @@ -145,16 +153,27 @@ describe('derived flag views are leak-free (no parallel flags)', () => { test('a non-hole reshape never reads as an editing hole', () => { const s = useInteractionScope.getState() - s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve' }) + s.begin({ kind: 'reshaping', nodeId: 'wall_1', reshape: 'curve', driver: 'tool' }) expect(editingHoleInfo(scope())).toBeNull() }) + test('a floorplan-owned curve drag does not activate the registry reshape tool', () => { + expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'floorplan'))).toBe(false) + expect(isToolDrivenReshape(curveReshapeScope('wall_1', 'tool'))).toBe(true) + }) + test('switching interactions never leaks the prior derived view', () => { const s = useInteractionScope.getState() s.begin({ kind: 'handle-drag', nodeId: 'wall_1', handle: 'height' }) // Single-owner replacement: the handle-drag view must vanish the instant a // different interaction begins — the two cannot be simultaneously active. - s.begin({ kind: 'reshaping', nodeId: 'slab_1', reshape: 'hole', holeIndex: 0 }) + s.begin({ + kind: 'reshaping', + nodeId: 'slab_1', + reshape: 'hole', + driver: 'tool', + holeIndex: 0, + }) expect(handleDragInfo(scope())).toBeNull() expect(editingHoleInfo(scope())).toEqual({ nodeId: 'slab_1', holeIndex: 0 }) }) diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 5af76c441..6a9423e9e 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -13,6 +13,8 @@ import { IDLE_SCOPE, type InteractionScope, isCurveReshape, + isFloorplanDrivenReshape, + isToolDrivenReshape, movingNodeOf, reshapingNodeId, tangentReshapeInfo, @@ -84,6 +86,12 @@ export const getIsCurveReshape = (): boolean => isCurveReshape(useInteractionSco // recovered by reading the reshaped node's type from `useReshapingNode`. export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCurveReshape(s.scope)) +export const useIsToolDrivenReshape = (): boolean => + useInteractionScope((s) => isToolDrivenReshape(s.scope)) + +export const useIsFloorplanDrivenReshape = (): boolean => + useInteractionScope((s) => isFloorplanDrivenReshape(s.scope)) + // Replaces the legacy `movingWallEndpoint` / `movingFenceEndpoint` payloads, // minus the node (fetch it from `useReshapingNode`). export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null => diff --git a/packages/nodes/src/wall/floorplan-affordances.test.ts b/packages/nodes/src/wall/floorplan-affordances.test.ts new file mode 100644 index 000000000..cc308bd8b --- /dev/null +++ b/packages/nodes/src/wall/floorplan-affordances.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNodeId, useLiveNodeOverrides, useScene, WallNode } from '@pascal-app/core' +import { wallCurveAffordance } from './floorplan-affordances' + +globalThis.requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +const modifiers = { + shiftKey: false, + altKey: false, + ctrlKey: false, + metaKey: false, +} + +describe('wall center curve handle release', () => { + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + }) + + test('persists the previewed curve offset after commit clears the override', () => { + const wall = WallNode.parse({ + id: 'wall_curve-release', + parentId: null, + start: [0, 0], + end: [4, 0], + }) + useScene.setState({ nodes: { [wall.id]: wall } as never }) + + const session = wallCurveAffordance.start({ + node: wall, + payload: { wallId: wall.id }, + nodes: useScene.getState().nodes, + initialPlanPoint: [2, 0], + gridSnapStep: 0.1, + }) + session.apply({ planPoint: [2, 1], modifiers }) + + expect((useScene.getState().nodes[wall.id] as typeof wall).curveOffset ?? 0).toBe(0) + expect(useLiveNodeOverrides.getState().get(wall.id as AnyNodeId)?.curveOffset).not.toBe(0) + + expect(session.canCommit()).toBe(true) + session.commit?.() + + expect((useScene.getState().nodes[wall.id] as typeof wall).curveOffset).not.toBe(0) + expect(useLiveNodeOverrides.getState().get(wall.id as AnyNodeId)).toBeUndefined() + }) +}) From bc86bdd9156babd7edba9926ca0edd65f2aaf18b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Jul 2026 20:50:41 +0530 Subject: [PATCH 7/7] fix floorplan registry scale subscriptions --- .../renderers/floorplan-registry-layer.tsx | 31 +++++++++++-------- wiki/architecture/interaction-scope.md | 22 +++++++++---- 2 files changed, 34 insertions(+), 19 deletions(-) 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 7487442f6..14e3d9c68 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 @@ -28,6 +28,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { + type ComponentProps, memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, @@ -146,6 +147,13 @@ const DIRECT_DRAG_THRESHOLD_PX = 4 const DIRECT_ROTATE_EPSILON = 1e-6 const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180 +const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox( + props: Omit, 'unitsPerPixel'>, +) { + const unitsPerPixel = useFloorplanStaticUnitsPerPixel() + return +}) + /** * Snapshot of node fields captured at drag-start, used by the single-undo * dance to revert untracked before re-applying as a single tracked @@ -425,7 +433,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const levelId = selectedLevelId ?? ambientLevelId const isAmbient = !selectedLevelId && !!ambientLevelId const renderCtx = useFloorplanStaticRender() - const unitsPerPixel = useFloorplanStaticUnitsPerPixel() const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) @@ -1378,7 +1385,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { unit={unit} metricNotation={metricNotation} wallDimensionReference={wallDimensionReference} - unitsPerPixel={unitsPerPixel} visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} ctxOverrides={entry.ctxOverrides} /> @@ -1432,7 +1438,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { unit={unit} metricNotation={metricNotation} wallDimensionReference={wallDimensionReference} - unitsPerPixel={unitsPerPixel} visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)} ctxOverrides={entry.ctxOverrides} /> @@ -1442,11 +1447,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { {/* Dashed group bbox — shows what a group drag carries along while a multi-selection exists, rides the live delta mid-drag, and doubles as the group's whole-area drag handle. */} - {/* Transient live-rotation readout — drawn last so the wedge + degree chip sit above all handle chrome while a rotate-arrow is dragged. */} @@ -1455,7 +1459,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { overlay={rotationOverlay} palette={palette} sceneRotationDeg={sceneRotationDeg} - unitsPerPixel={unitsPerPixel} /> ) : null} @@ -1779,7 +1782,6 @@ type FloorplanRegistryEntryProps = { unit: 'metric' | 'imperial' metricNotation: 'meters' | 'millimeters' wallDimensionReference: FloorplanWallDimensionReference - unitsPerPixel: number visibilityRootId: AnyNodeId | undefined } @@ -1822,7 +1824,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ unit, metricNotation, wallDimensionReference, - unitsPerPixel, visibilityRootId, }: FloorplanRegistryEntryProps): React.ReactElement | null { const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined)) @@ -1973,7 +1974,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ onMoveHandlePointerDown={handleMoveHandlePointerDown} palette={palette} sceneRotationDeg={sceneRotationDeg} - unitsPerPixel={unitsPerPixel} /> ) @@ -2246,7 +2246,7 @@ export function getFloorplanLevelData( type InteractiveGeometryProps = { geometry: FloorplanGeometry - unitsPerPixel: number + unitsPerPixel?: number palette: FloorplanPalette | undefined hatchPatternId: string | undefined hoveredHandleId: string | null @@ -2274,7 +2274,7 @@ type InteractiveGeometryProps = { export const InteractiveGeometry = memo(function InteractiveGeometry({ geometry, - unitsPerPixel, + unitsPerPixel: unitsPerPixelOverride, palette, hatchPatternId, hoveredHandleId, @@ -2288,6 +2288,9 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ onHandlePointerDown, onMoveHandlePointerDown, }: InteractiveGeometryProps): React.ReactElement { + const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel() + const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel + return renderInteractive(geometry, 0) function renderInteractive(g: FloorplanGeometry, keyHint: number): React.ReactElement { @@ -3495,7 +3498,7 @@ const ROTATION_WEDGE_SEGMENTS = 48 export function RotationAngleOverlay({ overlay, palette, - unitsPerPixel, + unitsPerPixel: unitsPerPixelOverride, sceneRotationDeg, }: { overlay: RotationOverlayState @@ -3503,9 +3506,11 @@ export function RotationAngleOverlay({ FloorplanPalette, 'measurementLabelBackground' | 'measurementLabelText' | 'measurementStroke' > - unitsPerPixel: number + unitsPerPixel?: number sceneRotationDeg: number }): React.ReactElement { + const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel() + const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel const { pivot, startAngle, endAngle, radius, sweep } = overlay const span = endAngle - startAngle const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS)) diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 61914d639..09b66aa97 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -22,22 +22,28 @@ scope is exactly one interaction at a time, and `idle` carries no payload. | `kind` | Payload | What | |---|---|---| | `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | -| `placing` | `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | -| `moving` | `nodeId`, `nodeType`, `view` | Moving an existing node. | +| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | +| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | | `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | | `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | -| `reshaping` | `nodeId`, `reshape`, `holeIndex?` | Reshaping a selected node's geometry. `reshape` is `curve \| hole \| endpoint \| boundary`. | +| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. | | `box-select` | — | Marquee selection drag. | | `painting` | — | Material paint application. | -`reshaping` groups endpoint/curve/hole/boundary edits as sub-states of one scope -(rather than four sibling kinds) — there is one node and one in-flight reshape, -so "curving and hole-editing at once" stays unrepresentable. `view` is `'2d' | '3d'`. +`reshaping` groups endpoint/curve/hole/boundary/control-point/tangent edits as +sub-states of one scope — there is one node and one in-flight reshape, so +"curving and hole-editing at once" stays unrepresentable. Its `driver` is +`'tool' | 'floorplan'`: framework tools own tool-driven interactions, while a +floor-plan affordance owns the complete preview/commit lifecycle of a +floorplan-driven interaction. The driver prevents both interaction bodies from +mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`. ### Helpers - `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`). - `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node. +- `isToolDrivenReshape(scope)` / `isFloorplanDrivenReshape(scope)` — narrow + reshape ownership so only the matching interaction body mounts. - `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false. --- @@ -176,6 +182,10 @@ independent flag clear can't stomp an unrelated scope) to keep the scope in sync ## Rules - **One owner, one scope.** Only `useInteractionScope` writes the scope, and only via `begin`/`update`/`end`/`endIf`. Never reconstruct interaction state from a private combination of flags. +- **One reshape driver.** A floorplan-driven reshape is previewed and committed + by its floor-plan affordance; a tool-driven reshape is owned by the framework + tool. Gate interaction bodies with the driver so both cannot act on one + gesture. - **`end` is atomic and payload-free.** Never leave a `nodeId`/payload behind on idle; commit-vs-revert logic belongs in the interaction body before `end`. - **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch. - **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.