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:
co-authored by
Claude Opus 4.8
parent
bbf9291c2d
commit
6d5294d43f
@@ -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 },
|
||||
),
|
||||
}))
|
||||
Reference in New Issue
Block a user