From 6ac6ee35ae0d6626782e3e41cb78e6f07cfce9f5 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Mon, 27 Jul 2026 15:47:14 +0300 Subject: [PATCH 1/2] perf(core,viewer): stop rebuilding wall geometry every frame Three separate hot paths were doing full-scene work per frame on a 1081-wall floor, adding up to 5.6 s of main-thread stalls per six scroll ticks: - findJunctions was O(J x N) over every wall end; a spatial-grid prefilter narrows it to real neighbours (level assembly 59.3 s -> 0.01 s, calculateLevelMiters 436 ms -> 10.8 ms, identical output) - miter data is now cached per level and keyed on the exact wall inputs, so draining the dirty queue no longer recomputes it - getLevelElevations was called inside the per-wall loop; memoised - slab support and the wall appearance key were both unstable, which retriggered opening cutouts on every frame for no reason Measured on scene e5f5822f8837, six wheel ticks in split view: long tasks 5652 ms -> 4322 ms -> 0 ms. Co-Authored-By: Claude --- .../spatial-grid/spatial-grid-manager.ts | 58 ++++++++++++- packages/core/src/services/storey.ts | 10 +++ .../core/src/systems/slab/slab-support.ts | 36 +++++++- .../core/src/systems/wall/wall-mitering.ts | 85 +++++++++++++++++-- .../viewer/src/systems/wall/wall-cutout.tsx | 40 +++++++-- .../viewer/src/systems/wall/wall-system.tsx | 47 +++++++++- 6 files changed, 252 insertions(+), 24 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index f73fce4c1..28f260484 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -404,6 +404,7 @@ export class SpatialGridManager { private readonly renderedSlabPolygons = new Map>() private invalidateRenderedSlabPolygons(levelId: string) { + this.supportInputsRevision += 1 const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) @@ -1062,15 +1063,66 @@ export class SpatialGridManager { } } + const inputs = this.getSupportInputs(levelId, slabMap) + return computeWallSlabSupport( { start, end, curveOffset, thickness }, - [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), - this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), + inputs.slabs, + inputs.walls, preferredSlabId, maxElevation, ) } + /** + * Effective slab and wall records for a level, held BY IDENTITY. A single + * viewer pass queries support once per wall, and each query used to derive + * both arrays afresh — mapping every wall on the level through + * `getEffectiveNode` — which also defeated the rendered-polygon memo + * downstream in `computeWallSlabSupport`. Rebuilt only when the scene + * nodes, either live-preview store, or the manager's own slab/wall + * bookkeeping changes. + */ + private supportInputsRevision = 0 + private readonly supportInputs = new Map< + string, + { + revision: number + nodes: object + overrides: object + transforms: object + slabs: SlabNode[] + walls: WallNode[] + } + >() + + private getSupportInputs(levelId: string, slabMap: Map) { + const nodes = useScene.getState().nodes + const overrides = useLiveNodeOverrides.getState().overrides + const transforms = useLiveTransforms.getState().transforms + const cached = this.supportInputs.get(levelId) + if ( + cached && + cached.revision === this.supportInputsRevision && + cached.nodes === nodes && + cached.overrides === overrides && + cached.transforms === transforms + ) { + return cached + } + + const next = { + revision: this.supportInputsRevision, + nodes, + overrides, + transforms, + slabs: [...slabMap.values()].map((slab) => this.effectiveSlabRecord(slab)), + walls: this.getLevelWallNodes(levelId).map((wall) => getEffectiveNode(wall)), + } + this.supportInputs.set(levelId, next) + return next + } + /** * Walls on a level, resolved fresh from the scene store (the manager's * own wall map is only maintained on create/delete, not on updates). @@ -1191,6 +1243,8 @@ export class SpatialGridManager { this.ceilings.clear() this.itemCeilingMap.clear() this.renderedSlabPolygons.clear() + this.supportInputs.clear() + this.supportInputsRevision += 1 } } diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 4fd6563fe..f50cc333e 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -56,7 +56,15 @@ function resolveLevelBuildingId( * * Pure — operates on the serialized nodes record only. */ +// Identity-keyed memo. `nodes` is an immutable store slice, so a hit means the +// scene has not changed since the last call. Hot callers ask once per wall per +// frame (WallCutout), which rebuilt an identical Map 1000+ times a frame. +let memoNodes: Record | null = null +let memoElevations: Map | null = null + export function getLevelElevations(nodes: Record): Map { + if (memoNodes === nodes && memoElevations) return memoElevations + const buildings = Object.values(nodes).filter( (node): node is BuildingNode => node?.type === 'building', ) @@ -87,6 +95,8 @@ export function getLevelElevations(nodes: Record): Map>() + +function renderedSlabPolygon( + slab: SlabNode, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): Array<[number, number]> { + if (polygonMemoSlabs !== slabs || polygonMemoWalls !== levelWalls) { + polygonMemoSlabs = slabs + polygonMemoWalls = levelWalls + polygonMemo = new Map() + } + const cached = polygonMemo.get(slab.id) + if (cached) return cached + + const polygon = getRenderableSlabPolygon(slab, { + walls: levelWalls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + polygonMemo.set(slab.id, polygon) + return polygon +} + export function computeWallSlabSupport( wallLike: WallOverlapInput, slabs: readonly SlabNode[], @@ -527,10 +558,7 @@ export function computeWallSlabSupport( for (const slab of slabs) { if (slab.polygon.length < 3) continue - const renderedPolygon = getRenderableSlabPolygon(slab, { - walls: levelWalls, - siblingSlabs: slabs.filter((other) => other.id !== slab.id), - }) + const renderedPolygon = renderedSlabPolygon(slab, slabs, levelWalls) let supported = 0 const perPolyline = polylines.map((line) => { diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 2dd09faa2..6984a8990 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -100,6 +100,58 @@ interface Junction { connectedWalls: Array<{ wall: WallNode; endType: 'start' | 'end' | 'passthrough' }> } +// --- Uniform grid used to prefilter T-junction candidates -------------------- +// 2 m cells: small enough that a dense imported floor spreads across many +// buckets, large enough that an ordinary room wall touches only a few. +const JUNCTION_GRID_CELL = 2.0 +// A wall whose AABB would touch more than this many cells (a very long diagonal) +// is kept in a fallback list checked against every junction. Such walls are rare, +// and a model made only of them is a model with very few walls — where the naive +// scan was never the problem. +const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64 + +function cellKey(x: number, y: number): string { + return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}` +} + +function buildJunctionGrid(walls: WallNode[]): { + grid: Map + oversized: WallNode[] +} { + const grid = new Map() + const oversized: WallNode[] = [] + + for (const wall of walls) { + // Pad by TOLERANCE so a point sitting exactly on the AABB edge still lands + // in a covered cell. + const minX = Math.min(wall.start[0], wall.end[0]) - TOLERANCE + const maxX = Math.max(wall.start[0], wall.end[0]) + TOLERANCE + const minY = Math.min(wall.start[1], wall.end[1]) - TOLERANCE + const maxY = Math.max(wall.start[1], wall.end[1]) + TOLERANCE + + const cx0 = Math.floor(minX / JUNCTION_GRID_CELL) + const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL) + const cy0 = Math.floor(minY / JUNCTION_GRID_CELL) + const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL) + + if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) { + oversized.push(wall) + continue + } + + for (let cx = cx0; cx <= cx1; cx++) { + for (let cy = cy0; cy <= cy1; cy++) { + const key = `${cx},${cy}` + const bucket = grid.get(key) + if (bucket) bucket.push(wall) + else grid.set(key, [wall]) + } + } + } + + return { grid, oversized } +} + function findJunctions(walls: WallNode[]): Map { const junctions = new Map() @@ -122,15 +174,32 @@ function findJunctions(walls: WallNode[]): Map { junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' }) } - // Second pass: detect T-junctions (walls passing through junction points) + // Second pass: detect T-junctions (walls passing through junction points). + // + // The naive form of this pass is `for each junction: for each wall` — O(J×N). + // On a real imported floor (1081 walls, 2047 endpoint keys) that is ~2.2M + // pointOnWallSegment calls and measured 584 ms per findJunctions() call, which + // WallSystem then repeats every frame while progressively rebuilding. + // + // A T-junction can only exist where the junction point lies ON the wall + // segment, so it must lie inside the wall's AABB. Bucketing walls by the grid + // cells their AABB covers therefore loses nothing: the cell containing the + // point is always one of the cells the wall was indexed into. Result is + // bit-identical to the naive pass; measured 11 ms on the same geometry. + const { grid, oversized } = buildJunctionGrid(walls) for (const [_key, junction] of junctions.entries()) { - for (const wall of walls) { - // Skip if wall already in this junction - if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue - - // Check if junction point lies on this wall's segment (not at endpoints) - if (pointOnWallSegment(junction.meetingPoint, wall)) { - junction.connectedWalls.push({ wall, endType: 'passthrough' }) + const p = junction.meetingPoint + const cellCandidates = grid.get(cellKey(p.x, p.y)) + for (const bucket of [cellCandidates, oversized]) { + if (!bucket || bucket.length === 0) continue + for (const wall of bucket) { + // Skip if wall already in this junction + if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id)) continue + + // Check if junction point lies on this wall's segment (not at endpoints) + if (pointOnWallSegment(junction.meetingPoint, wall)) { + junction.connectedWalls.push({ wall, endType: 'passthrough' }) + } } } } diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 929accfe5..c1c6d4892 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -64,6 +64,13 @@ export const WallCutout = () => { const lastNumberOfWalls = useRef(0) const lastHighlightKey = useRef('') const lastWallAppearanceKey = useRef('') + const wallAppearanceKeyRef = useRef('') + const wallAppearanceInputs = useRef({ + nodes: null as object | null, + materials: null as object | null, + shading: null as unknown, + wallCount: -1, + }) const lastTextures = useRef(useViewer.getState().textures) const lastColorPreset = useRef(useViewer.getState().colorPreset) const lastSceneTheme = useRef(useViewer.getState().sceneTheme) @@ -95,14 +102,31 @@ export const WallCutout = () => { ? hoveredId : null const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}` - const wallAppearanceKey = Array.from(sceneRegistry.byType.wall!) - .sort() - .map((wallId) => { - const wallNode = sceneState.nodes[wallId as WallNode['id']] - if (wallNode?.type !== 'wall') return `${wallId}:missing` - return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}` - }) - .join('|') + // Sorting every wall id, hashing each wall's material and JSON-dumping its + // face bands is a full-scene scan; its inputs are immutable store slices, + // so identity is enough to know the key cannot have changed. + const wallCount = sceneRegistry.byType.wall!.size + const appearanceInputs = wallAppearanceInputs.current + if ( + appearanceInputs.nodes !== sceneState.nodes || + appearanceInputs.materials !== sceneState.materials || + appearanceInputs.shading !== shading || + appearanceInputs.wallCount !== wallCount + ) { + appearanceInputs.nodes = sceneState.nodes + appearanceInputs.materials = sceneState.materials + appearanceInputs.shading = shading + appearanceInputs.wallCount = wallCount + wallAppearanceKeyRef.current = Array.from(sceneRegistry.byType.wall!) + .sort() + .map((wallId) => { + const wallNode = sceneState.nodes[wallId as WallNode['id']] + if (wallNode?.type !== 'wall') return `${wallId}:missing` + return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}` + }) + .join('|') + } + const wallAppearanceKey = wallAppearanceKeyRef.current const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 754b02efb..cba660d8a 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -556,7 +556,7 @@ export const WallSystem = () => { } const levelWalls = getLevelWalls(levelId) - const miterData = calculateLevelMiters(levelWalls) + const miterData = getCachedLevelMiters(levelId, levelWalls) const rebuiltWallIds = new Set() // Update dirty walls — always, no throttling. The dragged wall must @@ -617,7 +617,7 @@ export const WallSystem = () => { for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) - const miterData = calculateLevelMiters(levelWalls) + const miterData = getCachedLevelMiters(levelId, levelWalls) for (const wallId of Array.from(pendingIds)) { if (useProgressiveAdjacentRebuilds) { if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { @@ -667,6 +667,49 @@ function getEffectiveWall(wall: WallNode): WallNode { return { ...wall, ...override } as WallNode } +// --- Level miter cache ------------------------------------------------------ +// A progressive rebuild drains 8 walls per frame, so a 1081-wall import takes +// ~136 frames. The miter solution does not change across those frames — nothing +// dirties the geometry in between — yet the naive code recomputed it every +// frame. Cache it, keyed on the exact wall data the miters depend on. +// +// The comparison is exact (no hashing): a stale hit would silently render wrong +// joints, and 7 numeric compares × N walls is microseconds — far cheaper than +// the risk. +type LevelMiterCacheEntry = { walls: WallNode[]; data: WallMiterData } +const levelMiterCache = new Map() + +function sameMiterInputs(a: WallNode[], b: WallNode[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if (x === y) continue + if (!x || !y) return false + if ( + x.id !== y.id || + x.start[0] !== y.start[0] || + x.start[1] !== y.start[1] || + x.end[0] !== y.end[0] || + x.end[1] !== y.end[1] || + x.thickness !== y.thickness || + x.curveOffset !== y.curveOffset + ) { + return false + } + } + return true +} + +function getCachedLevelMiters(levelId: string, levelWalls: WallNode[]): WallMiterData { + const cached = levelMiterCache.get(levelId) + if (cached && sameMiterInputs(cached.walls, levelWalls)) return cached.data + + const data = calculateLevelMiters(levelWalls) + levelMiterCache.set(levelId, { walls: levelWalls, data }) + return data +} + /** * Gets all walls that belong to a level, with any live overrides * merged in so miters compute against the cursor-driven positions From 1b54ec2e4b0997b11e3604b2f222b6d0d955995f Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Tue, 28 Jul 2026 16:40:02 +0300 Subject: [PATCH 2/2] fix(viewer): clear the level miter cache when the wall system unmounts The cache lives at module scope, so it outlived the mount it was created for: every level ever visited kept its wall array reachable, and a remount or a second project in the same tab simply added more. Editor teardown already resets the other shared singletons; the cache now does the same from the system's unmount effect. Also records the rule in the systems wiki, since the same trap is waiting for the next module-level memo. Co-Authored-By: Claude --- packages/viewer/src/systems/wall/wall-system.tsx | 6 ++++++ wiki/architecture/systems.md | 1 + 2 files changed, 7 insertions(+) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index cba660d8a..3068068af 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -33,6 +33,7 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import { useEffect } from 'react' import * as THREE from 'three' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' @@ -511,6 +512,11 @@ export const WallSystem = () => { // tick and the next `useFrame` would still see the stale closure. useLiveNodeOverrides((s) => s.overrides) + // The miter cache is module-level, so it outlives this mount. Editor + // teardown resets the other shared singletons; without the same reset here a + // remount in the same tab keeps every previous level's walls reachable. + useEffect(() => () => levelMiterCache.clear(), []) + useFrame(() => { const hasDirty = dirtyNodes.size > 0 const hasPending = pendingAdjacentByLevel.size > 0 diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index 87ae82c87..37084ef58 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -69,6 +69,7 @@ Core and viewer systems are mounted inside `` alongside renderers. See ` - **Never duplicate logic** between a system and a renderer — if the renderer needs it, the system should compute and store it, and the renderer reads the result. - Systems should be **idempotent**: given the same nodes, they produce the same output. - Mark nodes as `dirty` in the scene store to signal that a system should re-run. Avoid running expensive logic every frame without a dirty check. +- **Clear module-level caches on unmount.** A cache that survives between frames also survives the mount, and one keyed by level or node ID grows with every project opened in the tab. Reset it from the system's unmount effect, the same way editor teardown calls `spatialGridManager.clear()`. ## Adding a New System