From 56b4c5e45e4539c6ffa3e987004065e84c445743 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Mon, 27 Jul 2026 15:47:14 +0300 Subject: [PATCH 1/4] 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 7eb8234dd3..a3ffb678fc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -408,6 +408,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) @@ -1083,10 +1084,12 @@ export class SpatialGridManager { } } + const inputs = this.getSupportInputs(levelId, slabMap) + const support = 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, levelBase, @@ -1103,6 +1106,55 @@ export class SpatialGridManager { } } + /** + * 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). @@ -1223,6 +1275,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 4fd6563fed..f50cc333ec 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[], @@ -546,10 +577,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 2dd09faa2b..6984a8990f 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 f5ffa9e6de..92af85f31d 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -72,6 +72,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) @@ -103,14 +110,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 06eb8c5a72..3e8c8db26d 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -559,7 +559,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 @@ -620,7 +620,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) { @@ -670,6 +670,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 e54ff9d6150ceebd78c3d5d30c7299205f1fac83 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Tue, 28 Jul 2026 16:40:02 +0300 Subject: [PATCH 2/4] 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 3e8c8db26d..140e46d9db 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -34,6 +34,7 @@ import { type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import { useEffect } from 'react' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' @@ -514,6 +515,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 87ae82c873..37084ef58a 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 From 7acdcd4e5bc42a87066473febbafe194a5aac3d7 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 15:15:36 -0400 Subject: [PATCH 3/4] fix(core,viewer): keep junction order stable and cover the miter cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid prefilter visits the per-cell bucket before the oversized-wall fallback, so a wall spanning more than JUNCTION_GRID_MAX_CELLS_PER_WALL cells was appended after shorter walls it precedes in the input. Collinear walls overlapping a junction tie on angle, and the sort in calculateJunctionIntersections is stable, so that reordering picked the other wall's thickness for the miter: a 20 m facade with a collinear infill of a different thickness moved the spur's boundary by 0.32 m. Restore the input order before appending. Extract the level miter cache so it is reachable from a test — replacing sameMiterInputs with `return true` previously left the whole suite green, which meant the cache the PR is built around had no coverage at all. Co-Authored-By: Claude Opus 5 --- .../src/systems/wall/wall-mitering.test.ts | 38 ++++++++++- .../core/src/systems/wall/wall-mitering.ts | 19 +++++- .../systems/wall/level-miter-cache.test.ts | 66 +++++++++++++++++++ .../src/systems/wall/level-miter-cache.ts | 52 +++++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 47 +------------ 5 files changed, 173 insertions(+), 49 deletions(-) create mode 100644 packages/viewer/src/systems/wall/level-miter-cache.test.ts create mode 100644 packages/viewer/src/systems/wall/level-miter-cache.ts diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index 637b1840ec..815c505a7b 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import type { WallNode } from '../../schema' -import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering' +import { calculateLevelMiters, getWallMiterBoundaryPoints, pointToKey } from './wall-mitering' function wall(id: string, start: [number, number], end: [number, number]): WallNode { return { @@ -78,3 +78,39 @@ describe('wall miter boundary sides', () => { expect(boundary.endRight.y).toBeCloseTo(-0.05) }) }) + +describe('junction grid prefilter', () => { + function thickWall( + id: string, + start: [number, number], + end: [number, number], + thickness: number, + ): WallNode { + return { ...wall(id, start, end), thickness } as WallNode + } + + // A wall covering more than JUNCTION_GRID_MAX_CELLS_PER_WALL grid cells is held + // in the fallback bucket, which is scanned after the per-cell bucket. When such + // a wall and a shorter collinear one both pass through a junction they tie on + // angle, so the order they were appended in decides which thickness the miter + // uses — the prefilter must not reorder them relative to the input. + test('an oversized wall keeps its input position among collinear passthroughs', () => { + const long = thickWall('long', [0, 0], [20, 20], 0.6) + const infill = thickWall('infill', [4, 4], [12, 12], 0.15) + const spur = thickWall('spur', [8, 8], [8, 14], 0.3) + + const junction = calculateLevelMiters([long, infill, spur]).junctions.get( + pointToKey({ x: 8, y: 8 }), + ) + expect(junction).toBeDefined() + expect(junction?.connectedWalls.map((cw) => cw.wall.id)).toEqual(['spur', 'long', 'infill']) + }) + + test('a junction on a long wall is still found through the fallback bucket', () => { + const long = thickWall('long', [0, 0], [140, 0], 0.2) + const spur = thickWall('spur', [60, 0], [60, 6], 0.2) + + const junction = calculateLevelMiters([long, spur]).junctions.get(pointToKey({ x: 60, y: 0 })) + expect(junction?.connectedWalls.map((cw) => cw.wall.id)).toEqual(['spur', 'long']) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 6984a8990f..d9fa5bc0a6 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -184,12 +184,15 @@ function findJunctions(walls: WallNode[]): Map { // 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. + // point is always one of the cells the wall was indexed into. With the input + // ordering restored below, the result matches the naive pass exactly; measured + // 11 ms on the same geometry. const { grid, oversized } = buildJunctionGrid(walls) + const wallOrder = new Map(walls.map((wall, index) => [wall.id, index])) for (const [_key, junction] of junctions.entries()) { const p = junction.meetingPoint const cellCandidates = grid.get(cellKey(p.x, p.y)) + const passthrough: WallNode[] = [] for (const bucket of [cellCandidates, oversized]) { if (!bucket || bucket.length === 0) continue for (const wall of bucket) { @@ -198,10 +201,20 @@ function findJunctions(walls: WallNode[]): Map { // Check if junction point lies on this wall's segment (not at endpoints) if (pointOnWallSegment(junction.meetingPoint, wall)) { - junction.connectedWalls.push({ wall, endType: 'passthrough' }) + passthrough.push(wall) } } } + + // Append in input order, not bucket order. Two collinear walls overlapping a + // junction tie on angle in `calculateJunctionIntersections`, so its stable + // sort leaves them in the order they were appended here — and an oversized + // wall would otherwise land after a shorter collinear neighbour it precedes + // in `walls`, picking the other wall's thickness for the miter. + passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0)) + for (const wall of passthrough) { + junction.connectedWalls.push({ wall, endType: 'passthrough' }) + } } // Filter to only junctions with 2+ walls diff --git a/packages/viewer/src/systems/wall/level-miter-cache.test.ts b/packages/viewer/src/systems/wall/level-miter-cache.test.ts new file mode 100644 index 0000000000..e3105dbb4e --- /dev/null +++ b/packages/viewer/src/systems/wall/level-miter-cache.test.ts @@ -0,0 +1,66 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core' +import { clearLevelMiterCache, getCachedLevelMiters, sameMiterInputs } from './level-miter-cache' + +function wall(overrides: Record = {}) { + return WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.2, ...overrides }) +} + +describe('level miter cache', () => { + test('reuses the solution when the wall data is unchanged', () => { + clearLevelMiterCache() + const walls = [wall({ id: 'wall_a' })] + const first = getCachedLevelMiters('level_1', walls) + // A progressive rebuild passes a freshly mapped array every frame, so + // identity cannot be the hit condition — equal field values must be. + expect(getCachedLevelMiters('level_1', [...walls])).toBe(first) + }) + + test('recomputes when a wall moves', () => { + clearLevelMiterCache() + const before = getCachedLevelMiters('level_1', [wall({ id: 'wall_a' })]) + const after = getCachedLevelMiters('level_1', [wall({ id: 'wall_a', end: [6, 0] })]) + expect(after).not.toBe(before) + expect(after.junctions).not.toBe(before.junctions) + }) + + test('keys by level, so two levels do not share a solution', () => { + clearLevelMiterCache() + const walls = [wall({ id: 'wall_a' })] + expect(getCachedLevelMiters('level_2', walls)).not.toBe(getCachedLevelMiters('level_1', walls)) + }) + + test('clearing drops entries so a remount cannot serve a previous project', () => { + clearLevelMiterCache() + const walls = [wall({ id: 'wall_a' })] + const first = getCachedLevelMiters('level_1', walls) + clearLevelMiterCache() + expect(getCachedLevelMiters('level_1', walls)).not.toBe(first) + }) + + describe('input comparison', () => { + test('accepts identical field values across distinct objects', () => { + expect(sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a' })])).toBe(true) + }) + + test.each([ + ['id', { id: 'wall_b' }], + ['start', { start: [1, 0] }], + ['end', { end: [5, 0] }], + ['thickness', { thickness: 0.4 }], + ['curveOffset', { curveOffset: 0.5 }], + ])('rejects a change to %s', (_field, change) => { + expect(sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a', ...change })])).toBe( + false, + ) + }) + + test('rejects a differing wall count', () => { + expect( + sameMiterInputs([wall({ id: 'wall_a' })], [wall({ id: 'wall_a' }), wall({ id: 'wall_b' })]), + ).toBe(false) + }) + }) +}) diff --git a/packages/viewer/src/systems/wall/level-miter-cache.ts b/packages/viewer/src/systems/wall/level-miter-cache.ts new file mode 100644 index 0000000000..2f7dc57ed0 --- /dev/null +++ b/packages/viewer/src/systems/wall/level-miter-cache.ts @@ -0,0 +1,52 @@ +import { calculateLevelMiters, type WallMiterData, type WallNode } from '@pascal-app/core' + +// 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() + +export 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 +} + +export 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 +} + +/** + * The cache is module-level, so it outlives any single mount. Editor teardown + * resets the other shared singletons; without the same reset a remount in the + * same tab keeps every previous level's walls reachable. + */ +export function clearLevelMiterCache(): void { + levelMiterCache.clear() +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 140e46d9db..f84f48c0ff 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -1,7 +1,6 @@ import { type AnyNode, type AnyNodeId, - calculateLevelMiters, DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, @@ -41,6 +40,7 @@ import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' +import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, @@ -518,7 +518,7 @@ export const WallSystem = () => { // 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(), []) + useEffect(() => () => clearLevelMiterCache(), []) useFrame(() => { const hasDirty = dirtyNodes.size > 0 @@ -676,49 +676,6 @@ 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 d87e4fa6b20eaa502f34a4907c8651c3a3593252 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 4 Aug 2026 15:17:54 -0400 Subject: [PATCH 4/4] perf(core): key the level-elevation memo weakly The single-slot memo held a strong reference to the whole node record, so closing a project left its entire graph reachable until the next call. The adjacent wrapper in terrain-support.ts already uses a WeakMap for the same value; match it. Co-Authored-By: Claude Opus 5 --- packages/core/src/services/storey.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index f50cc333ec..b3289898ee 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -58,12 +58,13 @@ function resolveLevelBuildingId( */ // 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 +// frame (WallCutout), which rebuilt an identical Map 1000+ times a frame. Weakly +// keyed so a closed project's node graph is not pinned by the memo. +const elevationMemo = new WeakMap>() export function getLevelElevations(nodes: Record): Map { - if (memoNodes === nodes && memoElevations) return memoElevations + const memoized = elevationMemo.get(nodes) + if (memoized) return memoized const buildings = Object.values(nodes).filter( (node): node is BuildingNode => node?.type === 'building', @@ -95,8 +96,7 @@ export function getLevelElevations(nodes: Record): Map