Skip to content

Commit 480483e

Browse files
authored
fix(editor): workaround gnarly performance issue by caching geometry
1 parent 92cdd95 commit 480483e

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

.changeset/solid-frogs-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pascal-app/editor": patch
3+
---
4+
5+
Introduce geometry cache workaround into **FloorplanRegistryLayer** to fix performance regression related to egregious re-render/scene graph traversal/rebuild for cursor and edit interactions.

packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
6592
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
6693
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
6794
const 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

Comments
 (0)