From d1cbf8de1d7b0df0d5b3e16cd825049d2742e519 Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Wed, 5 Aug 2026 14:26:58 -0400 Subject: [PATCH 1/2] fix(ifc): avoid merging adjacent wall assemblies --- packages/ifc-converter/src/cleanup.ts | 13 +++++- packages/ifc-converter/tests/cleanup.test.ts | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/ifc-converter/src/cleanup.ts b/packages/ifc-converter/src/cleanup.ts index 961ead4ecc..565d065b9f 100644 --- a/packages/ifc-converter/src/cleanup.ts +++ b/packages/ifc-converter/src/cleanup.ts @@ -199,13 +199,23 @@ function toWallSegment(wall: WallNode): WallSegment | null { } function wallLineTolerance(a: WallSegment, b: WallSegment) { - return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5)) + // Fragments must share essentially the same centerline. A tolerance based on + // half the wall thickness can collapse adjacent walls whose faces merely meet. + return Math.max(0.005, Math.min(0.025, Math.max(a.thickness, b.thickness) * 0.1)) } function wallHeightCompatible(a: WallSegment, b: WallSegment) { return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE } +function wallMaterialCompatible(a: WallSegment, b: WallSegment) { + const materialA = (a.wall.metadata as { material?: unknown } | undefined)?.material + const materialB = (b.wall.metadata as { material?: unknown } | undefined)?.material + const nameA = typeof materialA === 'string' ? materialA : null + const nameB = typeof materialB === 'string' ? materialB : null + return nameA === nameB +} + function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) { const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1) if (gap <= maxJoinGap) return true @@ -220,6 +230,7 @@ function wallsCanMerge(a: WallSegment, b: WallSegment, maxJoinGap: number) { if (Math.abs(a.angleBucket - b.angleBucket) > 1) return false if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b)) return false if (!wallHeightCompatible(a, b)) return false + if (!wallMaterialCompatible(a, b)) return false return wallIntervalsCompatible(a, b, maxJoinGap) } diff --git a/packages/ifc-converter/tests/cleanup.test.ts b/packages/ifc-converter/tests/cleanup.test.ts index e0544269c3..506dc6d597 100644 --- a/packages/ifc-converter/tests/cleanup.test.ts +++ b/packages/ifc-converter/tests/cleanup.test.ts @@ -85,6 +85,51 @@ describe('simplifyConvertedSceneGraph', () => { expect((nodes.level_1 as { children: string[] }).children).toEqual([keptWall?.id]) }) + it('does not merge parallel wall fragments on centerlines offset by two inches', () => { + const nodes: Record = { + level_1: level('level_1', ['wall_a', 'wall_b']), + wall_a: wall('wall_a', [0, 0], [2, 0]), + wall_b: wall('wall_b', [2, 0.0508], [4, 0.0508]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + + it('does not merge collinear wall fragments with different IFC materials', () => { + const exterior = wall('wall_exterior', [0, 0], [2, 0]) + const interior = wall('wall_interior', [2.9, 0], [5, 0]) + exterior.metadata = { material: 'Exterior Finish Assembly' } + interior.metadata = { material: 'Interior Partition Assembly' } + const nodes: Record = { + level_1: level('level_1', ['wall_exterior', 'wall_interior']), + wall_exterior: exterior, + wall_interior: interior, + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + + it('does not merge a material-tagged wall with an untagged wall', () => { + const tagged = wall('wall_tagged', [0, 0], [2, 0]) + tagged.metadata = { material: 'Exterior Finish Assembly' } + const nodes: Record = { + level_1: level('level_1', ['wall_tagged', 'wall_unknown']), + wall_tagged: tagged, + wall_unknown: wall('wall_unknown', [2.9, 0], [5, 0]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + it('reprojects openings from removed walls onto the merged wall', () => { const nodes: Record = { level_1: level('level_1', ['wall_a', 'wall_b']), From 0024b1edaf31a709e0d8a362986bd2e9352543dd Mon Sep 17 00:00:00 2001 From: Yorke Rhodes Date: Wed, 5 Aug 2026 14:44:42 -0400 Subject: [PATCH 2/2] fix(ifc): compare wall material layer assemblies --- packages/ifc-converter/src/cleanup.ts | 24 ++++++-- packages/ifc-converter/tests/cleanup.test.ts | 63 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/ifc-converter/src/cleanup.ts b/packages/ifc-converter/src/cleanup.ts index 565d065b9f..60a2f07b70 100644 --- a/packages/ifc-converter/src/cleanup.ts +++ b/packages/ifc-converter/src/cleanup.ts @@ -208,12 +208,26 @@ function wallHeightCompatible(a: WallSegment, b: WallSegment) { return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE } +function wallMaterialSignature(segment: WallSegment): string | null { + const metadata = segment.wall.metadata as + | { material?: unknown; materialLayers?: unknown } + | undefined + const material = typeof metadata?.material === 'string' ? metadata.material : null + const layers = Array.isArray(metadata?.materialLayers) + ? metadata.materialLayers.map((layer) => { + const value = layer as { name?: unknown; thickness?: unknown } + return [ + typeof value.name === 'string' ? value.name : null, + typeof value.thickness === 'number' ? value.thickness : null, + ] + }) + : [] + if (material === null && layers.length === 0) return null + return JSON.stringify({ material, layers }) +} + function wallMaterialCompatible(a: WallSegment, b: WallSegment) { - const materialA = (a.wall.metadata as { material?: unknown } | undefined)?.material - const materialB = (b.wall.metadata as { material?: unknown } | undefined)?.material - const nameA = typeof materialA === 'string' ? materialA : null - const nameB = typeof materialB === 'string' ? materialB : null - return nameA === nameB + return wallMaterialSignature(a) === wallMaterialSignature(b) } function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) { diff --git a/packages/ifc-converter/tests/cleanup.test.ts b/packages/ifc-converter/tests/cleanup.test.ts index 506dc6d597..28fd1d175e 100644 --- a/packages/ifc-converter/tests/cleanup.test.ts +++ b/packages/ifc-converter/tests/cleanup.test.ts @@ -130,6 +130,69 @@ describe('simplifyConvertedSceneGraph', () => { expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) }) + it('does not merge walls with different IFC material layer assemblies', () => { + const exterior = wall('wall_exterior', [0, 0], [2, 0]) + const interior = wall('wall_interior', [2.9, 0], [5, 0]) + exterior.metadata = { + materialLayers: [ + { name: 'Gypsum', thickness: 0.013 }, + { name: 'Stud', thickness: 0.09 }, + ], + } + interior.metadata = { + materialLayers: [ + { name: 'Gypsum', thickness: 0.013 }, + { name: 'Concrete', thickness: 0.2 }, + ], + } + const nodes: Record = { + level_1: level('level_1', ['wall_exterior', 'wall_interior']), + wall_exterior: exterior, + wall_interior: interior, + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + + it('does not merge a layer-tagged wall with an untagged wall', () => { + const tagged = wall('wall_tagged', [0, 0], [2, 0]) + tagged.metadata = { materialLayers: [{ name: 'Concrete', thickness: 0.2 }] } + const nodes: Record = { + level_1: level('level_1', ['wall_tagged', 'wall_unknown']), + wall_tagged: tagged, + wall_unknown: wall('wall_unknown', [2.9, 0], [5, 0]), + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(0) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(2) + }) + + it('merges walls with identical IFC material layer assemblies', () => { + const first = wall('wall_a', [0, 0], [2, 0]) + const second = wall('wall_b', [2.9, 0], [5, 0]) + const materialLayers = [ + { name: 'Gypsum', thickness: 0.013 }, + { name: 'Stud', thickness: 0.09 }, + ] + first.metadata = { materialLayers } + second.metadata = { materialLayers: [...materialLayers] } + const nodes: Record = { + level_1: level('level_1', ['wall_a', 'wall_b']), + wall_a: first, + wall_b: second, + } + + const stats = simplifyConvertedSceneGraph(nodes) + + expect(stats.removedMergedWalls).toBe(1) + expect(Object.values(nodes).filter((node) => node.type === 'wall')).toHaveLength(1) + }) + it('reprojects openings from removed walls onto the merged wall', () => { const nodes: Record = { level_1: level('level_1', ['wall_a', 'wall_b']),