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) + }) +}) 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]> = [ diff --git a/packages/nodes/src/wall/wall-batch-system.test.ts b/packages/nodes/src/wall/wall-batch-system.test.ts index 72f8075161..b0c01979e8 100644 --- a/packages/nodes/src/wall/wall-batch-system.test.ts +++ b/packages/nodes/src/wall/wall-batch-system.test.ts @@ -12,9 +12,12 @@ import { MeshBasicMaterial, Object3D, } from 'three' +import { revealAllBatchedHolds } from '../shared/node-batch/candidates' import { collectTintedWalls, collectWallBatchCandidates, + holdBatchedWallsAfterCapture, + revealBatchedWallsForCapture, WallBatchSystem, } from './wall-batch-system' @@ -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 openingBrush(opening, wall.thickness)) + return withChainedSubtraction(cutters, () => + generateExtrudedWall(wall, openings.slice(0, 1), 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], 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(`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') + 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('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({ + ...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: mergedCutter } = mergeWallCutoutBrushes([a, b, c]) + const cutter = mergedCutter! + 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-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-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..36c0880153 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,126 @@ function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry } +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() + const bounds = geometry.boundingBox!.clone().applyMatrix4(brush.matrixWorld) + return { + brush, + bounds, + containerBounds: bounds.clone().expandByScalar(1e-5), + isBox: isBoxCutout(brush, bounds), + } + }) + 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]!]! + index = parents[index]! + } + return index + } + 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() + retained.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() + 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) + 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) + } + } + + 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, fallbackBrushes, droppedCount } + } finally { + for (const geometry of geometries) geometry.dispose() + for (const geometry of intermediateGeometries) geometry.dispose() + } +} + type WallBoundaryEdgeTag = 'front' | 'back' | 'base' type TaggedWallBoundaryEdge = { @@ -482,9 +602,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 +732,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 +748,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 +775,7 @@ export const WallSystem = () => { } if (rebuiltWallIds.size === 0) { + if (deferWallRebuilds) break continue } @@ -639,6 +792,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 +804,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 +840,9 @@ export const WallSystem = () => { } if ( - useProgressiveAdjacentRebuilds && - rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME + deferAdjacentRebuilds || + (useProgressiveAdjacentRebuilds && + rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) ) { break } @@ -806,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 @@ -1147,7 +1307,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), @@ -1177,24 +1336,37 @@ export function generateExtrudedWall( const wallBrush = new Brush(geometry) wallBrush.updateMatrixWorld() - // Subtract each cutout from the wall + let mergedCutter: Brush | null = null let resultBrush = wallBrush - for (const cutoutBrush of cutoutBrushes) { - prepareBrushForCSG(cutoutBrush) - const newResult = timeSpan('wall-csg', () => - csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION), + try { + const properties: Array<[string, string]> = [] + const merged = timeSpan( + 'wall-csg-union', + () => { + const cutouts = mergeWallCutoutBrushes(cutoutBrushes) + properties.push(['droppedCutouts', String(cutouts.droppedCount)]) + return cutouts + }, + { properties }, ) - prepareBrushForCSG(newResult) - if (resultBrush !== wallBrush) { - csgGeometry(resultBrush).dispose() - } - resultBrush = newResult - } - - // Clean up - csgGeometry(wallBrush).dispose() - for (const brush of cutoutBrushes) { - csgGeometry(brush).dispose() + 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() + for (const brush of cutoutBrushes) csgGeometry(brush).dispose() } const resultGeometry = csgGeometry(resultBrush)