perf(editor): move 2D marquee + reference-scale draft out of panel state

The last two hot per-pointer-move 2D states still lived in FloorplanPanel's
useState, re-rendering the ~10k-line panel on every move:

- marquee (box-select): the whole drag struct moves to a dedicated
  use-floorplan-marquee store; down/move/up/cancel read+write it via
  getState() (panel holds nothing), and a FloorplanMarqueeOverlay leaf
  subscribes to the moving corner and renders the rect alone. Drops the 3
  bounds memos + the useState.
- reference-scale: the rubber-band's moving end was always equal to the
  shared cursorPoint (written every move anyway), so drop the `cursor` field
  from the draft and read it from useFloorplanDraftPreview in a new
  FloorplanReferenceScaleDraftLine leaf. The draft now carries only the
  per-click guide + start anchor, so it no longer re-renders the panel.

Closes out the 2D edition perf pass — every build/edit/select hot path now
writes a store, not panel state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 16:38:42 -04:00
co-authored by Claude Opus 4.8
parent 84cdf4519d
commit 0fb3604586
2 changed files with 145 additions and 96 deletions
@@ -98,6 +98,7 @@ import useEditor, {
selectSiteFloorplanContext, selectSiteFloorplanContext,
} from '../../store/use-editor' } from '../../store/use-editor'
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview' import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
import { useFloorplanMarquee } from '../../store/use-floorplan-marquee'
import useInteractionScope, { import useInteractionScope, {
useActiveHandleDrag, useActiveHandleDrag,
useEndpointReshape, useEndpointReshape,
@@ -368,14 +369,6 @@ type FloorplanSelectionBounds = {
maxY: number maxY: number
} }
type FloorplanMarqueeState = {
pointerId: number
startClientX: number
startClientY: number
startPlanPoint: WallPlanPoint
currentPlanPoint: WallPlanPoint
}
type LinkedWallSnapshot = { type LinkedWallSnapshot = {
id: WallNode['id'] id: WallNode['id']
start: WallPlanPoint start: WallPlanPoint
@@ -436,10 +429,13 @@ type GuideTransformDraft = {
type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches' type ReferenceScaleUnit = 'meters' | 'centimeters' | 'feet' | 'inches'
// The in-flight reference-scale measurement. Only the per-CLICK fields live
// here (guide + start anchor); the rubber-band's moving END is the shared
// `useFloorplanDraftPreview.cursorPoint` (set on every move anyway), so it
// never re-renders the panel — `FloorplanReferenceScaleDraftLine` reads it.
type ReferenceScaleDraft = { type ReferenceScaleDraft = {
guideId: GuideNode['id'] guideId: GuideNode['id']
start: WallPlanPoint | null start: WallPlanPoint | null
cursor: WallPlanPoint | null
} }
type PendingReferenceScale = { type PendingReferenceScale = {
@@ -3404,16 +3400,11 @@ function FloorplanReferenceScaleLayer({
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
/> />
))} ))}
{draft?.start && draft.cursor && ( {draft?.start && (
<FloorplanReferenceScaleLine <FloorplanReferenceScaleDraftLine
end={draft.cursor}
isDraft
label={`Ref ${formatMeasurement(
Math.hypot(draft.cursor[0] - draft.start[0], draft.cursor[1] - draft.start[1]),
unit,
)}`}
palette={palette} palette={palette}
start={draft.start} start={draft.start}
unit={unit}
unitsPerPixel={unitsPerPixel} unitsPerPixel={unitsPerPixel}
/> />
)} )}
@@ -3421,6 +3412,41 @@ function FloorplanReferenceScaleLayer({
) )
} }
// The live reference-scale rubber-band — split out of the layer so it can
// subscribe to the shared cursor store for its moving END. A per-move cursor
// update re-renders ONLY this line, never FloorplanPanel; the START anchor +
// render config arrive as props (set per click, never per move).
function FloorplanReferenceScaleDraftLine({
palette,
start,
unit,
unitsPerPixel,
}: {
palette: FloorplanPalette
start: WallPlanPoint
unit: 'metric' | 'imperial'
unitsPerPixel: number
}) {
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
if (!cursor) {
return null
}
return (
<FloorplanReferenceScaleLine
end={cursor}
isDraft
label={`Ref ${formatMeasurement(
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
unit,
)}`}
palette={palette}
start={start}
unitsPerPixel={unitsPerPixel}
/>
)
}
function FloorplanGuideSelectionOverlay({ function FloorplanGuideSelectionOverlay({
guide, guide,
isDarkMode, isDarkMode,
@@ -4706,6 +4732,39 @@ function FloorplanDraftCursorLayer({
) )
} }
// Leaf overlay for the marquee (box-select) rectangle. Subscribes to the
// marquee store's moving corner so a per-move drag re-renders ONLY this layer,
// never the (~120-220ms) FloorplanPanel. The bounds math is pure (the
// module-scope `getFloorplanSelectionBounds` / `toSvgSelectionBounds`); the
// cursor colour is the one bit of panel config, passed as a prop.
function FloorplanMarqueeOverlay({ cursorColor }: { cursorColor: string }) {
const drag = useFloorplanMarquee((s) => s.drag)
const bounds = useMemo(() => {
if (!drag) {
return null
}
const dragDistance = Math.hypot(
drag.currentPlanPoint[0] - drag.startPlanPoint[0],
drag.currentPlanPoint[1] - drag.startPlanPoint[1],
)
if (dragDistance <= 0) {
return null
}
return toSvgSelectionBounds(
getFloorplanSelectionBounds(drag.startPlanPoint, drag.currentPlanPoint),
)
}, [drag])
return (
<FloorplanMarqueeLayer
bounds={bounds}
cursorColor={cursorColor}
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
/>
)
}
// Thin subscriber wrapper for the coordinate-badge overlay: reads the hot // Thin subscriber wrapper for the coordinate-badge overlay: reads the hot
// screen-space cursor position from the draft store so a per-`pointermove` // screen-space cursor position from the draft store so a per-`pointermove`
// update re-renders only the badge, not FloorplanPanel. The remaining props // update re-renders only the badge, not FloorplanPanel. The remaining props
@@ -5154,9 +5213,6 @@ export function FloorplanPanel({
const setGuideLocked = useEditor((s) => s.setGuideLocked) const setGuideLocked = useEditor((s) => s.setGuideLocked)
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible) const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
const clearGuideUi = useEditor((s) => s.clearGuideUi) const clearGuideUi = useEditor((s) => s.clearGuideUi)
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
null,
)
const [shiftPressed, setShiftPressed] = useState(false) const [shiftPressed, setShiftPressed] = useState(false)
const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
@@ -6026,35 +6082,6 @@ export function FloorplanPanel({
() => new Set([...selectedIds, ...previewSelectedIds]), () => new Set([...selectedIds, ...previewSelectedIds]),
[previewSelectedIds, selectedIds], [previewSelectedIds, selectedIds],
) )
const activeMarqueeBounds = useMemo(() => {
if (!floorplanMarqueeState) {
return null
}
return getFloorplanSelectionBounds(
floorplanMarqueeState.startPlanPoint,
floorplanMarqueeState.currentPlanPoint,
)
}, [floorplanMarqueeState])
const visibleMarqueeBounds = useMemo(() => {
if (!(floorplanMarqueeState && activeMarqueeBounds)) {
return null
}
const dragDistance = Math.hypot(
floorplanMarqueeState.currentPlanPoint[0] - floorplanMarqueeState.startPlanPoint[0],
floorplanMarqueeState.currentPlanPoint[1] - floorplanMarqueeState.startPlanPoint[1],
)
return dragDistance > 0 ? activeMarqueeBounds : null
}, [activeMarqueeBounds, floorplanMarqueeState])
const visibleSvgMarqueeBounds = useMemo(() => {
if (!visibleMarqueeBounds) {
return null
}
return toSvgSelectionBounds(visibleMarqueeBounds)
}, [visibleMarqueeBounds])
const siteVertexHandles = useMemo(() => { const siteVertexHandles = useMemo(() => {
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) { if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
return [] return []
@@ -6901,7 +6928,6 @@ export function FloorplanPanel({
setReferenceScaleDraft({ setReferenceScaleDraft({
guideId: guide.id, guideId: guide.id,
start: null, start: null,
cursor: null,
}) })
setPendingReferenceScale(null) setPendingReferenceScale(null)
setMode('select') setMode('select')
@@ -8769,17 +8795,11 @@ export function FloorplanPanel({
if (referenceScaleDraft) { if (referenceScaleDraft) {
emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event) emitFloorplanGridEvent('move', getSnappedFloorplanPoint(planPoint), event)
// The rubber-band's moving end IS this cursor point — the draft-line
// leaf reads it from the store, so no per-move panel-state write.
setCursorPoint((previousPoint) => setCursorPoint((previousPoint) =>
previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint, previousPoint && pointsEqual(previousPoint, planPoint) ? previousPoint : planPoint,
) )
setReferenceScaleDraft((currentDraft) =>
currentDraft
? {
...currentDraft,
cursor: planPoint,
}
: currentDraft,
)
return return
} }
@@ -9444,7 +9464,6 @@ export function FloorplanPanel({
setReferenceScaleDraft({ setReferenceScaleDraft({
...referenceScaleDraft, ...referenceScaleDraft,
start: planPoint, start: planPoint,
cursor: planPoint,
}) })
setCursorPoint(planPoint) setCursorPoint(planPoint)
return return
@@ -10258,7 +10277,7 @@ export function FloorplanPanel({
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
floorplanMarqueeSnapPointRef.current = snappedPoint floorplanMarqueeSnapPointRef.current = snappedPoint
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
setFloorplanMarqueeState({ useFloorplanMarquee.getState().begin({
pointerId: event.pointerId, pointerId: event.pointerId,
startClientX: event.clientX, startClientX: event.clientX,
startClientY: event.clientY, startClientY: event.clientY,
@@ -10281,7 +10300,8 @@ export function FloorplanPanel({
}) })
} }
if (floorplanMarqueeState?.pointerId !== event.pointerId) { const marquee = useFloorplanMarquee.getState().drag
if (marquee?.pointerId !== event.pointerId) {
return return
} }
@@ -10296,8 +10316,8 @@ export function FloorplanPanel({
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
const dragDistance = Math.hypot( const dragDistance = Math.hypot(
event.clientX - floorplanMarqueeState.startClientX, event.clientX - marquee.startClientX,
event.clientY - floorplanMarqueeState.startClientY, event.clientY - marquee.startClientY,
) )
if ( if (
@@ -10310,37 +10330,22 @@ export function FloorplanPanel({
floorplanMarqueeSnapPointRef.current = snappedPoint floorplanMarqueeSnapPointRef.current = snappedPoint
if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) { if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) {
const bounds = getFloorplanSelectionBounds( const bounds = getFloorplanSelectionBounds(marquee.startPlanPoint, snappedPoint)
floorplanMarqueeState.startPlanPoint,
snappedPoint,
)
syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds)) syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds))
} else { } else {
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
} }
setFloorplanMarqueeState((currentState) => { // Advances the moving corner in the marquee store — re-renders only the
if (!currentState || currentState.pointerId !== event.pointerId) { // marquee overlay leaf, never this panel.
return currentState useFloorplanMarquee.getState().setCurrent(snappedPoint)
}
return {
...currentState,
currentPlanPoint: snappedPoint,
}
})
}, },
[ [getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds],
floorplanMarqueeState,
getFloorplanSelectionIdsInBounds,
getPlanPointFromClientPoint,
syncPreviewSelectedIds,
],
) )
const handleMarqueePointerUp = useCallback( const handleMarqueePointerUp = useCallback(
(event: ReactPointerEvent<SVGRectElement>) => { (event: ReactPointerEvent<SVGRectElement>) => {
const marqueeState = floorplanMarqueeState const marqueeState = useFloorplanMarquee.getState().drag
if (!marqueeState || marqueeState.pointerId !== event.pointerId) { if (!marqueeState || marqueeState.pointerId !== event.pointerId) {
return return
} }
@@ -10376,13 +10381,12 @@ export function FloorplanPanel({
} }
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
setFloorplanMarqueeState(null) useFloorplanMarquee.getState().reset()
floorplanMarqueeSnapPointRef.current = null floorplanMarqueeSnapPointRef.current = null
}, },
[ [
addFloorplanSelection, addFloorplanSelection,
commitFloorplanSelection, commitFloorplanSelection,
floorplanMarqueeState,
getFloorplanHitIdAtPoint, getFloorplanHitIdAtPoint,
getFloorplanSelectionIdsInBounds, getFloorplanSelectionIdsInBounds,
getPlanPointFromClientPoint, getPlanPointFromClientPoint,
@@ -10393,7 +10397,7 @@ export function FloorplanPanel({
const handleMarqueePointerCancel = useCallback( const handleMarqueePointerCancel = useCallback(
(event: ReactPointerEvent<SVGRectElement>) => { (event: ReactPointerEvent<SVGRectElement>) => {
if (floorplanMarqueeState?.pointerId !== event.pointerId) { if (useFloorplanMarquee.getState().drag?.pointerId !== event.pointerId) {
return return
} }
@@ -10401,18 +10405,18 @@ export function FloorplanPanel({
event.currentTarget.releasePointerCapture(event.pointerId) event.currentTarget.releasePointerCapture(event.pointerId)
} }
setFloorplanMarqueeState(null) useFloorplanMarquee.getState().reset()
setFloorplanCursorPosition(null) setFloorplanCursorPosition(null)
floorplanMarqueeSnapPointRef.current = null floorplanMarqueeSnapPointRef.current = null
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
setCursorPoint(null) setCursorPoint(null)
}, },
[floorplanMarqueeState?.pointerId, syncPreviewSelectedIds], [syncPreviewSelectedIds],
) )
useEffect(() => { useEffect(() => {
if (!isMarqueeSelectionToolActive) { if (!isMarqueeSelectionToolActive) {
setFloorplanMarqueeState(null) useFloorplanMarquee.getState().reset()
floorplanMarqueeSnapPointRef.current = null floorplanMarqueeSnapPointRef.current = null
syncPreviewSelectedIds([]) syncPreviewSelectedIds([])
if (mode === 'select') { if (mode === 'select') {
@@ -10995,12 +10999,7 @@ export function FloorplanPanel({
the alignment guides. */} the alignment guides. */}
<FloorplanSnapBeaconLayer /> <FloorplanSnapBeaconLayer />
<FloorplanMarqueeLayer <FloorplanMarqueeOverlay cursorColor={palette.cursor} />
bounds={visibleSvgMarqueeBounds}
cursorColor={palette.cursor}
glowWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
outlineWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
/>
{/* This shared layer now carries only the per-CLICK draft anchors {/* This shared layer now carries only the per-CLICK draft anchors
(reference-scale start + committed polygon vertices). The (reference-scale start + committed polygon vertices). The
@@ -0,0 +1,50 @@
// Ephemeral store for the 2D floor-plan marquee (box-select) drag — the hot,
// per-pointer-move rectangle the select tool republishes on every move. It
// lives here, not in `FloorplanPanel`'s `useState`, for the same reason as
// `useFloorplanDraftPreview`: the panel is a ~10k-line component whose render
// costs ~120-220ms, so a `setState` per move made dragging a selection box
// re-render the whole panel. Producers write via `getState()` (no panel
// re-render); the small marquee overlay leaf subscribes and re-renders alone.
//
// The whole drag struct lives here (not just the moving corner) so the
// pointer-move / -up handlers read it non-reactively via `getState()` and the
// panel never subscribes. Editor-only; reset on pointer-up / cancel /
// tool-inactive.
import type { WallPlanPoint } from '@pascal-app/core'
import { create } from 'zustand'
export type FloorplanMarqueeDrag = {
pointerId: number
startClientX: number
startClientY: number
startPlanPoint: WallPlanPoint
/** Moving corner under the cursor — the only field that changes per move. */
currentPlanPoint: WallPlanPoint
}
type FloorplanMarqueeState = {
drag: FloorplanMarqueeDrag | null
begin(drag: FloorplanMarqueeDrag): void
/** Advance the moving corner. No-ops (skips the store update, so the overlay
* doesn't re-render) when the snapped point is unchanged or no drag is open. */
setCurrent(point: WallPlanPoint): void
reset(): void
}
export const useFloorplanMarquee = create<FloorplanMarqueeState>((set) => ({
drag: null,
begin: (drag) => set({ drag }),
setCurrent: (point) =>
set((state) => {
const prev = state.drag
if (!prev) return state
if (prev.currentPlanPoint[0] === point[0] && prev.currentPlanPoint[1] === point[1]) {
return state
}
return { drag: { ...prev, currentPlanPoint: point } }
}),
reset: () => set((state) => (state.drag === null ? state : { drag: null })),
}))
export default useFloorplanMarquee