Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion packages/ifc-converter/src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,37 @@ 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 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) {
return wallMaterialSignature(a) === wallMaterialSignature(b)
}
Comment thread
yorhodes marked this conversation as resolved.

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
Expand All @@ -220,6 +244,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)
}

Expand Down
108 changes: 108 additions & 0 deletions packages/ifc-converter/tests/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,114 @@ 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<string, AnyNode> = {
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<string, AnyNode> = {
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<string, AnyNode> = {
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('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<string, AnyNode> = {
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<string, AnyNode> = {
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<string, AnyNode> = {
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<string, AnyNode> = {
level_1: level('level_1', ['wall_a', 'wall_b']),
Expand Down