@@ -62,6 +62,33 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
6262// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
6363// dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
6464// screen size at any zoom.
65+ /**
66+ * Per-node geometry cache for the floor-plan builder.
67+ *
68+ * The registry layer's `entries` useMemo re-runs whenever any of its many
69+ * dependencies change (`hoveredId`, `selectedIds`, `movingNode`, palette,
70+ * etc.). Without this cache, every state change forces `def.floorplan(node,
71+ * ctx)` to run for every node in the active level — O(n) builder calls for
72+ * a single pointer-move event. On large scenes (~400+ wall segments from
73+ * detection output) this drops frame rate to ~1 FPS.
74+ *
75+ * Cache key is the `AnyNode` object reference. Zustand mutates the scene
76+ * immutably, so any node change produces a new reference and the stale
77+ * entry is unreachable (GC'd via WeakMap). We only cache when the node has
78+ * no active visual state (selected/highlighted/hovered/moving) and the
79+ * palette matches — the builder may emit different chrome based on those.
80+ *
81+ * The cache is bypassed entirely while any live editing is in flight
82+ * (`liveTransforms` / `liveOverrides` non-empty) because cross-sibling
83+ * builders may read those via `def.floorplanSiblingOverrides`.
84+ */
85+ type CachedEntry = {
86+ palette : FloorplanPalette | undefined
87+ base : FloorplanGeometry | null
88+ overlay : FloorplanGeometry | null
89+ }
90+ const geometryCache = new WeakMap < AnyNode , CachedEntry > ( )
91+
6592const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
6693const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
6794const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
@@ -270,6 +297,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
270297 highlighted : boolean
271298 } [ ] = [ ]
272299
300+ const palette = renderCtx ?. palette
301+
302+ // Live edits publish per-node entries into `liveTransforms` (mover-style
303+ // drags: items, slabs, ceilings) or `liveOverrides` (per-frame field
304+ // patches: wall endpoint drags). By convention the publishers include
305+ // every node whose geometry should reflect the cursor — for a wall
306+ // drag that's the moved wall plus its linked neighbours (see
307+ // `wallFloorplanSiblingOverrides`). So we only need to bypass the
308+ // cache for nodes explicitly in those maps. Everything else — doors,
309+ // windows, slabs, ceilings, items, zones, and non-linked walls — can
310+ // hit the cache even mid-drag.
311+ const canUseCache = (
312+ id : AnyNodeId ,
313+ selected : boolean ,
314+ highlighted : boolean ,
315+ hovered : boolean ,
316+ moving : boolean ,
317+ ) : boolean => {
318+ if ( selected || highlighted || hovered || moving ) return false
319+ if ( liveTransforms . has ( id ) ) return false
320+ if ( liveOverrides . has ( id ) ) return false
321+ return true
322+ }
323+
273324 const visit = ( id : AnyNodeId ) => {
274325 const node = nodes [ id ]
275326 if ( ! node ) return
@@ -280,6 +331,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
280331 const highlighted = highlightedIdSet . has ( id )
281332 const hovered = hoveredId === id
282333 const moving = movingNode ?. id === id
334+
335+ // Fast path: stable nodes with no active visual state can reuse
336+ // the last builder result. Walls dominate large scenes (~400+ for
337+ // CV-detected floor plans) and most are inert at any given moment;
338+ // skipping their builder + geometry split is the difference
339+ // between 1 FPS and 60 FPS on hover.
340+ if ( canUseCache ( id , selected , highlighted , hovered , moving ) ) {
341+ const cached = geometryCache . get ( node )
342+ if ( cached && cached . palette === palette ) {
343+ out . push ( {
344+ id,
345+ node,
346+ base : cached . base ,
347+ overlay : cached . overlay ,
348+ selected : false ,
349+ highlighted : false ,
350+ } )
351+ const childIds = ( node as unknown as { children ?: AnyNodeId [ ] } ) . children
352+ if ( Array . isArray ( childIds ) ) {
353+ for ( const cid of childIds ) visit ( cid )
354+ }
355+ return
356+ }
357+ }
283358 // Live-transform override — when a mover is publishing per-frame
284359 // position/rotation, render that here instead of the committed
285360 // scene state. Without this the 2D floor plan would only update
@@ -369,6 +444,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
369444 if ( geometry ) {
370445 const { base, overlay } = splitFloorplanOverlay ( geometry )
371446 out . push ( { id, node : effectiveNode , base, overlay, selected, highlighted } )
447+ // Only cache the inert form so the next hover/selection on a
448+ // neighbouring node can reuse it. Caching mid-edit would store
449+ // chrome geometry against a stable node ref and never invalidate.
450+ if ( canUseCache ( id , selected , highlighted , hovered , moving ) ) {
451+ geometryCache . set ( node , { palette, base, overlay } )
452+ }
372453 }
373454 }
374455 const childIds = ( node as unknown as { children ?: AnyNodeId [ ] } ) . children
@@ -406,6 +487,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
406487 const highlighted = highlightedIdSet . has ( cid )
407488 const hovered = hoveredId === cid
408489 const moving = movingNode ?. id === cid
490+
491+ // Same per-node cache fast path as the level-scoped DFS above.
492+ // Elevator runtime ticks via `interactiveElevators` already force
493+ // a fresh useMemo run, so the cached entries will be repopulated
494+ // with up-to-date geometry on the next pass.
495+ if ( canUseCache ( cid , selected , highlighted , hovered , moving ) ) {
496+ const cached = geometryCache . get ( node )
497+ if ( cached && cached . palette === palette ) {
498+ out . push ( {
499+ id : cid ,
500+ node,
501+ base : cached . base ,
502+ overlay : cached . overlay ,
503+ selected : false ,
504+ highlighted : false ,
505+ } )
506+ continue
507+ }
508+ }
509+
409510 const ctx : GeometryContext = {
410511 resolve : < N = AnyNode > ( rid : AnyNodeId ) : N | undefined => nodes [ rid ] as N | undefined ,
411512 children : [ ] ,
@@ -428,6 +529,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
428529 if ( geometry ) {
429530 const { base, overlay } = splitFloorplanOverlay ( geometry )
430531 out . push ( { id : cid , node, base, overlay, selected, highlighted } )
532+ if ( canUseCache ( cid , selected , highlighted , hovered , moving ) ) {
533+ geometryCache . set ( node , { palette, base, overlay } )
534+ }
431535 }
432536 }
433537 }
0 commit comments