diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 8635a2e9..133d4058 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -56,6 +56,7 @@ import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from ' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Command, Ruler } from 'lucide-react' import { + type ComponentProps, memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, @@ -102,6 +103,7 @@ import useInteractionScope, { useReshapingNode, } from '../../store/use-interaction-scope' import usePlacementPreview from '../../store/use-placement-preview' +import { useStairBuildPreview } from '../../store/use-stair-build-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' @@ -4487,6 +4489,109 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ ) }) +// Static segment for the in-flight stair build preview. No per-render +// dependency (the geometry only moves / rotates), so it lives at module scope +// instead of a `useMemo`. +const FLOORPLAN_PREVIEW_STAIR_SEGMENT = StairSegmentNodeSchema.parse({ + id: 'sseg_floorplan_preview', + segmentType: 'stair', + width: DEFAULT_STAIR_WIDTH, + length: DEFAULT_STAIR_LENGTH, + height: DEFAULT_STAIR_HEIGHT, + stepCount: DEFAULT_STAIR_STEP_COUNT, + attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + thickness: DEFAULT_STAIR_THICKNESS, + position: [0, 0, 0], + metadata: { isTransient: true, isFloorplanPreview: true }, +}) + +const EMPTY_FLOORPLAN_ID_SET: ReadonlySet = new Set() + +type FloorplanStairLayerPalette = ComponentProps['palette'] + +// Leaf layer for the stair tool's in-flight 2D build preview. Subscribes to the +// `useStairBuildPreview` store directly so a per-`grid:move` point update (or an +// R/T rotation) re-renders ONLY this tiny layer — never the ~120-220ms +// `FloorplanPanel`. This mirrors how column / elevator placement stays smooth by +// routing preview state through a store + leaf. Committed stairs render through +// `FloorplanRegistryLayer`; this layer is non-interactive (noop handlers, empty +// hit sets), so it never participates in hover / select. +function FloorplanStairBuildPreviewLayer({ + palette, + isDeleteMode, +}: { + palette: FloorplanStairLayerPalette + isDeleteMode: boolean +}) { + const phase = useEditor((s) => s.phase) + const mode = useEditor((s) => s.mode) + const tool = useEditor((s) => s.tool) + const point = useStairBuildPreview((s) => s.point) + const rotation = useStairBuildPreview((s) => s.rotation) + const isActive = phase === 'structure' && mode === 'build' && tool === 'stair' + + const previewEntry = useMemo(() => { + if (!(isActive && point)) { + return null + } + const previewStair = StairNodeSchema.parse({ + id: 'stair_floorplan_preview', + name: 'Staircase preview', + position: [point[0], 0, point[1]], + rotation, + children: [FLOORPLAN_PREVIEW_STAIR_SEGMENT.id], + metadata: { isTransient: true, isFloorplanPreview: true }, + }) + const entry = buildSharedFloorplanStairEntry(previewStair, [FLOORPLAN_PREVIEW_STAIR_SEGMENT]) + if (!entry) { + return null + } + const hitPolygons = + (previewStair.stairType ?? 'straight') === 'straight' + ? entry.segments.map((segmentEntry) => segmentEntry.polygon) + : [getFloorplanCurvedStairHitPolygon(previewStair)] + + return { + ...entry, + hitPolygons, + segments: entry.segments.map((segmentEntry) => ({ + ...segmentEntry, + innerPoints: formatPolygonPoints(segmentEntry.innerPolygon), + points: formatPolygonPoints(segmentEntry.polygon), + treadBars: segmentEntry.treadBars.map((polygon) => ({ + points: formatPolygonPoints(polygon), + polygon, + })), + })), + } + }, [isActive, point, rotation]) + + if (!previewEntry) { + return null + } + + return ( + + ) +} + export function FloorplanPanel({ /** * Element to portal the compass button into. The 2D/3D navigation poses stay @@ -4743,8 +4848,6 @@ export function FloorplanPanel({ [site?.id], ), ) - const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) - const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isSpacePanPressed, setIsSpacePanPressed] = useState(false) const [isPanning, setIsPanning] = useState(false) const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false) @@ -5399,72 +5502,6 @@ export function FloorplanPanel({ isFloorItemBuildActive || isFloorItemMoveActive || isRegistryToolBuildActive - const floorplanPreviewStairSegment = useMemo( - () => - StairSegmentNodeSchema.parse({ - id: 'sseg_floorplan_preview', - segmentType: 'stair', - width: DEFAULT_STAIR_WIDTH, - length: DEFAULT_STAIR_LENGTH, - height: DEFAULT_STAIR_HEIGHT, - stepCount: DEFAULT_STAIR_STEP_COUNT, - attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, - fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, - thickness: DEFAULT_STAIR_THICKNESS, - position: [0, 0, 0], - metadata: { isTransient: true, isFloorplanPreview: true }, - }), - [], - ) - const floorplanPreviewStairEntry = useMemo(() => { - if (!(isStairBuildActive && stairBuildPreviewPoint)) { - return null - } - - const previewStair = StairNodeSchema.parse({ - id: 'stair_floorplan_preview', - name: 'Staircase preview', - position: [stairBuildPreviewPoint[0], 0, stairBuildPreviewPoint[1]], - rotation: stairBuildPreviewRotation, - children: [floorplanPreviewStairSegment.id], - metadata: { isTransient: true, isFloorplanPreview: true }, - }) - - const entry = buildSharedFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment]) - if (!entry) { - return null - } - const hitPolygons = - (previewStair.stairType ?? 'straight') === 'straight' - ? entry.segments.map((segmentEntry) => segmentEntry.polygon) - : [getFloorplanCurvedStairHitPolygon(previewStair)] - - return { - ...entry, - hitPolygons, - segments: entry.segments.map((segmentEntry) => ({ - ...segmentEntry, - innerPoints: formatPolygonPoints(segmentEntry.innerPolygon), - points: formatPolygonPoints(segmentEntry.polygon), - treadBars: segmentEntry.treadBars.map((polygon) => ({ - points: formatPolygonPoints(polygon), - polygon, - })), - })), - } - }, [ - floorplanPreviewStairSegment, - isStairBuildActive, - stairBuildPreviewPoint, - stairBuildPreviewRotation, - ]) - const renderedFloorplanStairEntries = useMemo( - () => - floorplanPreviewStairEntry - ? [...floorplanStairEntries, floorplanPreviewStairEntry] - : floorplanStairEntries, - [floorplanPreviewStairEntry, floorplanStairEntries], - ) const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1]) @@ -7311,15 +7348,18 @@ export function FloorplanPanel({ useEffect(() => { if (!isStairBuildActive) { - setStairBuildPreviewPoint(null) - setStairBuildPreviewRotation(0) + useStairBuildPreview.getState().reset() return } const handleGridMove = (event: GridEvent) => { - setStairBuildPreviewPoint( - getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]), - ) + // Publish to the dedicated store (deduped on the snapped point), NOT panel + // state: the stair preview lives in `FloorplanStairBuildPreviewLayer`, so a + // per-move update re-renders only that tiny leaf instead of this entire + // (~200ms) panel — the same pattern that keeps column/elevator smooth. + useStairBuildPreview + .getState() + .setPoint(getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]])) } emitter.on('grid:move', handleGridMove) @@ -7525,9 +7565,9 @@ export function FloorplanPanel({ } if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) { - setStairBuildPreviewRotation((current) => current + Math.PI / 4) + useStairBuildPreview.getState().rotateBy(Math.PI / 4) } else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) { - setStairBuildPreviewRotation((current) => current - Math.PI / 4) + useStairBuildPreview.getState().rotateBy(-Math.PI / 4) } if ( @@ -10519,31 +10559,14 @@ export function FloorplanPanel({ /> {/* Stair is fully registry-driven for committed nodes - (`def.floorplan` on the stair kind). This layer only - carries the in-flight stair preview, which lives outside - the scene graph and so isn't visible to - `FloorplanRegistryLayer`. When the preview entry is - absent the array is empty and the layer renders nothing. - Hover / select / double-click props are noops — the - preview isn't interactive, and committed stairs route - through `FloorplanRegistryLayer`. */} - + (`def.floorplan` on the stair kind). The only thing left for + this view is the in-flight build preview, which lives outside + the scene graph (so `FloorplanRegistryLayer` can't see it). + `FloorplanStairBuildPreviewLayer` owns it as a leaf that + subscribes to the `useStairBuildPreview` store directly, so a + per-`grid:move` cursor update re-renders only that tiny layer + rather than this whole panel. */} + { return { placementLevelId, previewNodes, stair } } + // The preview rebuild (full-scene copy + destination-level resolution + + // auto-opening CSG) is expensive; `grid:move` fires it every pointer event + // but the placed position is grid-snapped, so within a cell every rebuild + // is identical. Dedupe on the snapped position + rotation so we rebuild + // only when the staircase would actually land somewhere new — this is the + // difference between a smooth and a stuttering stair tool (the elevator is + // cheap because it has no opening sync). + let lastPreviewKey: string | null = null + const applyDraftPreview = (position: [number, number, number], rotation: number) => { + const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)}` + if (key === lastPreviewKey) return + lastPreviewKey = key const preview = buildPreviewScene(position, rotation) const visualPosition = preview ? getFloorStackPreviewPosition({ @@ -410,6 +422,9 @@ export const StairTool: React.FC = () => { commitStairPlacement(currentLevelId, position, rotationRef.current) openingPreview.clear() + // Commit cleared the opening preview, so force the next hover (even on the + // same cell) to rebuild rather than dedupe against the just-placed key. + lastPreviewKey = null alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) useAlignmentGuides.getState().clear() } diff --git a/packages/editor/src/store/use-stair-build-preview.ts b/packages/editor/src/store/use-stair-build-preview.ts new file mode 100644 index 00000000..fff08e35 --- /dev/null +++ b/packages/editor/src/store/use-stair-build-preview.ts @@ -0,0 +1,43 @@ +// Ephemeral store for the stair tool's 2D floor-plan build preview. The stair +// tool's snapped cursor point + rotation publish here on each `grid:move` / R-T +// rotate; the floor-plan stair preview layer subscribes and renders the ghost +// staircase. This mirrors how `usePlacementPreview` keeps column / elevator +// placement smooth: the preview lives OUTSIDE `FloorplanPanel`, so a per-move +// update re-renders only the tiny preview layer, not the (expensive) panel. +// +// Editor-only, same rationale as `usePlacementPreview`. Producers clear on +// tool-inactive, commit, and unmount. + +import { create } from 'zustand' + +type StairPreviewPoint = [number, number] + +type StairBuildPreviewState = { + /** Snapped plan-XZ point the ghost staircase sits at; `null` when idle. */ + point: StairPreviewPoint | null + /** Yaw (radians), cycled by R / T. */ + rotation: number + /** Set the snapped point. No-ops (skips the store update, so subscribers + * don't re-render) when the point is unchanged — `grid:move` fires far more + * often than the snapped cell actually changes. */ + setPoint(point: StairPreviewPoint | null): void + rotateBy(deltaRadians: number): void + reset(): void +} + +export const useStairBuildPreview = create((set) => ({ + point: null, + rotation: 0, + setPoint: (point) => + set((state) => { + const prev = state.point + if (!point && !prev) return state + if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state + return { point } + }), + rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })), + reset: () => + set((state) => + state.point === null && state.rotation === 0 ? state : { point: null, rotation: 0 }, + ), +}))