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..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, @@ -88,7 +89,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, @@ -142,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 @@ -235,7 +247,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 +255,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- @@ -1059,7 +1071,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 +1317,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 ( @@ -1374,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} /> @@ -1428,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} /> @@ -1438,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. */} @@ -1451,7 +1459,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { overlay={rotationOverlay} palette={palette} sceneRotationDeg={sceneRotationDeg} - unitsPerPixel={unitsPerPixel} /> ) : null} @@ -1775,7 +1782,6 @@ type FloorplanRegistryEntryProps = { unit: 'metric' | 'imperial' metricNotation: 'meters' | 'millimeters' wallDimensionReference: FloorplanWallDimensionReference - unitsPerPixel: number visibilityRootId: AnyNodeId | undefined } @@ -1818,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)) @@ -1969,7 +1974,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ onMoveHandlePointerDown={handleMoveHandlePointerDown} palette={palette} sceneRotationDeg={sceneRotationDeg} - unitsPerPixel={unitsPerPixel} /> ) @@ -2242,7 +2246,7 @@ export function getFloorplanLevelData( type InteractiveGeometryProps = { geometry: FloorplanGeometry - unitsPerPixel: number + unitsPerPixel?: number palette: FloorplanPalette | undefined hatchPatternId: string | undefined hoveredHandleId: string | null @@ -2270,7 +2274,7 @@ type InteractiveGeometryProps = { export const InteractiveGeometry = memo(function InteractiveGeometry({ geometry, - unitsPerPixel, + unitsPerPixel: unitsPerPixelOverride, palette, hatchPatternId, hoveredHandleId, @@ -2284,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 { @@ -3491,7 +3498,7 @@ const ROTATION_WEDGE_SEGMENTS = 48 export function RotationAngleOverlay({ overlay, palette, - unitsPerPixel, + unitsPerPixel: unitsPerPixelOverride, sceneRotationDeg, }: { overlay: RotationOverlayState @@ -3499,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/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 03e62e6ea..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, @@ -105,7 +106,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 +115,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 +132,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 +156,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,9 +171,13 @@ 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', () => { + test('applies 2D-first navigation on initial load without a 3D interaction', () => { const applied: CameraPose[] = [] const bridge = createFloorplanCameraSyncBridge({ applyCameraPose: (pose) => applied.push(pose), @@ -189,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/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 130722e77..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,10 +4,40 @@ import { canZoomFloorplanDuringNavigation, createFloorplanNavigationSyncScheduler, finalizeFloorplanNavigation, + flushFloorplanRotationPresentationRestore, + getFloorplanRotationOverscanViewBox, + queueFloorplanRotationPresentationRestore, resolveFloorplanPresentationViewBox, + setFloorplanCompassRotation, } 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 } @@ -30,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 b832f62f0..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,63 @@ 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 { + 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..07739f78b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -203,7 +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' @@ -358,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, ) { @@ -5343,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) @@ -6611,12 +6610,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 +6626,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], @@ -7134,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) } @@ -7167,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) } @@ -7213,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, + ) } }) @@ -7328,6 +7328,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 +7604,11 @@ export function FloorplanPanel({ [surfaceSize.width, viewBox.width], ) const gridBounds = useMemo( - () => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg), + () => + getRotatedViewBoxBounds( + getFloorplanRotationOverscanViewBox(viewBox), + floorplanSceneRotationDeg, + ), [floorplanSceneRotationDeg, viewBox], ) @@ -8177,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)` @@ -8348,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 @@ -8368,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(() => { @@ -11877,6 +11887,7 @@ export function FloorplanPanel({ style={{ cursor: floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR), + overflow: 'visible', }} viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`} > @@ -11916,11 +11927,11 @@ export function FloorplanPanel({ { 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() + }) +}) 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, 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.