From 353b429a013dd52139910d09b934a0af4eb10040 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 23 Jun 2026 15:24:06 -0400 Subject: [PATCH] perf(floorplan): override-driven 3D wall move + granular sibling invalidation Two fixes for the split-view FPS cliff when moving a wall or opening: - wall/move-tool: publish the live preview to useLiveNodeOverrides instead of writing useScene.updateNodes every frame. The store's `nodes` ref no longer churns per frame (which had re-rendered every useScene(s => s.nodes) subscriber app-wide). Matches the existing 2D wall drag + 3D wall-system override pattern; the final plan still commits atomically as one undoable change. - floorplan-registry-layer: replace the single global siblingEpoch with a per-node epoch bumped only for the nodes affected by the live drag (dragged wall -> walls at its old + new junctions + child openings; door/window -> host wall; gutter -> roof-peer gutters), unioned with the previous frame's live set so a cancelled drag reverts. Dragging one wall/opening now rebuilds a handful of geometries instead of all the level's walls + openings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../renderers/floorplan-registry-layer.tsx | 140 +++++++++++++++--- packages/nodes/src/wall/move-tool.tsx | 66 ++++++--- 2 files changed, 166 insertions(+), 40 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 954a33e1..04ab61b5 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -305,8 +305,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const [activeDragId, setActiveDragId] = useState(null) const [rotationOverlay, setRotationOverlay] = useState(null) const geometryCacheRef = useRef>(new Map()) - const siblingEpochInputsRef = useRef([]) - const siblingEpochRef = useRef(0) + // Per-node sibling epoch (replaces a single global epoch). Bumped only for the + // nodes affected by this frame's live drags, so an unaffected wall/opening + // keeps its epoch and stays cached. `prevLiveFlaggedIdsRef` remembers which + // sibling-dependent nodes were live last frame, so a node that just STOPPED + // being dragged (override cleared, no commit) still gets one final rebuild to + // revert — its dependents (host wall, junction neighbours) don't carry its + // override in their own deps. + const nodeSiblingEpochRef = useRef>(new Map()) + const prevLiveFlaggedIdsRef = useRef([]) const applyEntrySelection = useCallback( (id: AnyNodeId, shouldToggle: boolean) => { @@ -542,30 +549,38 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { return [] } - // The sibling epoch bumps whenever a sibling-affecting node's LIVE state - // changes (a wall/door/window/gutter being dragged or live-edited). Only - // flagged kinds feed it, so dragging or rotating a plain item — which also - // publishes to liveTransforms / liveOverrides — leaves it stable and the - // hundreds of wall/door geometries stay cached. Committed structural edits - // are covered separately by keying flagged kinds on the `nodes` ref. - const siblingEpochInputs: unknown[] = [] - for (const [id, live] of liveTransforms) { + // Granular sibling invalidation. A sibling-dependent node (wall miters, + // door/window cuts, gutter joins) must rebuild when a node it actually + // DEPENDS ON has a live drag in flight — not when ANY flagged node anywhere + // does. The old single global epoch took the latter route: dragging one wall + // or opening rebuilt every wall + opening on the level (the floor-plan FPS + // cliff). Instead, collect the flagged nodes with a live transform/override, + // expand to the set that depends on them (junction neighbours, host walls, + // gutter peers — see `computeAffectedSiblingIds`), and bump a PER-NODE epoch + // only for that set. `committedNodes` (kept in the deps) still catches + // committed structural edits, so this only narrows the LIVE-drag case. + const liveFlaggedIds: AnyNodeId[] = [] + for (const [id] of liveTransforms) { const node = nodes[id as AnyNodeId] if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { - siblingEpochInputs.push(live) + liveFlaggedIds.push(id as AnyNodeId) } } - for (const [id, override] of liveOverrides) { + for (const [id] of liveOverrides) { const node = nodes[id as AnyNodeId] if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { - siblingEpochInputs.push(override) + liveFlaggedIds.push(id as AnyNodeId) } } - if (!depsValueEqual(siblingEpochInputsRef.current, siblingEpochInputs)) { - siblingEpochRef.current += 1 - siblingEpochInputsRef.current = siblingEpochInputs + // Union with last frame's live set so a node that just stopped being dragged + // (and its dependents) rebuilds one final time to drop the now-cleared override. + const expandFrom = Array.from(new Set([...liveFlaggedIds, ...prevLiveFlaggedIdsRef.current])) + const affectedSiblingIds = computeAffectedSiblingIds(expandFrom, nodes, liveOverrides) + const nodeSiblingEpochs = nodeSiblingEpochRef.current + for (const id of affectedSiblingIds) { + nodeSiblingEpochs.set(id, (nodeSiblingEpochs.get(id) ?? 0) + 1) } - const siblingEpoch = siblingEpochRef.current + prevLiveFlaggedIdsRef.current = liveFlaggedIds const out: FloorplanEntry[] = [] const levelDataByType = new Map() const levelNodeIdsByType = new Map() @@ -624,7 +639,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { hovered, moving, palette: renderCtx?.palette, - siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0, + siblingEpoch: dependsOnSiblingInputs ? (nodeSiblingEpochs.get(id) ?? 0) : 0, // Sibling-dependent kinds (wall miters, opening cuts) read other nodes' // COMMITTED state via `ctx`, so a committed edit to a sibling/child that // doesn't change this node's own ref must still invalidate it. The @@ -2216,6 +2231,95 @@ function splitFloorplanOverlay(g: FloorplanGeometry): { return { base: g, overlay: null } } +// Stable string key for a wall endpoint, rounded to 1 mm so floating-point +// drift collapses while distinct corners stay distinct. +function endpointKey(x: number, y: number): string { + return `${Math.round(x * 1000)},${Math.round(y * 1000)}` +} + +// Given the sibling-dependent nodes with a live drag in flight, the set of +// floor-plan geometries that must rebuild this frame. A node's geometry depends +// on more than its own data: +// - a wall's miters depend on the walls meeting at each of its endpoints, so a +// dragged wall invalidates the walls at its old AND new junctions, plus its +// own door/window children (their cuts are drawn into it); +// - a door/window cut is drawn into its host wall, so it invalidates that wall; +// - a gutter join depends on sibling gutters under the same roof. +// Everything else stays cached, so dragging one wall/opening rebuilds a handful +// of geometries rather than every wall + opening on the level. +function computeAffectedSiblingIds( + liveFlaggedIds: readonly AnyNodeId[], + nodes: Record, + liveOverrides: Map>, +): Set { + const affected = new Set() + if (liveFlaggedIds.length === 0) return affected + + // Junction map (committed wall endpoint → wall ids), built lazily on first use. + let junctions: Map | null = null + const wallsAtPoint = (x: number, y: number): AnyNodeId[] => { + if (!junctions) { + junctions = new Map() + for (const id in nodes) { + const n = nodes[id] + if (n?.type !== 'wall') continue + const w = n as unknown as { start: [number, number]; end: [number, number] } + for (const [px, py] of [w.start, w.end]) { + const key = endpointKey(px, py) + const arr = junctions.get(key) + if (arr) arr.push(id as AnyNodeId) + else junctions.set(key, [id as AnyNodeId]) + } + } + } + return junctions.get(endpointKey(x, y)) ?? [] + } + + for (const id of liveFlaggedIds) { + const node = nodes[id] + if (!node) continue + affected.add(id) + if (node.type === 'wall') { + const w = node as unknown as { + start: [number, number] + end: [number, number] + children?: AnyNodeId[] + } + // Use the live (override-merged) endpoints as well as the committed ones, + // so walls at both the wall's old and new junctions get fresh miters. + const ov = liveOverrides.get(id) as + | { start?: [number, number]; end?: [number, number] } + | undefined + const points: [number, number][] = [w.start, w.end] + if (ov?.start) points.push(ov.start) + if (ov?.end) points.push(ov.end) + for (const [px, py] of points) { + for (const wid of wallsAtPoint(px, py)) affected.add(wid) + } + if (Array.isArray(w.children)) { + for (const cid of w.children) { + const child = nodes[cid] + if (child?.type === 'door' || child?.type === 'window') affected.add(cid) + } + } + } else if (node.type === 'door' || node.type === 'window') { + const hostId = (node as { parentId?: string }).parentId + if (hostId) affected.add(hostId as AnyNodeId) + } else if (node.type === 'gutter') { + const roofId = (node as { parentId?: string }).parentId + if (roofId) { + for (const sid in nodes) { + const s = nodes[sid] + if (s?.type === 'gutter' && (s as { parentId?: string }).parentId === roofId) { + affected.add(sid as AnyNodeId) + } + } + } + } + } + return affected +} + function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { const keys: Array = [ 'node', diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 1b327558..7ba487cf 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -213,17 +213,32 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { pauseSceneHistory(useScene) let shouldRestoreOnCleanup = true + // Wall ids that currently carry a live position override. Cleared on commit + // (after the final store write lands) and on cancel / external unmount. + const touchedWallIds = new Set() + const applyNodePreview = ( updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, ) => { - useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), + // Publish the preview to `useLiveNodeOverrides` rather than writing the + // scene store. The 3D wall system (`getEffectiveWall`) and the 2D floor + // plan (`wallFloorplanSiblingOverrides`) both merge these overrides, so + // the mesh + miters track the cursor with NO `useScene` churn during the + // drag. A store write would hand a fresh `nodes` reference to every + // `useScene(s => s.nodes)` subscriber each frame (catalog tiles, panels, + // selection) and rebuild them all. Mirrors the wall's own 2D drag pattern; + // the final plan is written once, atomically, on commit. + const overrides = useLiveNodeOverrides.getState() + const sceneState = useScene.getState() + overrides.setMany( + updates.map( + (entry) => + [entry.id, { start: entry.start, end: entry.end }] as [string, Record], + ), ) for (const entry of updates) { - useScene.getState().markDirty(entry.id as AnyNodeId) + touchedWallIds.add(entry.id as AnyNodeId) + sceneState.markDirty(entry.id as AnyNodeId) } } @@ -353,6 +368,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { latestSurfacePlans = null } + // Drop the wall position overrides and mark the walls dirty so they rebuild + // from the now-authoritative store value (after commit) or the unchanged + // pre-drag value (after cancel). + const clearWallOverrides = () => { + const overrides = useLiveNodeOverrides.getState() + const sceneState = useScene.getState() + for (const id of touchedWallIds) { + overrides.clear(id) + sceneState.markDirty(id) + } + touchedWallIds.clear() + } + const buildWallFromCenter = (center: [number, number]) => { const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] @@ -426,13 +454,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const restoreOriginal = () => { setGhostWallPreviews([]) - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) - // No scene rollback for surfaces — nothing was written. Just - // clear the live overrides so the renderer falls back to the - // (pre-drag, unchanged) store state. + // Nothing was written to the scene store during the drag — the preview + // was override-driven — so dropping the wall + surface overrides reveals + // the unchanged pre-drag state. + clearWallOverrides() clearSurfaceOverrides() } @@ -512,15 +537,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { shouldRestoreOnCleanup = false - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - // Surfaces stayed in the store the whole drag (override-driven - // mesh preview), so there's nothing to restore for them. + // The store was never touched during the drag (the preview was + // override-driven for both walls and surfaces), so there is no baseline to + // restore before the tracked commit — just resume history and write the + // final plan as one undoable change. setGhostWallPreviews([]) - applyNodePreview([ - { id: nodeId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) resumeSceneHistory(useScene) const commitPlan = getMovePlan(preview.start, preview.end) @@ -577,9 +598,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // updates, deletes) into the store while history is still // resumed, so the surface delta joins the wall change as one // undoable step. Then drop the live overrides — the renderer - // now reads the committed polygons directly. + // now reads the committed walls + polygons directly. commitSurfacesToStore() clearSurfaceOverrides() + clearWallOverrides() pauseSceneHistory(useScene)