perf(editor): extract stair 2D build preview to store + leaf — kill per-move panel re-render

The stair tool held its 2D build preview in FloorplanPanel useState, so every
grid:move re-rendered the whole ~120-220ms panel. Move the preview into a
dedicated useStairBuildPreview store written via getState() (no panel re-render)
and render it from a FloorplanStairBuildPreviewLayer leaf that subscribes to the
store directly — the same pattern that keeps column/elevator placement smooth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 13:02:48 -04:00
co-authored by Claude Opus 4.8
parent bbf9291c2d
commit 6d5294d43f
3 changed files with 181 additions and 100 deletions
@@ -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<string> = new Set()
type FloorplanStairLayerPalette = ComponentProps<typeof FloorplanStairLayer>['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 (
<FloorplanStairLayer
canFocusStairs={false}
canSelectStairs={false}
cursor={EDITOR_CURSOR}
highlightedIdSet={EMPTY_FLOORPLAN_ID_SET}
hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
hoveredStairId={null}
isDeleteMode={isDeleteMode}
onStairDoubleClick={noopFloorplanStairHandler}
onStairHoverChange={noopFloorplanStairHandler}
onStairHoverEnter={noopFloorplanStairHandler}
onStairPointerDown={noopFloorplanStairHandler}
onStairSelect={noopFloorplanStairHandler}
palette={palette}
selectedIdSet={EMPTY_FLOORPLAN_ID_SET}
stairEntries={[previewEntry]}
/>
)
}
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<WallPlanPoint | null>(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`. */}
<FloorplanStairLayer
canFocusStairs={false}
canSelectStairs={false}
cursor={EDITOR_CURSOR}
highlightedIdSet={highlightedFloorplanIdSet}
hitStrokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
hoveredStairId={null}
isDeleteMode={isDeleteMode}
onStairDoubleClick={noopFloorplanStairHandler}
onStairHoverChange={noopFloorplanStairHandler}
onStairHoverEnter={noopFloorplanStairHandler}
onStairPointerDown={noopFloorplanStairHandler}
onStairSelect={noopFloorplanStairHandler}
palette={palette}
selectedIdSet={selectedIdSet}
stairEntries={renderedFloorplanStairEntries}
/>
(`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. */}
<FloorplanStairBuildPreviewLayer isDeleteMode={isDeleteMode} palette={palette} />
<FloorplanReferenceScaleLayer
draft={referenceScaleDraft}
@@ -264,7 +264,19 @@ export const StairTool: React.FC = () => {
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()
}
@@ -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<StairBuildPreviewState>((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 },
),
}))