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 { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { Command, Ruler } from 'lucide-react'
|
import { Command, Ruler } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
type ComponentProps,
|
||||||
memo,
|
memo,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
@@ -102,6 +103,7 @@ import useInteractionScope, {
|
|||||||
useReshapingNode,
|
useReshapingNode,
|
||||||
} from '../../store/use-interaction-scope'
|
} from '../../store/use-interaction-scope'
|
||||||
import usePlacementPreview from '../../store/use-placement-preview'
|
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 { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
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({
|
export function FloorplanPanel({
|
||||||
/**
|
/**
|
||||||
* Element to portal the compass button into. The 2D/3D navigation poses stay
|
* Element to portal the compass button into. The 2D/3D navigation poses stay
|
||||||
@@ -4743,8 +4848,6 @@ export function FloorplanPanel({
|
|||||||
[site?.id],
|
[site?.id],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
|
|
||||||
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
|
|
||||||
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
|
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
|
||||||
const [isPanning, setIsPanning] = useState(false)
|
const [isPanning, setIsPanning] = useState(false)
|
||||||
const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false)
|
const [isRotatingFloorplan, setIsRotatingFloorplan] = useState(false)
|
||||||
@@ -5399,72 +5502,6 @@ export function FloorplanPanel({
|
|||||||
isFloorItemBuildActive ||
|
isFloorItemBuildActive ||
|
||||||
isFloorItemMoveActive ||
|
isFloorItemMoveActive ||
|
||||||
isRegistryToolBuildActive
|
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(() => {
|
const floorplanOpeningLocalY = useMemo(() => {
|
||||||
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
|
if (movingNode?.type === 'door' || movingNode?.type === 'window') {
|
||||||
return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
|
return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1])
|
||||||
@@ -7311,15 +7348,18 @@ export function FloorplanPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStairBuildActive) {
|
if (!isStairBuildActive) {
|
||||||
setStairBuildPreviewPoint(null)
|
useStairBuildPreview.getState().reset()
|
||||||
setStairBuildPreviewRotation(0)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGridMove = (event: GridEvent) => {
|
const handleGridMove = (event: GridEvent) => {
|
||||||
setStairBuildPreviewPoint(
|
// Publish to the dedicated store (deduped on the snapped point), NOT panel
|
||||||
getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]]),
|
// 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)
|
emitter.on('grid:move', handleGridMove)
|
||||||
@@ -7525,9 +7565,9 @@ export function FloorplanPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
|
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')) {
|
} else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) {
|
||||||
setStairBuildPreviewRotation((current) => current - Math.PI / 4)
|
useStairBuildPreview.getState().rotateBy(-Math.PI / 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -10519,31 +10559,14 @@ export function FloorplanPanel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stair is fully registry-driven for committed nodes
|
{/* Stair is fully registry-driven for committed nodes
|
||||||
(`def.floorplan` on the stair kind). This layer only
|
(`def.floorplan` on the stair kind). The only thing left for
|
||||||
carries the in-flight stair preview, which lives outside
|
this view is the in-flight build preview, which lives outside
|
||||||
the scene graph and so isn't visible to
|
the scene graph (so `FloorplanRegistryLayer` can't see it).
|
||||||
`FloorplanRegistryLayer`. When the preview entry is
|
`FloorplanStairBuildPreviewLayer` owns it as a leaf that
|
||||||
absent the array is empty and the layer renders nothing.
|
subscribes to the `useStairBuildPreview` store directly, so a
|
||||||
Hover / select / double-click props are noops — the
|
per-`grid:move` cursor update re-renders only that tiny layer
|
||||||
preview isn't interactive, and committed stairs route
|
rather than this whole panel. */}
|
||||||
through `FloorplanRegistryLayer`. */}
|
<FloorplanStairBuildPreviewLayer isDeleteMode={isDeleteMode} palette={palette} />
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FloorplanReferenceScaleLayer
|
<FloorplanReferenceScaleLayer
|
||||||
draft={referenceScaleDraft}
|
draft={referenceScaleDraft}
|
||||||
|
|||||||
@@ -264,7 +264,19 @@ export const StairTool: React.FC = () => {
|
|||||||
return { placementLevelId, previewNodes, stair }
|
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 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 preview = buildPreviewScene(position, rotation)
|
||||||
const visualPosition = preview
|
const visualPosition = preview
|
||||||
? getFloorStackPreviewPosition({
|
? getFloorStackPreviewPosition({
|
||||||
@@ -410,6 +422,9 @@ export const StairTool: React.FC = () => {
|
|||||||
|
|
||||||
commitStairPlacement(currentLevelId, position, rotationRef.current)
|
commitStairPlacement(currentLevelId, position, rotationRef.current)
|
||||||
openingPreview.clear()
|
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)
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId)
|
||||||
useAlignmentGuides.getState().clear()
|
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