From c8cf79f1335acf98cbd97dc12cc578450ce72c77 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 15:30:22 -0400 Subject: [PATCH 1/7] Wall treatments: recompute level miters only when their inputs change, subscribe per wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While any wall on a level was dirty, the treatment system recomputed the level's miters every frame (uncached, once per proud offset) and wrote a fresh object to its store; every wall renderer on the level subscribed to that object, so all of them re-rendered each frame — 65k WallTreatments renders in 12 s on the IFC castle, and the same fan-out on every wall drag. Now the system skips a level whose effective walls (identity, with live overrides cached per override) and proud offsets are unchanged, caches the per-proud miter data on those inputs, and each wall subscribes to a slice holding only its own endpoint intersections, compared structurally. Walls with no trim enabled mount no treatment work at all. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- packages/nodes/src/wall/renderer.tsx | 37 +++- packages/nodes/src/wall/system.test.ts | 194 ++++++++++++++++++ packages/nodes/src/wall/system.tsx | 95 +++++++-- .../src/wall/treatment-level-data.test.ts | 100 +++++++++ .../nodes/src/wall/treatment-level-data.ts | 89 +++++++- packages/nodes/src/wall/treatments.test.ts | 96 ++++++++- packages/nodes/src/wall/treatments.tsx | 8 + 7 files changed, 581 insertions(+), 38 deletions(-) create mode 100644 packages/nodes/src/wall/system.test.ts create mode 100644 packages/nodes/src/wall/treatment-level-data.test.ts diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index c239a4d945..04cecec679 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNode, type AnyNodeId, hiddenWallPointerEventsHeld, + useLiveNodeOverrides, useRegistry, useScene, type WallNode, @@ -15,7 +16,7 @@ import { useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { type ComponentProps, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' @@ -25,8 +26,25 @@ import { wallPointerEventsSuppressed, } from './pointer-transparency' import { createWallRayHitClassifier } from './selection-hit-owner' -import { useWallTreatmentLevelData } from './treatment-level-data' -import { createWallExtraSlotMaterials, WallTreatments } from './treatments' +import { createWallTreatmentSelector, useWallTreatmentLevelData } from './treatment-level-data' +import { + createWallExtraSlotMaterials, + hasWallTreatments, + WallTreatments, + wallTreatmentProudOffsets, +} from './treatments' + +function WallTreatmentSubscription( + props: Omit, 'levelData'>, +) { + const { node } = props + const selector = useMemo( + () => createWallTreatmentSelector(node, wallTreatmentProudOffsets(node)), + [node], + ) + const levelData = useWallTreatmentLevelData(selector) + return levelData ? : null +} /** * Thin wall renderer. @@ -122,8 +140,10 @@ const WallRenderer = ({ node }: { node: WallNode }) => { .filter((child): child is AnyNode => child !== undefined), ), ) - const treatmentLevelData = useWallTreatmentLevelData((state) => - node.parentId ? state.byLevelId.get(node.parentId) : undefined, + const treatmentOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) + const treatmentNode = useMemo( + () => (treatmentOverride ? ({ ...node, ...treatmentOverride } as WallNode) : node), + [node, treatmentOverride], ) // Subscribe to the scene-material palette so editing a `scene:` material a // wall slot references re-renders the wall live (the wall-system geometry @@ -172,12 +192,11 @@ const WallRenderer = ({ node }: { node: WallNode }) => { {...handlers} /> - {treatmentLevelData && ( - )} diff --git a/packages/nodes/src/wall/system.test.ts b/packages/nodes/src/wall/system.test.ts new file mode 100644 index 0000000000..77258bb99a --- /dev/null +++ b/packages/nodes/src/wall/system.test.ts @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core' +import { resetWallTreatmentLevels, updateWallTreatmentLevels } from './system' +import { useWallTreatmentLevelData } from './treatment-level-data' + +const originalScene = useScene.getState() + +afterEach(() => { + resetWallTreatmentLevels() + useLiveNodeOverrides.getState().clearAll() + useScene.setState(originalScene) +}) + +function wall(id: string, parentId = 'level_a'): WallNode { + return { + id, + type: 'wall', + parentId, + start: [0, 0], + end: [3, 0], + thickness: 0.1, + children: [], + } as unknown as WallNode +} + +function setWalls(walls: WallNode[], dirtyIds = walls.map((node) => node.id)) { + const levelIds = [...new Set(walls.map((node) => node.parentId))] + useScene.setState({ + nodes: Object.fromEntries([ + ...walls.map((node) => [node.id, node]), + ...levelIds.map((id) => [ + id, + { + id, + type: 'level', + children: walls.filter((node) => node.parentId === id).map((node) => node.id), + }, + ]), + ]), + dirtyNodes: new Set(dirtyIds), + } as never) +} + +function countWrites() { + const writes: string[] = [] + const unsubscribe = useWallTreatmentLevelData.subscribe((state, previous) => { + for (const [levelId, data] of state.byLevelId) { + if (data !== previous.byLevelId.get(levelId)) writes.push(levelId) + } + }) + return { writes, unsubscribe } +} + +describe('wall treatment frame updates', () => { + test('writes once across repeated dirty frames with unchanged wall identities', () => { + setWalls([wall('wall_a')]) + const { writes, unsubscribe } = countWrites() + try { + updateWallTreatmentLevels() + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a']) + } finally { + unsubscribe() + } + }) + + test('writes only the overridden level once per live override and once on clearing', () => { + setWalls([wall('wall_a'), wall('wall_b', 'level_b')]) + updateWallTreatmentLevels() + useScene.setState({ dirtyNodes: new Set() }) + const { writes, unsubscribe } = countWrites() + try { + useLiveNodeOverrides.getState().set('wall_a', { end: [4, 1] }) + updateWallTreatmentLevels() + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a']) + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls[0]?.end).toEqual([ + 4, 1, + ]) + + useLiveNodeOverrides.getState().set('wall_a', { end: [5, 1] }) + updateWallTreatmentLevels() + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a', 'level_a']) + + useLiveNodeOverrides.getState().clear('wall_a') + updateWallTreatmentLevels() + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a', 'level_a', 'level_a']) + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls[0]?.end).toEqual([ + 3, 0, + ]) + } finally { + unsubscribe() + } + }) + + test('invalidates effective walls when the stored wall changes under a live override', () => { + const node = wall('wall_a') + useLiveNodeOverrides.getState().set(node.id, { end: [4, 1] }) + setWalls([node]) + updateWallTreatmentLevels() + setWalls([{ ...node, thickness: 0.3 }]) + updateWallTreatmentLevels() + const effective = useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls[0] + expect(effective?.thickness).toBe(0.3) + expect(effective?.end).toEqual([4, 1]) + }) + + test('writes after wall addition and treatment proud changes', () => { + const a = wall('wall_a') + const b = wall('wall_b') + setWalls([a]) + updateWallTreatmentLevels() + const { writes, unsubscribe } = countWrites() + try { + setWalls([a, b]) + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a']) + const treated = { + ...a, + skirting: { enabled: true, proud: 0.02, height: 0.1, profile: 'flat', sides: 'both' }, + } as WallNode + setWalls([treated, b]) + updateWallTreatmentLevels() + const before = useWallTreatmentLevelData.getState().byLevelId.get('level_a')! + setWalls([{ ...treated, skirting: { ...treated.skirting!, proud: 0.04 } }, b]) + updateWallTreatmentLevels() + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a', 'level_a', 'level_a']) + const after = useWallTreatmentLevelData.getState().byLevelId.get('level_a')! + expect([...after.miterDataByProud.keys()]).not.toEqual([...before.miterDataByProud.keys()]) + } finally { + unsubscribe() + } + }) + + test('updates old and new levels when a wall moves between them', () => { + const a = wall('wall_a') + const b = wall('wall_b', 'level_b') + setWalls([a, b]) + updateWallTreatmentLevels() + const { writes, unsubscribe } = countWrites() + try { + setWalls([a, { ...b, parentId: a.parentId }], [b.id]) + updateWallTreatmentLevels() + expect(writes).toEqual(['level_a']) + expect(useWallTreatmentLevelData.getState().byLevelId.has('level_b')).toBe(false) + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls).toHaveLength(2) + } finally { + unsubscribe() + } + }) + + test('removes stale neighbors and handles an empty level with only the level dirty', () => { + const a = wall('wall_a') + const b = { ...wall('wall_b'), end: [0, 3] } as WallNode + setWalls([a, b]) + updateWallTreatmentLevels() + setWalls([a], []) + updateWallTreatmentLevels() + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls).toEqual([a]) + const nodes = { ...useScene.getState().nodes } + delete nodes[a.id] + nodes[a.parentId as AnyNodeId] = { id: a.parentId, type: 'level', children: [] } as never + useScene.setState({ nodes, dirtyNodes: new Set([a.parentId as AnyNodeId]) }) + updateWallTreatmentLevels() + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls).toEqual([]) + }) + + test('clears removed levels even when no nodes are dirty and rebuilds reused ids', () => { + const a = wall('wall_a') + setWalls([a]) + updateWallTreatmentLevels() + useScene.setState({ nodes: {}, dirtyNodes: new Set() }) + updateWallTreatmentLevels() + expect(useWallTreatmentLevelData.getState().byLevelId.size).toBe(0) + setWalls([a]) + updateWallTreatmentLevels() + expect(useWallTreatmentLevelData.getState().byLevelId.get('level_a')?.walls).toEqual([a]) + }) + + test('teardown clears published data and permits an identical scene to rebuild', () => { + setWalls([wall('wall_a')]) + updateWallTreatmentLevels() + const before = useWallTreatmentLevelData.getState().byLevelId.get('level_a') + resetWallTreatmentLevels() + expect(useWallTreatmentLevelData.getState().byLevelId.size).toBe(0) + updateWallTreatmentLevels() + const after = useWallTreatmentLevelData.getState().byLevelId.get('level_a') + expect(after).toEqual(before) + expect(after?.miterDataByProud.get(0)).not.toBe(before?.miterDataByProud.get(0)) + }) +}) diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index 0c9f42c312..d121c9fd2b 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -1,41 +1,92 @@ 'use client' import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core' -import { WallCutout, WallSystem } from '@pascal-app/viewer' +import { timeSpan, WallCutout, WallSystem } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' -import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data' +import { useEffect } from 'react' +import { + buildWallTreatmentLevelData, + clearWallTreatmentMiterCache, + sameTreatmentWalls, + treatmentProudKeys, + useWallTreatmentLevelData, +} from './treatment-level-data' import { wallTreatmentProudOffsets } from './treatments' import { WallBatchSystem } from './wall-batch-system' +const levelInputs = new Map() +let effectiveWalls = new WeakMap< + WallNode, + { override: ReturnType['get']>; wall: WallNode } +>() +let previousNodes: ReturnType['nodes'] | undefined +let previousOverrides: ReturnType['overrides'] | undefined + function effectiveWall(wall: WallNode): WallNode { const override = useLiveNodeOverrides.getState().get(wall.id) - return override ? ({ ...wall, ...override } as WallNode) : wall + if (!override) return wall + const cached = effectiveWalls.get(wall) + if (cached?.override === override) return cached.wall + const effective = { ...wall, ...override } as WallNode + effectiveWalls.set(wall, { override, wall: effective }) + return effective } -const WallTreatmentMiterSystem = () => { - useFrame(() => { - const { dirtyNodes, nodes } = useScene.getState() - if (dirtyNodes.size === 0) return +export function resetWallTreatmentLevels(): void { + levelInputs.clear() + effectiveWalls = new WeakMap() + previousNodes = undefined + previousOverrides = undefined + clearWallTreatmentMiterCache() + useWallTreatmentLevelData.setState({ byLevelId: new Map() }) +} + +export function updateWallTreatmentLevels(): void { + const { dirtyNodes, nodes } = useScene.getState() + const { overrides } = useLiveNodeOverrides.getState() + const dirtyLevelIds = new Set() + for (const id of dirtyNodes) { + const node = nodes[id] + if (node?.type === 'wall' && node.parentId) dirtyLevelIds.add(node.parentId) + else if (node?.type === 'level') dirtyLevelIds.add(node.id) + } + + // Removed walls and cleared overrides can leave no dirty wall to identify their old level. + if (nodes !== previousNodes || overrides !== previousOverrides) { + for (const levelId of levelInputs.keys()) dirtyLevelIds.add(levelId) + previousNodes = nodes + previousOverrides = overrides + } - const dirtyLevelIds = new Set() - for (const id of dirtyNodes) { - const node = nodes[id] - if (node?.type === 'wall' && node.parentId) dirtyLevelIds.add(node.parentId) + for (const levelId of dirtyLevelIds) { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') { + levelInputs.delete(levelId) + clearWallTreatmentMiterCache(levelId) + useWallTreatmentLevelData.getState().removeLevelData(levelId) + continue } + const walls = level.children + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + .map(effectiveWall) + const proudOffsets = walls.flatMap(wallTreatmentProudOffsets) + const proudKey = treatmentProudKeys(proudOffsets).join(',') + const previous = levelInputs.get(levelId) + if (previous?.proudKey === proudKey && sameTreatmentWalls(previous.walls, walls)) continue - for (const levelId of dirtyLevelIds) { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') continue - const walls = level.children - .map((id) => nodes[id]) - .filter((node): node is WallNode => node?.type === 'wall') - .map(effectiveWall) - const proudOffsets = walls.flatMap(wallTreatmentProudOffsets) + timeSpan('wall-treatment-level', () => { useWallTreatmentLevelData .getState() - .setLevelData(levelId, buildWallTreatmentLevelData(walls, proudOffsets)) - } - }, -1) + .setLevelData(levelId, buildWallTreatmentLevelData(levelId, walls, proudOffsets)) + levelInputs.set(levelId, { walls, proudKey }) + }) + } +} + +const WallTreatmentMiterSystem = () => { + useEffect(() => resetWallTreatmentLevels, []) + useFrame(updateWallTreatmentLevels, -1) return null } diff --git a/packages/nodes/src/wall/treatment-level-data.test.ts b/packages/nodes/src/wall/treatment-level-data.test.ts new file mode 100644 index 0000000000..53dfc65c1f --- /dev/null +++ b/packages/nodes/src/wall/treatment-level-data.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import type { WallNode } from '@pascal-app/core' +import { + buildWallTreatmentLevelData, + clearWallTreatmentMiterCache, + createWallTreatmentSelector, + useWallTreatmentLevelData, +} from './treatment-level-data' + +afterEach(() => { + clearWallTreatmentMiterCache() + useWallTreatmentLevelData.setState({ byLevelId: new Map() }) +}) + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { id, type: 'wall', parentId: 'level_a', start, end, thickness: 0.1 } as WallNode +} + +function publish(walls: WallNode[], proudOffsets = [0.02]) { + const data = buildWallTreatmentLevelData('level_a', walls, proudOffsets) + useWallTreatmentLevelData.getState().setLevelData('level_a', data) + return useWallTreatmentLevelData.getState() +} + +describe('wall treatment miter cache', () => { + test('reuses normalized proud entries on identical ordered wall references', () => { + const walls = [wall('wall_a', [0, 0], [3, 0])] + const before = buildWallTreatmentLevelData('level_a', walls, [0.02]) + const after = buildWallTreatmentLevelData('level_a', [...walls], [0.02000001, 0.03, 0.02]) + expect([...after.miterDataByProud.keys()]).toEqual([0, 0.02, 0.03]) + expect(after.miterDataByProud.get(0)).toBe(before.miterDataByProud.get(0)) + expect(after.miterDataByProud.get(0.02)).toBe(before.miterDataByProud.get(0.02)) + const pruned = buildWallTreatmentLevelData('level_a', walls, []) + expect([...pruned.miterDataByProud.keys()]).toEqual([0]) + }) + + test('invalidates on replacement, order, membership, level id, and cache cleanup', () => { + const a = wall('wall_a', [0, 0], [3, 0]) + const b = wall('wall_b', [0, 0], [0, 3]) + let previous = buildWallTreatmentLevelData('level_a', [a, b], [0.02]) + for (const walls of [[{ ...a }, b], [b, a], [a]]) { + const next = buildWallTreatmentLevelData('level_a', walls, [0.02]) + expect(next.miterDataByProud.get(0)).not.toBe(previous.miterDataByProud.get(0)) + expect(next.miterDataByProud.get(0.02)).not.toBe(previous.miterDataByProud.get(0.02)) + previous = next + } + const other = buildWallTreatmentLevelData('level_b', [a], [0.02]) + expect(other.miterDataByProud.get(0)).not.toBe(previous.miterDataByProud.get(0)) + clearWallTreatmentMiterCache('level_a') + const reset = buildWallTreatmentLevelData('level_a', [a], [0.02]) + expect(reset.miterDataByProud.get(0)).not.toBe(previous.miterDataByProud.get(0)) + expect(buildWallTreatmentLevelData('level_b', [a], [0.02]).miterDataByProud.get(0)).toBe( + other.miterDataByProud.get(0), + ) + }) +}) + +describe('wall treatment selector', () => { + test('changes only the moved wall and its affected junction neighbor', () => { + const a = wall('wall_a', [0, 0], [3, 0]) + const b = wall('wall_b', [0, 0], [0, 3]) + const c = wall('wall_c', [10, 0], [13, 0]) + const selectors = [a, b, c].map((node) => createWallTreatmentSelector(node, [0.02])) + const before = publish([a, b, c]) + const slices = selectors.map((select) => select(before)) + const after = publish([{ ...a, end: [3, 1] }, b, c]) + expect(selectors[0]!(after)).not.toBe(slices[0]) + expect(selectors[1]!(after)).not.toBe(slices[1]) + expect(selectors[2]!(after)).toBe(slices[2]) + expect(selectors[2]!(after)).toBe(selectors[2]!(after)) + }) + + test('updates a T-junction endpoint when the passing wall changes thickness', () => { + const through = wall('wall_a', [-3, 0], [3, 0]) + const branch = wall('wall_b', [0, 0], [0, 3]) + const select = createWallTreatmentSelector(branch, [0.02]) + const before = select(publish([through, branch])) + const after = select(publish([{ ...through, thickness: 0.3 }, branch])) + expect(after).not.toBe(before) + }) + + test('ignores unrelated proud offsets and neighbor metadata but tracks lost junctions', () => { + const a = wall('wall_a', [0, 0], [3, 0]) + const b = wall('wall_b', [0, 0], [0, 3]) + const select = createWallTreatmentSelector(a, [0.02]) + const before = select(publish([a, b])) + expect(select(publish([a, b], [0.02, 0.05]))).toBe(before) + expect(select(publish([a, { ...b, name: 'Renamed wall' }]))).toBe(before) + expect(select(publish([a]))).not.toBe(before) + }) + + test('clears a slice when its level disappears and restores it on reload', () => { + const a = wall('wall_a', [0, 0], [3, 0]) + const select = createWallTreatmentSelector(a, [0.02]) + const before = select(publish([a])) + useWallTreatmentLevelData.getState().removeLevelData('level_a') + expect(select(useWallTreatmentLevelData.getState())).toBeUndefined() + expect(select(publish([a]))).toEqual(before) + }) +}) diff --git a/packages/nodes/src/wall/treatment-level-data.ts b/packages/nodes/src/wall/treatment-level-data.ts index 16e760b680..b58fb27f0e 100644 --- a/packages/nodes/src/wall/treatment-level-data.ts +++ b/packages/nodes/src/wall/treatment-level-data.ts @@ -1,10 +1,13 @@ import { calculateLevelMiters, getWallThickness, + isCurvedWall, + pointToKey, type WallMiterData, type WallNode, } from '@pascal-app/core' import { create } from 'zustand' +import { shallow } from 'zustand/vanilla/shallow' const PROUD_KEY_PRECISION = 1e6 @@ -12,19 +15,41 @@ function proudKey(proud: number) { return Math.round(proud * PROUD_KEY_PRECISION) / PROUD_KEY_PRECISION } +export function treatmentProudKeys(proudOffsets: readonly number[]): number[] { + return [...new Set([0, ...proudOffsets.map(proudKey)])].sort((a, b) => a - b) +} + +export function sameTreatmentWalls(a: readonly WallNode[], b: readonly WallNode[]): boolean { + return a.length === b.length && a.every((wall, index) => wall === b[index]) +} + export type WallTreatmentLevelData = { walls: readonly WallNode[] miterDataByProud: ReadonlyMap } +const levelMiterCache = new Map() + +export function clearWallTreatmentMiterCache(levelId?: string): void { + if (levelId === undefined) levelMiterCache.clear() + else levelMiterCache.delete(levelId) +} + export function buildWallTreatmentLevelData( + levelId: string, walls: readonly WallNode[], proudOffsets: readonly number[], ): WallTreatmentLevelData { - const uniqueProudOffsets = new Set([0, ...proudOffsets.map(proudKey)]) + const cached = levelMiterCache.get(levelId) + const reusable = cached && sameTreatmentWalls(cached.walls, walls) ? cached : undefined const miterDataByProud = new Map() - for (const proud of uniqueProudOffsets) { + for (const proud of treatmentProudKeys(proudOffsets)) { + const previous = reusable?.miterDataByProud.get(proud) + if (previous) { + miterDataByProud.set(proud, previous) + continue + } const adjustedWalls = proud === 0 ? [...walls] @@ -35,7 +60,9 @@ export function buildWallTreatmentLevelData( miterDataByProud.set(proud, calculateLevelMiters(adjustedWalls)) } - return { walls, miterDataByProud } + const data = { walls, miterDataByProud } + levelMiterCache.set(levelId, data) + return data } export function treatmentMiterDataForProud( @@ -48,6 +75,55 @@ export function treatmentMiterDataForProud( type WallTreatmentLevelDataState = { byLevelId: ReadonlyMap setLevelData: (levelId: string, data: WallTreatmentLevelData) => void + removeLevelData: (levelId: string) => void +} + +export function createWallTreatmentSelector(node: WallNode, proudOffsets: readonly number[]) { + const keys = isCurvedWall(node) + ? [] + : [...new Set([node.start, node.end].map(([x, y]) => pointToKey({ x, y })))] + const prouds = treatmentProudKeys(proudOffsets) + let previousLevel: WallTreatmentLevelData | undefined + let previousSlice: WallTreatmentLevelData | undefined + let previousInputs: Array = [] + + return (state: WallTreatmentLevelDataState): WallTreatmentLevelData | undefined => { + const level = node.parentId ? state.byLevelId.get(node.parentId) : undefined + if (level === previousLevel) return previousSlice + previousLevel = level + if (!level) { + previousSlice = undefined + previousInputs = [] + return undefined + } + + const inputs: Array = [] + for (const proud of prouds) { + const data = level.miterDataByProud.get(proud) + inputs.push(!!data) + for (const key of keys) { + const entry = data?.junctionData.get(key)?.get(node.id) + inputs.push(entry?.left?.x, entry?.left?.y, entry?.right?.x, entry?.right?.y) + } + } + if (previousSlice && shallow(previousInputs, inputs)) return previousSlice + + const miterDataByProud = new Map() + for (const proud of prouds) { + const data = level.miterDataByProud.get(proud) + if (!data) continue + const junctionData: WallMiterData['junctionData'] = new Map() + for (const key of keys) { + const entry = data.junctionData.get(key)?.get(node.id) + if (entry) junctionData.set(key, new Map([[node.id, entry]])) + } + // Trim boundaries read only this wall's endpoint intersections, never junction membership. + miterDataByProud.set(proud, { junctionData, junctions: new Map() }) + } + previousInputs = inputs + previousSlice = { walls: [node], miterDataByProud } + return previousSlice + } } export const useWallTreatmentLevelData = create((set) => ({ @@ -58,4 +134,11 @@ export const useWallTreatmentLevelData = create((se byLevelId.set(levelId, data) return { byLevelId } }), + removeLevelData: (levelId) => + set((state) => { + if (!state.byLevelId.has(levelId)) return state + const byLevelId = new Map(state.byLevelId) + byLevelId.delete(levelId) + return { byLevelId } + }), })) diff --git a/packages/nodes/src/wall/treatments.test.ts b/packages/nodes/src/wall/treatments.test.ts index a54799b99d..d42a2b0116 100644 --- a/packages/nodes/src/wall/treatments.test.ts +++ b/packages/nodes/src/wall/treatments.test.ts @@ -1,7 +1,17 @@ import { describe, expect, test } from 'bun:test' -import type { WallNode, WallTrimConfig } from '@pascal-app/core' -import { buildWallTreatmentLevelData } from './treatment-level-data' -import { buildTrimGeometry, wallTreatmentProudOffsets } from './treatments' +import { + calculateLevelMiters, + getWallThickness, + type WallNode, + type WallTrimConfig, +} from '@pascal-app/core' +import { + buildWallTreatmentLevelData, + createWallTreatmentSelector, + useWallTreatmentLevelData, + type WallTreatmentLevelData, +} from './treatment-level-data' +import { buildTrimGeometry, hasWallTreatments, wallTreatmentProudOffsets } from './treatments' function wall(id: string, start: [number, number], end: [number, number]): WallNode { return { @@ -36,7 +46,11 @@ function treatmentLevelData(walls: WallNode[]) { crown: trim, chairRail: trim, })) - return buildWallTreatmentLevelData(treatedWalls, treatedWalls.flatMap(wallTreatmentProudOffsets)) + return buildWallTreatmentLevelData( + 'level_test', + treatedWalls, + treatedWalls.flatMap(wallTreatmentProudOffsets), + ) } function cornerXs( @@ -69,6 +83,80 @@ function allPositions(geometry: NonNullable } describe('wall treatment miters', () => { + test('mount eligibility follows disabled defaults and each enabled trim kind', () => { + const node = wall('A', [0, 0], [3, 0]) + expect(hasWallTreatments(node)).toBe(false) + expect(wallTreatmentProudOffsets(node)).toEqual([]) + for (const kind of ['skirting', 'crown', 'chairRail'] as const) { + expect(hasWallTreatments({ ...node, [kind]: trim })).toBe(true) + expect(hasWallTreatments({ ...node, [kind]: { ...trim, enabled: false } })).toBe(false) + } + }) + + test.each([ + 'skirting', + 'crown', + 'chairRail', + ] as const)('preserves %s position bytes with cached per-wall endpoint data', (kind) => { + const cases = [ + [wall('A', [0, 0], [3, 0])], + [wall('A', [0, 0], [3, 0]), wall('B', [3, 0], [3, 3])], + [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [1, 3])], + [wall('A', [0, 0], [0, 3]), wall('B', [-3, 0], [3, 0])], + [{ ...wall('A', [0, 0], [3, 0]), curveOffset: 0.4 }], + ] + for (const walls of cases) { + const node = { ...walls[0]!, [kind]: trim } + const proudOffsets = wallTreatmentProudOffsets(node) + const prouds = new Set([0, ...proudOffsets.map((proud) => Math.round(proud * 1e6) / 1e6)]) + const reference: WallTreatmentLevelData = { + walls, + miterDataByProud: new Map( + [...prouds].map((proud) => [ + proud, + calculateLevelMiters( + proud === 0 + ? [...walls] + : walls.map((entry) => ({ + ...entry, + thickness: getWallThickness(entry) + proud * 2, + })), + ), + ]), + ), + } + const data = buildWallTreatmentLevelData('level_test', walls, proudOffsets) + const select = createWallTreatmentSelector(node, proudOffsets) + const slice = select({ + ...useWallTreatmentLevelData.getState(), + byLevelId: new Map([['level_test', data]]), + })! + for (const side of ['interior', 'exterior'] as const) { + const openings = [ + { + type: 'door', + width: 0.8, + height: 2, + position: [1.5, 1, 0] as [number, number, number], + }, + ] + const before = buildTrimGeometry(node, side, trim, kind, openings, reference)! + const after = buildTrimGeometry(node, side, trim, kind, openings, slice)! + expect(before).not.toBeNull() + expect(after).not.toBeNull() + const beforeArray = before.getAttribute('position').array + const afterArray = after.getAttribute('position').array + expect( + new Uint8Array(afterArray.buffer, afterArray.byteOffset, afterArray.byteLength), + ).toEqual( + new Uint8Array(beforeArray.buffer, beforeArray.byteOffset, beforeArray.byteLength), + ) + before.dispose() + after.dispose() + } + } + }) + test.each([ ['skirting', 0.0624], ['crown', 0.0604], diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 6fe6856bd9..9296a98164 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -261,6 +261,14 @@ function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) { ) } +export function hasWallTreatments(node: WallNode): boolean { + return !!( + (node.skirting?.enabled ?? WALL_SKIRTING_DEFAULT.enabled) || + (node.crown?.enabled ?? WALL_CROWN_DEFAULT.enabled) || + (node.chairRail?.enabled ?? WALL_CHAIR_RAIL_DEFAULT.enabled) + ) +} + export function wallTreatmentProudOffsets(node: WallNode): number[] { const offsets = new Set() const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [ From c6cf10d4a8d6577209878032c9b084c79e557a3a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 15:31:38 -0400 Subject: [PATCH 2/7] Wall openings: union the cutouts and subtract once; give heavy walls their own frame Each opening was subtracted in turn from a result that grew with every cut, so a wall with 20 openings paid 20 passes over an ever-larger mesh (70 ms in one call on the IFC castle). The cutouts are now grouped by bounding-box overlap: disjoint ones are merged as-is, overlapping groups go through a real union, and the wall is cut once. The progressive rebuild loop also defers a wall with six or more cutouts when the frame has already rebuilt something, instead of checking the time budget only between walls. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- .../src/systems/wall/wall-csg-union.test.ts | 403 ++++++++++++++++++ .../wall/wall-progressive-budget.test.ts | 71 +++ .../viewer/src/systems/wall/wall-system.tsx | 171 ++++++-- 3 files changed, 605 insertions(+), 40 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-csg-union.test.ts create mode 100644 packages/viewer/src/systems/wall/wall-progressive-budget.test.ts diff --git a/packages/viewer/src/systems/wall/wall-csg-union.test.ts b/packages/viewer/src/systems/wall/wall-csg-union.test.ts new file mode 100644 index 0000000000..0106b41c62 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-csg-union.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, spyOn, test } from 'bun:test' +import { + type AnyNode, + calculateLevelMiters, + DoorNode, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { prepareBrushForCSG } from '../../lib/csg-utils' +import { + buildOpeningCutoutGeometry, + getOpeningCutoutBottomPadding, +} from './opening-cutout-geometry' +import { generateExtrudedWall, mergeWallCutoutBrushes } from './wall-system' + +type Opening = DoorNode | WindowNode + +function openingBrush(opening: Opening, thickness: number): Brush { + const bottom = opening.position[1] - opening.height / 2 + const brush = new Brush( + buildOpeningCutoutGeometry( + opening, + { + left: opening.position[0] - opening.width / 2, + right: opening.position[0] + opening.width / 2, + bottom: bottom - getOpeningCutoutBottomPadding(opening, bottom), + top: opening.position[1] + opening.height / 2, + }, + thickness * 2, + thickness, + ), + ) + prepareBrushForCSG(brush) + return brush +} + +function chainedSubtract(wall: Brush, cutters: Brush[], evaluator: Evaluator): Brush { + let result = wall + for (const cutter of cutters) { + const next = evaluator.evaluate(result, cutter, SUBTRACTION) + prepareBrushForCSG(next) + if (result !== wall) result.geometry.dispose() + result = next + } + return result +} + +function generateChainedReference(wall: WallNode, openings: Opening[]): THREE.BufferGeometry { + const cutters = openings.map((opening) => openingBrush(opening, wall.thickness)) + return withChainedSubtraction(cutters, () => + generateExtrudedWall(wall, openings, calculateLevelMiters([wall])), + ) +} + +function withChainedSubtraction(cutters: Brush[], generate: () => THREE.BufferGeometry) { + const evaluate = Evaluator.prototype.evaluate + const referenceEvaluator = new Evaluator() + referenceEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] + referenceEvaluator.evaluate = evaluate + // Substitute only the boolean stage so both paths use the actual wall's + // mitering, band splitting, and final reveal material classification. + const spy = spyOn(Evaluator.prototype, 'evaluate').mockImplementation(function ( + this: Evaluator, + a: Brush, + b: Brush, + operation: typeof SUBTRACTION, + ) { + return operation === SUBTRACTION + ? chainedSubtract(a, cutters, referenceEvaluator) + : evaluate.call(this, a, b, operation) + }) + try { + return generate() + } finally { + spy.mockRestore() + for (const cutter of cutters) cutter.geometry.dispose() + } +} + +function triangleCount(geometry: THREE.BufferGeometry): number { + return (geometry.index?.count ?? geometry.getAttribute('position').count) / 3 +} + +function measurements(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const cross = new THREE.Vector3() + let volume = 0 + const materialAreas = new Map() + for (let offset = 0; offset < triangleCount(geometry) * 3; offset += 3) { + const vertexIndex = (corner: number) => geometry.index?.getX(offset + corner) ?? offset + corner + a.fromBufferAttribute(position, vertexIndex(0)) + b.fromBufferAttribute(position, vertexIndex(1)) + c.fromBufferAttribute(position, vertexIndex(2)) + volume += a.dot(cross.crossVectors(b, c)) / 6 + const area = cross.crossVectors(b.sub(a), c.sub(a)).length() / 2 + const material = geometry.groups.find( + (group) => offset >= group.start && offset < group.start + group.count, + )?.materialIndex + expect(material).toBeDefined() + materialAreas.set(material!, (materialAreas.get(material!) ?? 0) + area) + } + geometry.computeBoundingBox() + return { volume, materialAreas, bounds: geometry.boundingBox! } +} + +function expectEquivalent( + actual: THREE.BufferGeometry, + reference: THREE.BufferGeometry, + relativeAreaTolerance = 0, +) { + expect(Array.from(actual.getAttribute('position').array).every(Number.isFinite)).toBe(true) + const a = measurements(actual) + const b = measurements(reference) + expect(Math.abs(a.volume - b.volume)).toBeLessThan(1e-6) + expect(a.bounds.min.distanceTo(b.bounds.min)).toBeLessThan(1e-6) + expect(a.bounds.max.distanceTo(b.bounds.max)).toBeLessThan(1e-6) + expect([...a.materialAreas.keys()].sort()).toEqual([...b.materialAreas.keys()].sort()) + const totalArea = [...b.materialAreas.values()].reduce((sum, area) => sum + area, 0) + for (const [material, area] of a.materialAreas) { + expect(Math.abs(area - b.materialAreas.get(material)!)).toBeLessThan( + Math.max(1e-6, relativeAreaTolerance * totalArea), + ) + } +} + +function fixture() { + const wall = WallNode.parse({ start: [0, 0], end: [8, 0], height: 3, thickness: 0.25 }) + const mesh = new THREE.Mesh() + sceneRegistry.nodes.set(wall.id, mesh) + const windowAt = (x: number, width = 1) => + WindowNode.parse({ wallId: wall.id, position: [x, 1.5, 0], width, height: 1 }) + const cleanup = () => { + sceneRegistry.nodes.delete(wall.id) + mesh.geometry.dispose() + } + return { wall, windowAt, cleanup } +} + +describe('wall cutter union', () => { + test('subtracts three disjoint boxes once with equivalent solid and reveal materials', () => { + const { wall, windowAt, cleanup } = fixture() + const openings = [windowAt(1), windowAt(3), windowAt(6)] + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + expect(spy.mock.calls.map((call) => call[2])).toEqual([SUBTRACTION]) + spy.mockRestore() + const reference = generateChainedReference(wall, openings) + expectEquivalent(actual, reference) + expect(measurements(actual).volume).toBeCloseTo(5.25, 6) + expect(triangleCount(actual)).toBeLessThanOrEqual(triangleCount(reference)) + console.info( + `Disjoint cutouts: merged ${triangleCount(actual)} triangles; chained ${triangleCount(reference)} triangles`, + ) + actual.dispose() + reference.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + + for (const [name, centers, widths, unions] of [ + ['overlapping', [2, 2.5], [1, 1], 1], + ['sharing a face', [2, 3], [1, 1], 1], + ['nested', [2, 2], [2, 1], 1], + ['identical', [2, 2], [1, 1], 1], + ['transitively overlapping with a disjoint shell', [2, 3.5, 2.75, 6], [1, 1, 1, 1], 2], + ] as const) { + test(`unions ${name} cutouts before subtraction`, () => { + const { wall, windowAt, cleanup } = fixture() + const openings = centers.map((x, index) => windowAt(x, widths[index])) + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + expect(spy.mock.calls.filter((call) => call[2] === ADDITION)).toHaveLength(unions) + expect(spy.mock.calls.filter((call) => call[2] === SUBTRACTION)).toHaveLength(1) + spy.mockRestore() + const reference = generateChainedReference(wall, openings) + expectEquivalent(actual, reference) + actual.dispose() + reference.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + } + + test('keeps band materials and base-material reveals across overlapping floor-level doors', () => { + const { wall: originalWall, cleanup } = fixture() + const wall = WallNode.parse({ + ...originalWall, + frontSide: 'exterior', + backSide: 'interior', + faceBands: { enabled: true, count: 3, lowerHeight: 0.75, middleHeight: 1 }, + }) + const doors = [2, 2.75, 6].map((x) => + DoorNode.parse({ wallId: wall.id, position: [x, 1, 0], width: 1, height: 2 }), + ) + try { + const actual = generateExtrudedWall(wall, doors, calculateLevelMiters([wall])) + const reference = generateChainedReference(wall, doors) + expectEquivalent(actual, reference) + const mesh = new THREE.Mesh( + actual, + Array.from({ length: 11 }, () => new THREE.MeshBasicMaterial()), + ) + const hit = new THREE.Raycaster( + new THREE.Vector3(2, 1, 0), + new THREE.Vector3(-1, 0, 0), + ).intersectObject(mesh)[0] + expect(hit?.face?.materialIndex).toBe(0) + for (const material of mesh.material) material.dispose() + actual.dispose() + reference.dispose() + } finally { + cleanup() + } + }) + + test('keeps the zero-cutout path free of CSG', () => { + const { wall, cleanup } = fixture() + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall])) + expect(spy).not.toHaveBeenCalled() + expect(measurements(geometry).volume).toBeCloseTo(6, 6) + expect([...measurements(geometry).materialAreas.keys()].sort()).toEqual([0, 1, 2]) + geometry.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + + test('disposes the source wall and merged cutter once while retaining the result', () => { + const { wall, windowAt, cleanup } = fixture() + const evaluate = Evaluator.prototype.evaluate + const disposed = new Map() + const spy = spyOn(Evaluator.prototype, 'evaluate').mockImplementation(function ( + this: Evaluator, + a: Brush, + b: Brush, + operation: typeof SUBTRACTION, + ) { + if (operation === SUBTRACTION) { + for (const brush of [a, b]) { + brush.geometry.addEventListener('dispose', () => { + disposed.set(brush.geometry, (disposed.get(brush.geometry) ?? 0) + 1) + }) + } + } + return evaluate.call(this, a, b, operation) + }) + try { + const geometry = generateExtrudedWall( + wall, + [windowAt(2), windowAt(2.5), windowAt(6)], + calculateLevelMiters([wall]), + ) + expect([...disposed.values()]).toEqual([1, 1]) + expect(disposed.has(geometry)).toBe(false) + expect(measurements(geometry).volume).toBeCloseTo(5.375, 6) + geometry.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + + for (const openingShape of ['arch', 'rounded'] as const) { + test(`preserves overlapping ${openingShape} openings and their reveal materials`, () => { + const { wall, windowAt, cleanup } = fixture() + const openings = [2, 2.5, 6].map((x) => WindowNode.parse({ ...windowAt(x), openingShape })) + try { + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + const reference = generateChainedReference(wall, openings) + // Beveled Float32 faces accumulate area rounding across thousands of + // splits; permit one ppm of surface area, retaining the volume bound. + expectEquivalent(actual, reference, openingShape === 'rounded' ? 1e-6 : 0) + const materials = Array.from({ length: 3 }, () => new THREE.MeshBasicMaterial()) + try { + const actualMesh = new THREE.Mesh(actual, materials) + const referenceMesh = new THREE.Mesh(reference, materials) + for (const x of [2, 2.5, 6]) { + for (const z of [-0.12, -0.06, 0, 0.06, 0.12]) { + for (const direction of [ + new THREE.Vector3(-1, 0, 0), + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 1, 0), + ]) { + const ray = new THREE.Raycaster(new THREE.Vector3(x, 1.5, z), direction) + const actualHit = ray.intersectObject(actualMesh)[0] + const referenceHit = ray.intersectObject(referenceMesh)[0] + expect(actualHit).toBeDefined() + expect(referenceHit).toBeDefined() + expect(Math.abs(actualHit!.distance - referenceHit!.distance)).toBeLessThan(1e-6) + expect(actualHit!.face!.materialIndex).toBe(referenceHit!.face!.materialIndex) + expect(actualHit!.face!.materialIndex).toBe(0) + } + } + } + } finally { + for (const material of materials) material.dispose() + } + actual.dispose() + reference.dispose() + } finally { + cleanup() + } + }) + } + + test('unions overlapping support and door cuts alongside an item proxy', () => { + const { wall, cleanup } = fixture() + const door = DoorNode.parse({ + wallId: wall.id, + position: [2, 1, 0], + width: 1, + height: 2, + }) + const item = { id: 'item_union-test', type: 'item' } as AnyNode + const itemMesh = new THREE.Group() + const proxy = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0.5)) + proxy.name = 'cutout' + proxy.position.set(6, 1.5, 0) + itemMesh.add(proxy) + sceneRegistry.nodes.set(item.id, itemMesh) + const generate = () => + generateExtrudedWall(wall, [door, item], calculateLevelMiters([wall]), 0.5, 0, [ + { start: 0, end: 0.5, elevation: 0.5 }, + { start: 0.5, end: 1, elevation: 0 }, + ]) + try { + const actual = generate() + const support = new Brush(new THREE.BoxGeometry(4.5, 0.51, 1)) + support.geometry.translate(1.75, -0.255, 0) + const itemCutter = new Brush(proxy.geometry.clone().translate(6, 1.5, 0)) + const cutters = [support, openingBrush(door, wall.thickness), itemCutter] + for (const cutter of cutters) prepareBrushForCSG(cutter) + const reference = withChainedSubtraction(cutters, generate) + expectEquivalent(actual, reference) + expect(measurements(actual).volume).toBeCloseTo(5.75, 6) + actual.dispose() + reference.dispose() + } finally { + sceneRegistry.nodes.delete(item.id) + proxy.geometry.dispose() + cleanup() + } + }) + + test('bakes transforms and normalizes mixed indexed and non-indexed attributes', () => { + const a = new Brush(new THREE.BoxGeometry(1, 1, 1)) + const b = new Brush(new THREE.BoxGeometry(1, 1, 1).toNonIndexed()) + const c = new Brush(new THREE.BoxGeometry(1, 1, 1)) + a.position.set(2, 1, 0) + b.position.set(2.5, 1, 0) + c.position.set(6, 1, 0) + c.rotation.z = Math.PI / 4 + b.geometry.deleteAttribute('uv') + a.geometry.setAttribute( + 'color', + new THREE.Float32BufferAttribute(a.geometry.getAttribute('position').count * 3, 3), + ) + const cutter = mergeWallCutoutBrushes([a, b, c]) + try { + expect(Object.keys(cutter.geometry.attributes).sort()).toEqual([ + 'normal', + 'position', + 'uv', + 'uv2', + ]) + expect(cutter.matrixWorld.equals(new THREE.Matrix4())).toBe(true) + const evaluator = new Evaluator() + evaluator.attributes = ['position', 'normal', 'uv', 'uv2'] + const wall = new Brush(new THREE.BoxGeometry(10, 4, 0.5)) + wall.geometry.translate(4, 1, 0) + prepareBrushForCSG(wall) + const actual = evaluator.evaluate(wall, cutter, SUBTRACTION) + const reference = chainedSubtract(wall, [a, b, c], evaluator) + // Compare solids here; semantic wall materials are covered above. + actual.geometry.clearGroups() + actual.geometry.addGroup(0, actual.geometry.index!.count, 0) + reference.geometry.clearGroups() + reference.geometry.addGroup(0, reference.geometry.index!.count, 0) + expectEquivalent(actual.geometry, reference.geometry) + wall.geometry.dispose() + actual.geometry.dispose() + reference.geometry.dispose() + } finally { + for (const brush of [a, b, c, cutter]) brush.geometry.dispose() + } + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts new file mode 100644 index 0000000000..98947b8c88 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-progressive-budget.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { shouldDeferWallRebuild } from './wall-system' + +describe('progressive wall budget', () => { + const openings = Array.from({ length: 6 }, (_, index) => + (index % 2 ? DoorNode : WindowNode).parse({ position: [index, 1, 0] }), + ) + const cheap = WallNode.parse({ start: [0, 0], end: [8, 0], children: [] }) + const heavy = WallNode.parse({ + start: [0, 0], + end: [8, 0], + children: openings.map((opening) => opening.id), + }) + const nodes: Record = Object.fromEntries( + [cheap, heavy, ...openings].map((node) => [node.id, node]), + ) + + function frame(walls: WallNode[]): string[] { + const rebuilt: string[] = [] + for (const wall of walls) { + if (shouldDeferWallRebuild(wall.id, nodes, rebuilt.length, 0)) break + rebuilt.push(wall.id) + } + return rebuilt + } + + test('defers a heavy wall after a cheap wall and rebuilds it at the start of the next frame', () => { + expect(frame([cheap, heavy])).toEqual([cheap.id]) + expect(frame([heavy])).toEqual([heavy.id]) + }) + + test('counts hosted cutouts rather than all children', () => { + const five = { ...heavy, children: [...heavy.children.slice(0, 5), cheap.id] } + expect(shouldDeferWallRebuild(five.id, { ...nodes, [five.id]: five }, 1, 0)).toBe(false) + expect(shouldDeferWallRebuild(heavy.id, nodes, 1, 0)).toBe(true) + }) + + test('retains the eight-wall and eight-millisecond limits while allowing initial progress', () => { + expect(shouldDeferWallRebuild(cheap.id, nodes, 7, 7.9)).toBe(false) + expect(shouldDeferWallRebuild(cheap.id, nodes, 8, 0)).toBe(true) + expect(shouldDeferWallRebuild(cheap.id, nodes, 1, 8)).toBe(true) + expect(shouldDeferWallRebuild(heavy.id, nodes, 0, 100)).toBe(false) + }) + + test('counts item cutout proxies but skips ordinary items', () => { + const item = { id: 'item_budget-test', type: 'item' } as AnyNode + const wall = { ...heavy, children: [...heavy.children.slice(0, 5), item.id] } + const sceneNodes = { ...nodes, [wall.id]: wall, [item.id]: item } + const mesh = new THREE.Group() + const proxy = new THREE.Mesh(new THREE.BoxGeometry()) + proxy.name = 'cutout' + sceneRegistry.nodes.set(item.id, mesh) + try { + expect(shouldDeferWallRebuild(wall.id, sceneNodes, 1, 0)).toBe(false) + mesh.add(proxy) + expect(shouldDeferWallRebuild(wall.id, sceneNodes, 1, 0)).toBe(true) + } finally { + sceneRegistry.nodes.delete(item.id) + proxy.geometry.dispose() + } + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 355a191ea3..c40e963c5f 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -36,7 +36,7 @@ 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' +import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' @@ -84,6 +84,67 @@ function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry } +export function mergeWallCutoutBrushes(brushes: readonly Brush[]): Brush { + const bounds = brushes.map((brush) => { + prepareBrushForCSG(brush) + const geometry = csgGeometry(brush) + geometry.computeBoundingBox() + return geometry.boundingBox!.clone().applyMatrix4(brush.matrixWorld).expandByScalar(1e-6) + }) + const parents = brushes.map((_, index) => index) + const root = (index: number): number => { + while (parents[index] !== index) { + parents[index] = parents[parents[index]!]! + index = parents[index]! + } + return index + } + for (let a = 0; a < brushes.length; a++) { + for (let b = a + 1; b < brushes.length; b++) { + if (bounds[a]!.intersectsBox(bounds[b]!)) parents[root(b)] = root(a) + } + } + const groups = new Map() + brushes.forEach((brush, index) => { + const key = root(index) + const group = groups.get(key) ?? [] + group.push(brush) + groups.set(key, group) + }) + + const geometries: THREE.BufferGeometry[] = [] + const intermediateGeometries = new Set() + try { + for (const group of groups.values()) { + let result = group[0]! + for (let index = 1; index < group.length; index++) { + const next = csgEvaluator.evaluate(result, group[index]!, ADDITION) + intermediateGeometries.add(csgGeometry(next)) + if (intermediateGeometries.delete(csgGeometry(result))) csgGeometry(result).dispose() + result = next + } + const source = csgGeometry(result) + const geometry = source.index ? source.toNonIndexed() : source.clone() + geometries.push(geometry) + geometry.applyMatrix4(result.matrixWorld) + for (const attribute of Object.keys(geometry.attributes)) { + if (!csgEvaluator.attributes.includes(attribute)) geometry.deleteAttribute(attribute) + } + } + + // CSG material indices are temporary: assignWallMaterialGroups classifies + // the final faces, including reveals, into the wall's semantic slots. + const merged = mergeGeometries(geometries, false) + if (!merged) throw new Error('Unable to merge wall cutout geometries') + const cutter = new Brush(merged) + prepareBrushForCSG(cutter) + return cutter + } finally { + for (const geometry of geometries) geometry.dispose() + for (const geometry of intermediateGeometries) geometry.dispose() + } +} + type WallBoundaryEdgeTag = 'front' | 'back' | 'base' type TaggedWallBoundaryEdge = { @@ -482,9 +543,39 @@ const DRAG_FLUSH_MS = 80 const MAX_WALL_REBUILDS_PER_FRAME = 8 const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 +const HEAVY_WALL_OPENINGS = 6 let lastWallDirtyAtMs = 0 const pendingAdjacentByLevel = new Map>() +export function shouldDeferWallRebuild( + wallId: string, + nodes: Record, + rebuiltThisFrame: number, + elapsedMs: number, +): boolean { + if (rebuiltThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) return true + if (rebuiltThisFrame === 0) return false + if (elapsedMs >= WALL_PROGRESSIVE_TIME_BUDGET_MS) return true + const wall = nodes[wallId as AnyNodeId] + if (wall?.type !== 'wall') return false + let cutouts = 0 + for (const childId of getEffectiveWall(wall).children ?? []) { + const child = nodes[childId] + if ( + child?.type === 'door' || + child?.type === 'window' || + (child?.type === 'item' && + ( + sceneRegistry.nodes.get(childId)?.getObjectByName('cutout') as THREE.Mesh | undefined + )?.geometry?.getAttribute('position')?.count) + ) { + cutouts++ + if (cutouts >= HEAVY_WALL_OPENINGS) return true + } + } + return false +} + // Walls whose geometry this system replaced since the last drain. // // The store's dirty mark is cleared the moment a wall is rebuilt, so anything @@ -582,6 +673,7 @@ export const WallSystem = () => { const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD let rebuiltWallsThisFrame = 0 const rebuildFrameStartedAt = now + let deferWallRebuilds = false // Process each level that has dirty walls for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) { @@ -597,16 +689,17 @@ export const WallSystem = () => { // follow the cursor with full fidelity (cutouts and all). Large imports // enter the progressive path so initial load can't lock the tab. for (const wallId of dirtyWallIds) { - if (useProgressiveWallRebuilds) { - if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { - break - } - if ( - rebuiltWallsThisFrame > 0 && - performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS - ) { - break - } + if ( + useProgressiveWallRebuilds && + shouldDeferWallRebuild( + wallId, + nodes, + rebuiltWallsThisFrame, + performance.now() - rebuildFrameStartedAt, + ) + ) { + deferWallRebuilds = true + break } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh @@ -623,6 +716,7 @@ export const WallSystem = () => { } if (rebuiltWallIds.size === 0) { + if (deferWallRebuilds) break continue } @@ -639,6 +733,7 @@ export const WallSystem = () => { pending.add(wallId) } } + if (deferWallRebuilds) break } // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the @@ -650,22 +745,24 @@ export const WallSystem = () => { const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD let rebuiltAdjacentThisFrame = 0 const adjacentFrameStartedAt = performance.now() + let deferAdjacentRebuilds = false for (const [levelId, pendingIds] of pendingAdjacentByLevel) { if (pendingIds.size === 0) continue const levelWalls = getLevelWalls(levelId) const miterData = timeSpan('wall-miter', () => getCachedLevelMiters(levelId, levelWalls)) for (const wallId of Array.from(pendingIds)) { - if (useProgressiveAdjacentRebuilds) { - if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) { - break - } - if ( - rebuiltAdjacentThisFrame > 0 && - performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS - ) { - break - } + if ( + useProgressiveAdjacentRebuilds && + shouldDeferWallRebuild( + wallId, + nodes, + rebuiltAdjacentThisFrame, + performance.now() - adjacentFrameStartedAt, + ) + ) { + deferAdjacentRebuilds = true + break } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh @@ -684,8 +781,9 @@ export const WallSystem = () => { } if ( - useProgressiveAdjacentRebuilds && - rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME + deferAdjacentRebuilds || + (useProgressiveAdjacentRebuilds && + rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) ) { break } @@ -1177,24 +1275,17 @@ export function generateExtrudedWall( const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() - // Subtract each cutout from the wall - let resultBrush = wallBrush - for (const cutoutBrush of cutoutBrushes) { - prepareBrushForCSG(cutoutBrush) - const newResult = timeSpan('wall-csg', () => - csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION), + let mergedCutter: Brush | undefined + let resultBrush: Brush + try { + mergedCutter = timeSpan('wall-csg-union', () => mergeWallCutoutBrushes(cutoutBrushes)) + resultBrush = timeSpan('wall-csg', () => + csgEvaluator.evaluate(wallBrush, mergedCutter!, SUBTRACTION), ) - prepareBrushForCSG(newResult) - if (resultBrush !== wallBrush) { - csgGeometry(resultBrush).dispose() - } - resultBrush = newResult - } - - // Clean up - csgGeometry(wallBrush).dispose() - for (const brush of cutoutBrushes) { - csgGeometry(brush).dispose() + } finally { + csgGeometry(wallBrush).dispose() + if (mergedCutter) csgGeometry(mergedCutter).dispose() + for (const brush of cutoutBrushes) csgGeometry(brush).dispose() } const resultGeometry = csgGeometry(resultBrush) From 2b98b1649e9fa688da2dc88cba9871758a4a4e95 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 15:42:48 -0400 Subject: [PATCH 3/7] Wall batch: keep its holds out of the node batch's capture sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project thumbnail autosave fires 30–40 s after load and emits thumbnail:before-capture. The node batch answers by revealing every mesh held under the shared 'batched' reason — including the wall sources the merged wall batch was already drawing — and nothing re-hid them: every batched wall drew twice (and cast shadows twice) for the rest of the session. Measured on rich-4×: 4 494 → 5 662 draws, 11.8 → 17.1 ms, forever. Wall holds now carry their own reason, and the wall batch handles capture itself: sources come back for the capture (exports still prune off-layer meshes) and go under again on thumbnail:after-capture. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- .../nodes/src/wall/wall-batch-system.test.ts | 19 +++++++++++++ packages/nodes/src/wall/wall-batch-system.tsx | 27 ++++++++++++++++++- packages/nodes/src/wall/wall-batch.ts | 4 +-- packages/viewer/src/lib/scene-visibility.ts | 4 +-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 72f8075161..7fffa4711c 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -15,8 +15,11 @@ import { import { collectTintedWalls, collectWallBatchCandidates, + holdBatchedWallsAfterCapture, + revealBatchedWallsForCapture, WallBatchSystem, } from './wall-batch-system' +import { revealAllBatchedHolds } from '../shared/node-batch/candidates' type FrameCallback = Parameters[0] let frameCallback: FrameCallback | null = null @@ -263,3 +266,19 @@ describe('WallBatchSystem cutaway releases', () => { expect(walls[0]!.layers.isEnabled(SCENE_LAYER)).toBe(true) }) }) + +describe('WallBatchSystem capture holds', () => { + test("the node batch's reveal sweep leaves batched walls held", () => { + const { walls } = setupBatchedLevel() + revealAllBatchedHolds() + expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + }) + + test('sources come back for a capture and go under again after it', () => { + const { walls } = setupBatchedLevel() + revealBatchedWallsForCapture() + expect(walls.every((wall) => wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + holdBatchedWallsAfterCapture() + expect(walls.every((wall) => !wall.layers.isEnabled(SCENE_LAYER))).toBe(true) + }) +}) diff --git a/packages/nodes/src/wall/wall-batch-system.tsx b/packages/nodes/src/wall/wall-batch-system.tsx index f838a942a1..4944ed5b44 100644 --- a/packages/nodes/src/wall/wall-batch-system.tsx +++ b/packages/nodes/src/wall/wall-batch-system.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNodeId, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { drainRebuiltWalls, getPendingWallRebuildCount, @@ -109,6 +109,17 @@ function showOwnGeometry(nodeId: string) { if (mesh) revealBatchedWall(mesh) } +export function revealBatchedWallsForCapture(): void { + for (const nodeId of batchByNode.keys()) showOwnGeometry(nodeId) +} + +export function holdBatchedWallsAfterCapture(): void { + for (const nodeId of batchByNode.keys()) { + const mesh = sceneRegistry.nodes.get(nodeId) + if (mesh) hideBatchedWall(mesh) + } +} + /** Hands a wall back to itself: the merged mesh stops drawing it, it resumes. */ function releaseWall(nodeId: string) { const record = batchByNode.get(nodeId) @@ -329,6 +340,20 @@ export const WallBatchSystem = () => { useFrame(() => runBatchFrame(invalidate, wakeRef), 5) + // Captures and exports clone the scene and prune whatever is off the scene + // layer, so the sources come back for the capture and go under again after + // it. The node batch used to reveal them as a side effect of its own sweep + // (shared hold reason) and nothing ever re-hid them: every batched wall + // drew twice for the rest of the session. + useEffect(() => { + emitter.on('thumbnail:before-capture', revealBatchedWallsForCapture) + emitter.on('thumbnail:after-capture', holdBatchedWallsAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', revealBatchedWallsForCapture) + emitter.off('thumbnail:after-capture', holdBatchedWallsAfterCapture) + } + }, []) + // Scripted-probe hook, ?perf sessions only (mirrors __itemBatch): the // panel has no row for the merged wall batch, so probes read it here. useEffect(() => { diff --git a/packages/nodes/src/wall/wall-batch.ts b/packages/nodes/src/wall/wall-batch.ts index 362ef169e1..d3480bbe28 100644 --- a/packages/nodes/src/wall/wall-batch.ts +++ b/packages/nodes/src/wall/wall-batch.ts @@ -221,10 +221,10 @@ export function applyWallBatchGroups(batch: WallBatch, hidden: ReadonlySet Date: Fri, 4 Sep 2026 16:12:12 -0400 Subject: [PATCH 4/7] Wall openings: drop coincident box cutouts, cap the union at four A real IFC import carries the same door eight times on one wall; coincident boxes are the worst input a boolean can get, and a union chain over them took 148 ms and then froze the page. Box cutouts fully inside another box are dropped before grouping, overlapping groups of up to four are unioned, and larger groups fall back to the bounded chained subtraction. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- .../src/systems/wall/wall-csg-union.test.ts | 198 +++++++++++++++++- .../viewer/src/systems/wall/wall-system.tsx | 106 ++++++++-- 2 files changed, 285 insertions(+), 19 deletions(-) diff --git a/packages/viewer/src/systems/wall/wall-csg-union.test.ts b/packages/viewer/src/systems/wall/wall-csg-union.test.ts index 0106b41c62..659a59f708 100644 --- a/packages/viewer/src/systems/wall/wall-csg-union.test.ts +++ b/packages/viewer/src/systems/wall/wall-csg-union.test.ts @@ -51,7 +51,7 @@ function chainedSubtract(wall: Brush, cutters: Brush[], evaluator: Evaluator): B function generateChainedReference(wall: WallNode, openings: Opening[]): THREE.BufferGeometry { const cutters = openings.map((opening) => openingBrush(opening, wall.thickness)) return withChainedSubtraction(cutters, () => - generateExtrudedWall(wall, openings, calculateLevelMiters([wall])), + generateExtrudedWall(wall, openings.slice(0, 1), calculateLevelMiters([wall])), ) } @@ -169,11 +169,12 @@ describe('wall cutter union', () => { for (const [name, centers, widths, unions] of [ ['overlapping', [2, 2.5], [1, 1], 1], ['sharing a face', [2, 3], [1, 1], 1], - ['nested', [2, 2], [2, 1], 1], - ['identical', [2, 2], [1, 1], 1], + ['nested', [2, 2], [2, 1], 0], + ['identical', [2, 2], [1, 1], 0], + ['four overlapping', [2, 2.5, 3, 3.5], [1, 1, 1, 1], 3], ['transitively overlapping with a disjoint shell', [2, 3.5, 2.75, 6], [1, 1, 1, 1], 2], ] as const) { - test(`unions ${name} cutouts before subtraction`, () => { + test(`combines ${name} cutouts before subtraction`, () => { const { wall, windowAt, cleanup } = fixture() const openings = centers.map((x, index) => windowAt(x, widths[index])) const spy = spyOn(Evaluator.prototype, 'evaluate') @@ -193,6 +194,192 @@ describe('wall cutter union', () => { }) } + test('collapses 20 coincident boxes to the first cutter and preserves the single-cutout wall', () => { + const { wall, windowAt, cleanup } = fixture() + const openings = Array.from({ length: 20 }, () => windowAt(2)) + const brushes = openings.map((opening) => openingBrush(opening, wall.thickness)) + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const merged = mergeWallCutoutBrushes(brushes) + expect(merged.droppedCount).toBe(19) + expect(merged.fallbackBrushes).toHaveLength(0) + expect(merged.cutter!.geometry.getAttribute('position').array).toEqual( + brushes[0]!.geometry.getAttribute('position').array, + ) + merged.cutter!.geometry.dispose() + expect(spy).not.toHaveBeenCalled() + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + expect(spy.mock.calls.map((call) => call[2])).toEqual([SUBTRACTION]) + spy.mockRestore() + const reference = generateChainedReference(wall, openings.slice(0, 1)) + expectEquivalent(actual, reference) + expect(actual.groups).toEqual(reference.groups) + actual.dispose() + reference.dispose() + } finally { + spy.mockRestore() + for (const brush of brushes) brush.geometry.dispose() + cleanup() + } + }) + + test('dedupes the 8/8/4 door clusters before deciding whether to union', () => { + const { wall: originalWall, cleanup } = fixture() + const wall = WallNode.parse({ ...originalWall, end: [1.3, 0] }) + const unique = [0.45, 0.67, 0.85].map((x) => + DoorNode.parse({ wallId: wall.id, position: [x, 1.05, 0], width: 0.9, height: 2.1 }), + ) + const openings = unique.flatMap((door, index) => + Array.from({ length: index === 2 ? 4 : 8 }, () => DoorNode.parse({ ...door, id: undefined })), + ) + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + expect(spy.mock.calls.map((call) => call[2])).toEqual([ADDITION, ADDITION, SUBTRACTION]) + spy.mockRestore() + const reference = generateChainedReference(wall, unique) + expectEquivalent(actual, reference) + actual.dispose() + reference.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + + for (const reverse of [false, true]) { + test(`drops a strictly contained box with the container ${reverse ? 'last' : 'first'}`, () => { + const outer = new Brush(new THREE.BoxGeometry(2, 2, 2)) + const inner = new Brush(new THREE.BoxGeometry(0.5, 0.5, 0.5).toNonIndexed()) + outer.position.set(3, 2, 1) + inner.position.set(3.25, 2.25, 1.25) + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + const merged = mergeWallCutoutBrushes(reverse ? [inner, outer] : [outer, inner]) + expect(merged.droppedCount).toBe(1) + expect(merged.fallbackBrushes).toHaveLength(0) + expect(spy).not.toHaveBeenCalled() + merged.cutter!.geometry.computeBoundingBox() + expect(merged.cutter!.geometry.boundingBox).toEqual( + new THREE.Box3(new THREE.Vector3(2, 1, 0), new THREE.Vector3(4, 3, 2)), + ) + merged.cutter!.geometry.dispose() + } finally { + spy.mockRestore() + outer.geometry.dispose() + inner.geometry.dispose() + } + }) + + test(`keeps a coincident arch with the box ${reverse ? 'last' : 'first'}`, () => { + const { wall, windowAt, cleanup } = fixture() + const box = openingBrush(windowAt(2), wall.thickness) + const arch = openingBrush( + WindowNode.parse({ ...windowAt(2), openingShape: 'arch' }), + wall.thickness, + ) + const spy = spyOn(Evaluator.prototype, 'evaluate') + try { + expect(arch.geometry.boundingBox).toEqual(box.geometry.boundingBox) + const merged = mergeWallCutoutBrushes(reverse ? [arch, box] : [box, arch]) + expect(merged.droppedCount).toBe(0) + expect(spy.mock.calls.map((call) => call[2])).toEqual([ADDITION]) + merged.cutter!.geometry.dispose() + } finally { + spy.mockRestore() + box.geometry.dispose() + arch.geometry.dispose() + cleanup() + } + }) + } + + for (const [offset, droppedCount] of [ + [0.000005, 1], + [0.00002, 0], + ] as const) { + test(`uses a 1e-5 containment tolerance for boxes offset by ${offset}`, () => { + const a = new Brush(new THREE.BoxGeometry(1, 1, 1)) + const b = new Brush(new THREE.BoxGeometry(1, 1, 1)) + b.position.x = offset + try { + const merged = mergeWallCutoutBrushes([a, b]) + expect(merged.droppedCount).toBe(droppedCount) + merged.cutter!.geometry.dispose() + } finally { + a.geometry.dispose() + b.geometry.dispose() + } + }) + } + + test('does not use a rotated box AABB as a solid container', () => { + const outer = new Brush(new THREE.BoxGeometry(2, 2, 1)) + outer.rotation.z = Math.PI / 4 + const inner = new Brush(new THREE.BoxGeometry(0.2, 0.2, 0.2)) + inner.position.set(1, 1, 0) + try { + const merged = mergeWallCutoutBrushes([outer, inner]) + expect(merged.droppedCount).toBe(0) + merged.cutter!.geometry.dispose() + } finally { + outer.geometry.dispose() + inner.geometry.dispose() + } + }) + + for (const includeSmallGroups of [false, true]) { + test(`subtracts six overlapping boxes sequentially ${includeSmallGroups ? 'after small groups' : 'without a merged cutter'}`, () => { + const { wall, windowAt, cleanup } = fixture() + const openings = [1, 1.5, 2, 2.5, 3, 3.5].map((x) => windowAt(x)) + if (includeSmallGroups) openings.push(windowAt(5, 0.5), windowAt(6.5), windowAt(7)) + const brushes = openings.map((opening) => openingBrush(opening, wall.thickness)) + const merged = mergeWallCutoutBrushes(brushes) + expect(merged.droppedCount).toBe(0) + expect(merged.fallbackBrushes).toEqual(brushes.slice(0, 6)) + expect(merged.cutter !== null).toBe(includeSmallGroups) + merged.cutter?.geometry.dispose() + for (const brush of brushes) brush.geometry.dispose() + + const disposed = new Map() + const evaluate = Evaluator.prototype.evaluate + const spy = spyOn(Evaluator.prototype, 'evaluate').mockImplementation(function ( + this: Evaluator, + a: Brush, + b: Brush, + operation: typeof SUBTRACTION, + ) { + const result = evaluate.call(this, a, b, operation) + for (const brush of [a, b, result]) { + if (disposed.has(brush.geometry)) continue + disposed.set(brush.geometry, 0) + brush.geometry.addEventListener('dispose', () => { + disposed.set(brush.geometry, disposed.get(brush.geometry)! + 1) + }) + } + return result + }) + try { + const actual = generateExtrudedWall(wall, openings, calculateLevelMiters([wall])) + expect(spy.mock.calls.map((call) => call[2])).toEqual([ + ...(includeSmallGroups ? [ADDITION, SUBTRACTION] : []), + ...Array.from({ length: 6 }, () => SUBTRACTION), + ]) + expect(disposed.get(actual)).toBe(0) + disposed.delete(actual) + expect([...disposed.values()].every((count) => count === 1)).toBe(true) + spy.mockRestore() + const reference = generateChainedReference(wall, openings) + expectEquivalent(actual, reference) + actual.dispose() + reference.dispose() + } finally { + spy.mockRestore() + cleanup() + } + }) + } + test('keeps band materials and base-material reveals across overlapping floor-level doors', () => { const { wall: originalWall, cleanup } = fixture() const wall = WallNode.parse({ @@ -371,7 +558,8 @@ describe('wall cutter union', () => { 'color', new THREE.Float32BufferAttribute(a.geometry.getAttribute('position').count * 3, 3), ) - const cutter = mergeWallCutoutBrushes([a, b, c]) + const { cutter: mergedCutter } = mergeWallCutoutBrushes([a, b, c]) + const cutter = mergedCutter! try { expect(Object.keys(cutter.geometry.attributes).sort()).toEqual([ 'normal', diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index c40e963c5f..3bb8973906 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -84,14 +84,65 @@ function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry } -export function mergeWallCutoutBrushes(brushes: readonly Brush[]): Brush { - const bounds = brushes.map((brush) => { +function isBoxCutout(brush: Brush, bounds: THREE.Box3): boolean { + const geometry = csgGeometry(brush) + const positions = geometry.getAttribute('position') + if ((geometry.index?.count ?? positions.count) !== 36) return false + + const vertex = new THREE.Vector3() + const corners = new Set() + for (let index = 0; index < positions.count; index++) { + vertex.fromBufferAttribute(positions, index).applyMatrix4(brush.matrixWorld) + let corner = 0 + for (const [bit, axis] of ['x', 'y', 'z'].entries()) { + const coordinate = axis as 'x' | 'y' | 'z' + if (Math.abs(vertex[coordinate] - bounds.min[coordinate]) <= 1e-6) continue + if (Math.abs(vertex[coordinate] - bounds.max[coordinate]) > 1e-6) return false + corner |= 1 << bit + } + corners.add(corner) + } + // A rotated box's AABB can contain another cutter without the solid doing so. + return corners.size === 8 +} + +export function mergeWallCutoutBrushes(brushes: readonly Brush[]): { + cutter: Brush | null + fallbackBrushes: Brush[] + droppedCount: number +} { + const cutouts = brushes.map((brush) => { prepareBrushForCSG(brush) const geometry = csgGeometry(brush) geometry.computeBoundingBox() - return geometry.boundingBox!.clone().applyMatrix4(brush.matrixWorld).expandByScalar(1e-6) + const bounds = geometry.boundingBox!.clone().applyMatrix4(brush.matrixWorld) + return { + brush, + bounds, + containerBounds: bounds.clone().expandByScalar(1e-5), + isBox: isBoxCutout(brush, bounds), + } }) - const parents = brushes.map((_, index) => index) + const retained: typeof cutouts = [] + for (const cutout of cutouts) { + if (cutout.isBox) { + if ( + retained.some((other) => other.isBox && other.containerBounds.containsBox(cutout.bounds)) + ) { + continue + } + for (let index = retained.length - 1; index >= 0; index--) { + const other = retained[index]! + if (other.isBox && cutout.containerBounds.containsBox(other.bounds)) { + retained.splice(index, 1) + } + } + } + retained.push(cutout) + } + const droppedCount = cutouts.length - retained.length + const bounds = retained.map((cutout) => cutout.bounds.clone().expandByScalar(1e-6)) + const parents = retained.map((_, index) => index) const root = (index: number): number => { while (parents[index] !== index) { parents[index] = parents[parents[index]!]! @@ -99,13 +150,13 @@ export function mergeWallCutoutBrushes(brushes: readonly Brush[]): Brush { } return index } - for (let a = 0; a < brushes.length; a++) { - for (let b = a + 1; b < brushes.length; b++) { + for (let a = 0; a < retained.length; a++) { + for (let b = a + 1; b < retained.length; b++) { if (bounds[a]!.intersectsBox(bounds[b]!)) parents[root(b)] = root(a) } } const groups = new Map() - brushes.forEach((brush, index) => { + retained.forEach(({ brush }, index) => { const key = root(index) const group = groups.get(key) ?? [] group.push(brush) @@ -114,8 +165,14 @@ export function mergeWallCutoutBrushes(brushes: readonly Brush[]): Brush { const geometries: THREE.BufferGeometry[] = [] const intermediateGeometries = new Set() + const fallbackBrushes: Brush[] = [] try { for (const group of groups.values()) { + // Long unions of coplanar openings can grow explosively; subtract these directly. + if (group.length > 4) { + fallbackBrushes.push(...group) + continue + } let result = group[0]! for (let index = 1; index < group.length; index++) { const next = csgEvaluator.evaluate(result, group[index]!, ADDITION) @@ -132,13 +189,15 @@ export function mergeWallCutoutBrushes(brushes: readonly Brush[]): Brush { } } + if (geometries.length === 0) return { cutter: null, fallbackBrushes, droppedCount } + // CSG material indices are temporary: assignWallMaterialGroups classifies // the final faces, including reveals, into the wall's semantic slots. const merged = mergeGeometries(geometries, false) if (!merged) throw new Error('Unable to merge wall cutout geometries') const cutter = new Brush(merged) prepareBrushForCSG(cutter) - return cutter + return { cutter, fallbackBrushes, droppedCount } } finally { for (const geometry of geometries) geometry.dispose() for (const geometry of intermediateGeometries) geometry.dispose() @@ -1245,7 +1304,6 @@ export function generateExtrudedWall( baseProfileCutouts.push(new Brush(cutoutGeometry)) } - // Apply base-profile and opening cutouts in one CSG pass. const cutoutBrushes = [ ...baseProfileCutouts, ...collectCutoutBrushes(wallNode, childrenNodes, thickness), @@ -1275,13 +1333,33 @@ export function generateExtrudedWall( const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() - let mergedCutter: Brush | undefined - let resultBrush: Brush + let mergedCutter: Brush | null = null + let resultBrush = wallBrush try { - mergedCutter = timeSpan('wall-csg-union', () => mergeWallCutoutBrushes(cutoutBrushes)) - resultBrush = timeSpan('wall-csg', () => - csgEvaluator.evaluate(wallBrush, mergedCutter!, SUBTRACTION), + const properties: Array<[string, string]> = [] + const merged = timeSpan( + 'wall-csg-union', + () => { + const cutouts = mergeWallCutoutBrushes(cutoutBrushes) + properties.push(['droppedCutouts', String(cutouts.droppedCount)]) + return cutouts + }, + { properties }, ) + mergedCutter = merged.cutter + timeSpan('wall-csg', () => { + if (mergedCutter) { + resultBrush = csgEvaluator.evaluate(resultBrush, mergedCutter, SUBTRACTION) + } + for (const cutter of merged.fallbackBrushes) { + const next = csgEvaluator.evaluate(resultBrush, cutter, SUBTRACTION) + if (resultBrush !== wallBrush) csgGeometry(resultBrush).dispose() + resultBrush = next + } + }) + } catch (error) { + if (resultBrush !== wallBrush) csgGeometry(resultBrush).dispose() + throw error } finally { csgGeometry(wallBrush).dispose() if (mergedCutter) csgGeometry(mergedCutter).dispose() From f8b69f823716b7436cbcb3e1dde48749b1b48308 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 16:15:11 -0400 Subject: [PATCH 5/7] IFC converter: emit each door and window once Every void relationship for a wall appended to its opening list, and the emission loop created a fresh node per visit, so a fill referenced by repeated relationships became eight identical doors on one wall (the sample castle). A fill belongs to one opening, which voids one host, so emitted fill ids are now tracked globally; spatial children use sets. Existing converted scenes keep their duplicates until reconverted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- packages/ifc-converter/src/index.ts | 21 +-- packages/ifc-converter/tests/openings.test.ts | 135 ++++++++++++++++++ 2 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 packages/ifc-converter/tests/openings.test.ts diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index ed775a16ee..09c3e4e91f 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -699,7 +699,7 @@ export async function convertIfcToPascal( // Maps to track relationships const parentMap = new Map() - const childrenMap = new Map() + const childrenMap = new Map>() const expressIdToNodeId = new Map() progress('Analyzing spatial relationships...', 20) @@ -754,9 +754,9 @@ export async function convertIfcToPascal( }) if (!childrenMap.has(parentExpressID)) { - childrenMap.set(parentExpressID, []) + childrenMap.set(parentExpressID, new Set()) } - childrenMap.get(parentExpressID)?.push(...children) + for (const childID of children) childrenMap.get(parentExpressID)!.add(childID) } } @@ -776,9 +776,9 @@ export async function convertIfcToPascal( }) if (!childrenMap.has(parentExpressID)) { - childrenMap.set(parentExpressID, []) + childrenMap.set(parentExpressID, new Set()) } - childrenMap.get(parentExpressID)?.push(...children) + for (const childID of children) childrenMap.get(parentExpressID)!.add(childID) } } @@ -1131,6 +1131,9 @@ export async function convertIfcToPascal( for (const openingId of openingIds) { const fillId = openingToFill.get(openingId) if (!fillId) continue + // IFC fills belong to at most one opening, which voids one host element. + // Repeated or conflicting relationships must not emit another node. + if (expressIdToNodeId.has(fillId)) continue const isDoor = doorExpressIds.has(fillId) const isWindow = windowExpressIds.has(fillId) @@ -1200,7 +1203,6 @@ export async function convertIfcToPascal( if (isDoor) { const nodeId = generateId('door') - expressIdToNodeId.set(fillId, nodeId) // Vertical centering is now handled: door center Y = height/2 so the // opening sits at the correct position. Remaining caveat: door bottom @@ -1226,10 +1228,10 @@ export async function convertIfcToPascal( }) nodes[nodeId] = doorNode + expressIdToNodeId.set(fillId, nodeId) wallNode.children.push(nodeId) } else { const nodeId = generateId('window') - expressIdToNodeId.set(fillId, nodeId) // TODO(ifc-fix): same scalar-vs-tuple position issue as door above. // sillHeight stays read-only metadata until we resolve the window @@ -1260,6 +1262,7 @@ export async function convertIfcToPascal( }) nodes[nodeId] = windowNode + expressIdToNodeId.set(fillId, nodeId) wallNode.children.push(nodeId) } } catch { @@ -1383,7 +1386,6 @@ export async function convertIfcToPascal( if (isDoor) { const h = height ?? 2.1 const nodeId = generateId('door') - expressIdToNodeId.set(fillId, nodeId) const doorNode = tryParse(DoorNode, 'door', { object: 'node', id: nodeId, @@ -1404,6 +1406,7 @@ export async function convertIfcToPascal( }), }) nodes[nodeId] = doorNode + expressIdToNodeId.set(fillId, nodeId) if (parentNodeId && nodes[parentNodeId]) { ;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId) } @@ -1411,7 +1414,6 @@ export async function convertIfcToPascal( const h = height ?? 1.2 const sill = hosted && scene ? Math.max(0, scene[2] - hosted.info.baseY) : 0 const nodeId = generateId('window') - expressIdToNodeId.set(fillId, nodeId) const windowNode = tryParse(WindowNode, 'window', { object: 'node', id: nodeId, @@ -1431,6 +1433,7 @@ export async function convertIfcToPascal( }), }) nodes[nodeId] = windowNode + expressIdToNodeId.set(fillId, nodeId) if (parentNodeId && nodes[parentNodeId]) { ;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId) } diff --git a/packages/ifc-converter/tests/openings.test.ts b/packages/ifc-converter/tests/openings.test.ts new file mode 100644 index 0000000000..4623372205 --- /dev/null +++ b/packages/ifc-converter/tests/openings.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as WebIFC from 'web-ifc' +import { convertIfcToPascal, type PascalSceneGraph } from '../src' + +const fixture = new URL( + '../../../apps/ifc-converter/public/test-ifc-files/04-ifc-open-house.ifc', + import.meta.url, +) +const fillIds = [2441, 2511, 2594, 2667, 2740, 2813] +const originalSetWasmPath = WebIFC.IfcAPI.prototype.SetWasmPath +const originalGetLineIDsWithType = WebIFC.IfcAPI.prototype.GetLineIDsWithType +const originalGetLine = WebIFC.IfcAPI.prototype.GetLine + +function assertUniqueFills(graph: PascalSceneGraph) { + const fills = Object.values(graph.nodes).filter( + (node) => node.type === 'door' || node.type === 'window', + ) + expect( + fills.map((node) => node.metadata?.expressID).sort((a, b) => Number(a) - Number(b)), + ).toEqual(fillIds) + for (const fill of fills) { + const parent = fill.parentId ? graph.nodes[fill.parentId] : undefined + expect(parent).toBeDefined() + if (parent && 'children' in parent) { + expect(parent.children.filter((id) => id === fill.id)).toHaveLength(1) + } + } +} + +describe('IFC opening emission', () => { + const spies: { mockRestore: () => void }[] = [] + + beforeEach(() => { + const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/` + spies.push( + spyOn(WebIFC.IfcAPI.prototype, 'SetWasmPath').mockImplementation(function ( + this: WebIFC.IfcAPI, + ) { + originalSetWasmPath.call(this, wasmPath, true) + }), + ) + }) + + afterEach(() => { + for (const spy of spies.splice(0).reverse()) spy.mockRestore() + }) + + it('emits each fixture fill once across relationship and fallback paths without cleanup', async () => { + const graph = await convertIfcToPascal(await Bun.file(fixture).bytes(), undefined, { + simplify: false, + }) + assertUniqueFills(graph) + const door = Object.values(graph.nodes).find((node) => node.metadata?.expressID === 2441) + expect(door?.metadata?.hostWallExpressID).toBe(268) + }) + + for (const [kind, fillId] of [ + ['door', 2441], + ['window', 2511], + ] as const) { + it(`emits one ${kind} when void, fill, and containment records repeat`, async () => { + spies.push( + spyOn(WebIFC.IfcAPI.prototype, 'GetLineIDsWithType').mockImplementation(function ( + this: WebIFC.IfcAPI, + modelID, + type, + includeInherited, + ) { + const ids = originalGetLineIDsWithType.call(this, modelID, type, includeInherited) + if ( + type !== WebIFC.IFCRELVOIDSELEMENT && + type !== WebIFC.IFCRELFILLSELEMENT && + type !== WebIFC.IFCRELAGGREGATES && + type !== WebIFC.IFCRELCONTAINEDINSPATIALSTRUCTURE + ) { + return ids + } + const repeatedIds = Array.from({ length: ids.size() * 8 }, (_, i) => + ids.get(i % ids.size()), + ) + return { + size: () => repeatedIds.length, + get: (i: number) => repeatedIds[i]!, + [Symbol.iterator]: () => repeatedIds.values(), + } + }), + spyOn(WebIFC.IfcAPI.prototype, 'GetLine').mockImplementation(function ( + this: WebIFC.IfcAPI, + modelID, + expressID, + ...args + ) { + const line = originalGetLine.call(this, modelID, expressID, ...args) + if (expressID !== 2451) return line + return { + ...line, + RelatedBuildingElement: { ...line.RelatedBuildingElement, value: fillId }, + } + }), + ) + + const graph = await convertIfcToPascal(await Bun.file(fixture).bytes(), undefined, { + simplify: false, + }) + assertUniqueFills(graph) + const fill = Object.values(graph.nodes).find((node) => node.metadata?.expressID === fillId) + expect(fill?.type).toBe(kind) + expect(fill?.metadata?.hostWallExpressID).toBe(268) + }) + } + + it('emits a shared fill only on the first converted host wall', async () => { + spies.push( + spyOn(WebIFC.IfcAPI.prototype, 'GetLine').mockImplementation(function ( + this: WebIFC.IfcAPI, + modelID, + expressID, + ...args + ) { + const line = originalGetLine.call(this, modelID, expressID, ...args) + if (expressID !== 120) return line + return { ...line, RelatedOpeningElement: { ...line.RelatedOpeningElement, value: 2380 } } + }), + ) + + const graph = await convertIfcToPascal(await Bun.file(fixture).bytes(), undefined, { + simplify: false, + }) + assertUniqueFills(graph) + const door = Object.values(graph.nodes).find((node) => node.metadata?.expressID === 2441) + expect(door?.metadata?.hostWallExpressID).toBe(40) + }) +}) From 493be6abd40c24172121b586b1bee466b7cb3906 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 16:15:11 -0400 Subject: [PATCH 6/7] Wall placeholder sweep: stamp rebuilt geometry so degenerate walls stay built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-length or fully cut wall rebuilds to as few vertices as the mount placeholder, and the sweep re-marked it every 30 frames — 21 castle walls rebuilt forever and their level's batch re-sewed with them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- .../src/systems/wall/wall-placeholder-sweep.test.ts | 11 +++++++++++ .../viewer/src/systems/wall/wall-placeholder-sweep.ts | 3 ++- packages/viewer/src/systems/wall/wall-system.tsx | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts b/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts index c9530ce07d..215887fd42 100644 --- a/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts +++ b/packages/viewer/src/systems/wall/wall-placeholder-sweep.test.ts @@ -81,3 +81,14 @@ describe('sweepUnbuiltWalls', () => { expect(sweep()).toEqual([]) }) }) + +describe('built stamp', () => { + test('a degenerate rebuilt geometry is not mistaken for the placeholder', () => { + expect( + isPlaceholderWallGeometry({ + userData: { built: true }, + getAttribute: () => ({ count: 3 }), + }), + ).toBe(false) + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts b/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts index 209bdaba62..08c1c2db22 100644 --- a/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts +++ b/packages/viewer/src/systems/wall/wall-placeholder-sweep.ts @@ -25,7 +25,7 @@ export const WALL_PLACEHOLDER_SWEEP_INTERVAL = 30 type GeometryLike = { - userData?: { placeholder?: unknown } + userData?: { placeholder?: unknown; built?: unknown } getAttribute?: (name: string) => { count: number } | undefined } | null @@ -33,6 +33,7 @@ type GeometryLike = { export const isPlaceholderWallGeometry = (geometry: GeometryLike): boolean => { if (!geometry) return false if (geometry.userData?.placeholder === true) return true + if (geometry.userData?.built === true) return false const position = geometry.getAttribute?.('position') return position !== undefined && position.count === 3 } diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 3bb8973906..36c0880153 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -963,6 +963,9 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix) mesh.geometry.dispose() + // A degenerate rebuild (zero-length or fully cut wall) yields as few vertices + // as the mount-time placeholder; the stamp keeps the sweep from re-marking it. + newGeo.userData.built = true mesh.geometry = newGeo // Update collision mesh const collisionMesh = mesh.getObjectByName('collision-mesh') as THREE.Mesh From 5e2243bee3ac2c3c8ab670badf232432076a33ec Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 4 Sep 2026 16:34:47 -0400 Subject: [PATCH 7/7] Sort imports in the wall batch test Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN --- packages/nodes/src/wall/wall-batch-system.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 7fffa4711c..b0c01979e8 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -12,6 +12,7 @@ import { MeshBasicMaterial, Object3D, } from 'three' +import { revealAllBatchedHolds } from '../shared/node-batch/candidates' import { collectTintedWalls, collectWallBatchCandidates, @@ -19,7 +20,6 @@ import { revealBatchedWallsForCapture, WallBatchSystem, } from './wall-batch-system' -import { revealAllBatchedHolds } from '../shared/node-batch/candidates' type FrameCallback = Parameters[0] let frameCallback: FrameCallback | null = null