Skip to content

Commit f5635ae

Browse files
committed
fix(ifc): address imported mesh review
1 parent 7ea95d0 commit f5635ae

7 files changed

Lines changed: 280 additions & 154 deletions

File tree

packages/ifc-converter/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ IFC bytes, returns `{ nodes, rootNodeIds, stats }` shaped against
66

77
No DOM, no React. The UI lives in `apps/ifc-converter`.
88

9-
Native Pascal nodes are produced for sites, buildings, levels, walls, doors,
10-
windows, slabs, stairs, roofs, columns, and IFC spaces (as room zones). Beams,
9+
Native Pascal nodes are produced when the converter can recover the required
10+
parameters for sites, buildings, levels, walls, doors, windows, slabs, columns,
11+
and IFC spaces (as room zones). IFC stairs and roofs retain Pascal hierarchy and
12+
source metadata, but are not yet a complete parametric conversion. Beams,
1113
railings, coverings, furnishings, proxies, curtain walls, plates, members,
12-
footings, and native wall/slab shapes that cannot be parameterized are retained
14+
footings, and elements whose native parameters cannot be recovered are retained
1315
as selectable `imported-mesh` nodes using their IFC triangle geometry and color.
1416
Imported meshes are import-only and do not appear as empty objects in the editor
1517
palette.

packages/ifc-converter/src/cleanup.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,24 +199,13 @@ function toWallSegment(wall: WallNode): WallSegment | null {
199199
}
200200

201201
function wallLineTolerance(a: WallSegment, b: WallSegment) {
202-
// Wall fragments must share essentially the same centerline. A tolerance based
203-
// on half the wall thickness can collapse adjacent, parallel walls whose faces
204-
// happen to meet (for example, a 4-inch exterior wall and an interior return
205-
// offset by 2 inches). Keep a small allowance for IFC modelling noise instead.
206-
return Math.max(0.005, Math.min(0.025, Math.max(a.thickness, b.thickness) * 0.1))
202+
return Math.max(0.06, Math.min(0.14, Math.max(a.thickness, b.thickness) * 0.5))
207203
}
208204

209205
function wallHeightCompatible(a: WallSegment, b: WallSegment) {
210206
return Math.abs(a.height - b.height) <= WALL_HEIGHT_TOLERANCE
211207
}
212208

213-
function wallMaterialCompatible(a: WallSegment, b: WallSegment) {
214-
const materialA = (a.wall.metadata as { material?: unknown } | undefined)?.material
215-
const materialB = (b.wall.metadata as { material?: unknown } | undefined)?.material
216-
if (typeof materialA !== 'string' || typeof materialB !== 'string') return true
217-
return materialA === materialB
218-
}
219-
220209
function wallIntervalsCompatible(a: WallSegment, b: WallSegment, maxJoinGap: number) {
221210
const gap = Math.max(a.t0, b.t0) - Math.min(a.t1, b.t1)
222211
if (gap <= maxJoinGap) return true
@@ -231,9 +220,6 @@ function wallsCanMerge(a: WallSegment, b: WallSegment, maxJoinGap: number) {
231220
if (Math.abs(a.angleBucket - b.angleBucket) > 1) return false
232221
if (Math.abs(a.offset - b.offset) > wallLineTolerance(a, b)) return false
233222
if (!wallHeightCompatible(a, b)) return false
234-
// Collinear walls can still represent different assemblies. Merging across
235-
// that semantic boundary can bridge a real doorway or open connection.
236-
if (!wallMaterialCompatible(a, b)) return false
237223
return wallIntervalsCompatible(a, b, maxJoinGap)
238224
}
239225

packages/ifc-converter/src/index.ts

Lines changed: 139 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,16 @@ function sourceColor(color: { x?: number; y?: number; z?: number } | undefined):
621621
return `#${channel(color?.x)}${channel(color?.y)}${channel(color?.z)}`
622622
}
623623

624+
function roundMeshPosition(value: number): number {
625+
const rounded = Math.round(value * 10_000) / 10_000
626+
return rounded === 0 ? 0 : rounded
627+
}
628+
629+
function roundMeshNormal(value: number): number {
630+
const rounded = Math.round(value * 1000) / 1000
631+
return rounded === 0 ? 0 : rounded
632+
}
633+
624634
function extractImportedMeshPrimitives(
625635
ifcApi: WebIFC.IfcAPI,
626636
modelID: number,
@@ -678,24 +688,21 @@ function extractImportedMeshPrimitives(
678688
// STEP `swapYZ` transform here a second time makes plan depth look
679689
// like height (and height look like plan depth), exploding fallback
680690
// walls and railings across the scene.
681-
if (swapYZ) {
682-
positions.push(
683-
(world[0]! - originOffset[0]!) * unitFactor,
684-
(world[1]! - originOffset[2]!) * unitFactor - levelElevation,
685-
-(world[2]! + originOffset[1]!) * unitFactor,
686-
)
687-
} else {
688-
positions.push(
689-
...toPascalPoint(
691+
const mappedPosition: [number, number, number] = swapYZ
692+
? [
693+
world[0]! - originOffset[0]! * unitFactor,
694+
world[1]! - originOffset[2]! * unitFactor - levelElevation,
695+
-(world[2]! + originOffset[1]! * unitFactor),
696+
]
697+
: toPascalPoint(
690698
[
691-
(world[0]! - originOffset[0]!) * unitFactor,
692-
(world[1]! - originOffset[1]!) * unitFactor,
693-
(world[2]! - originOffset[2]!) * unitFactor,
699+
world[0]! - originOffset[0]! * unitFactor,
700+
world[1]! - originOffset[1]! * unitFactor,
701+
world[2]! - originOffset[2]! * unitFactor,
694702
],
695703
levelElevation,
696-
),
697-
)
698-
}
704+
)
705+
positions.push(...mappedPosition.map(roundMeshPosition))
699706

700707
const nx = vertices[vertex + 3]!
701708
const ny = vertices[vertex + 4]!
@@ -710,9 +717,9 @@ function extractImportedMeshPrimitives(
710717
: worldNormal
711718
const normalLength = Math.hypot(...mappedNormal) || 1
712719
normals.push(
713-
mappedNormal[0]! / normalLength,
714-
mappedNormal[1]! / normalLength,
715-
mappedNormal[2]! / normalLength,
720+
roundMeshNormal(mappedNormal[0]! / normalLength),
721+
roundMeshNormal(mappedNormal[1]! / normalLength),
722+
roundMeshNormal(mappedNormal[2]! / normalLength),
716723
)
717724
}
718725

@@ -1042,6 +1049,24 @@ export async function convertIfcToPascal(
10421049
return storeyExpressId == null ? 0 : (storeyElevationByExpressId.get(storeyExpressId) ?? 0)
10431050
}
10441051

1052+
const importedPrimitivesByExpressId = new Map<number, ImportedMeshPrimitiveValue[]>()
1053+
function importedMeshPrimitivesFor(expressId: number): ImportedMeshPrimitiveValue[] {
1054+
const cached = importedPrimitivesByExpressId.get(expressId)
1055+
if (cached) return cached
1056+
const primitives = extractImportedMeshPrimitives(
1057+
ifcApi,
1058+
modelID,
1059+
expressId,
1060+
unitFactor,
1061+
originOffset,
1062+
elementLevelElevation(expressId),
1063+
toPascalPoint,
1064+
opts.swapYZ,
1065+
)
1066+
importedPrimitivesByExpressId.set(expressId, primitives)
1067+
return primitives
1068+
}
1069+
10451070
progress('Processing sites...', 30)
10461071
// Process sites
10471072
const sites = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSITE)
@@ -1763,7 +1788,12 @@ export async function convertIfcToPascal(
17631788
// landings as exact meshes: roofs may be sloped, while a local landing is
17641789
// not a storey floor and must not raise adjacent wall bases.
17651790
const slabPredefinedType = String(slab.PredefinedType?.value ?? '').toUpperCase()
1766-
if (slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') continue
1791+
if (
1792+
(slabPredefinedType === 'ROOF' || slabPredefinedType === 'LANDING') &&
1793+
importedMeshPrimitivesFor(slabExpressID).length > 0
1794+
) {
1795+
continue
1796+
}
17671797

17681798
const nodeId = generateId('slab')
17691799
expressIdToNodeId.set(slabExpressID, nodeId)
@@ -1851,6 +1881,7 @@ export async function convertIfcToPascal(
18511881
ifcType: 'IFCSLAB',
18521882
expressID: slabExpressID,
18531883
globalId: slab.GlobalId?.value,
1884+
predefinedType: slab.PredefinedType?.value,
18541885
thickness,
18551886
}),
18561887
})
@@ -1863,6 +1894,24 @@ export async function convertIfcToPascal(
18631894
}
18641895

18651896
// Process stairs
1897+
const stairFlightExpressIds = new Set<number>()
1898+
const stairFlights = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIRFLIGHT)
1899+
for (let i = 0; i < stairFlights.size(); i++) stairFlightExpressIds.add(stairFlights.get(i))
1900+
1901+
function claimStairFlightDescendants(stairExpressId: number, stairNodeId: string) {
1902+
const pending = [...(childrenMap.get(stairExpressId) ?? [])]
1903+
const visited = new Set<number>()
1904+
while (pending.length > 0) {
1905+
const descendant = pending.pop()!
1906+
if (visited.has(descendant)) continue
1907+
visited.add(descendant)
1908+
if (stairFlightExpressIds.has(descendant)) {
1909+
expressIdToNodeId.set(descendant, stairNodeId)
1910+
}
1911+
pending.push(...(childrenMap.get(descendant) ?? []))
1912+
}
1913+
}
1914+
18661915
const stairs = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSTAIR)
18671916
for (let i = 0; i < stairs.size(); i++) {
18681917
const stairExpressID = stairs.get(i)
@@ -1871,6 +1920,7 @@ export async function convertIfcToPascal(
18711920
const stair = ifcApi.GetLine(modelID, stairExpressID)
18721921
const nodeId = generateId('stair')
18731922
expressIdToNodeId.set(stairExpressID, nodeId)
1923+
claimStairFlightDescendants(stairExpressID, nodeId)
18741924

18751925
const parentNodeId = resolveElementParent(stairExpressID)
18761926

@@ -2165,87 +2215,80 @@ export async function convertIfcToPascal(
21652215
try {
21662216
const spaces = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCSPACE)
21672217
for (let i = 0; i < spaces.size(); i++) {
2168-
const spaceExpressId = spaces.get(i)
2169-
if (expressIdToNodeId.has(spaceExpressId)) continue
2170-
const space = ifcApi.GetLine(modelID, spaceExpressId)
2171-
const parentNodeId = resolveElementParent(spaceExpressId)
2172-
const levelElevation = elementLevelElevation(spaceExpressId)
2173-
const primitives = extractImportedMeshPrimitives(
2174-
ifcApi,
2175-
modelID,
2176-
spaceExpressId,
2177-
unitFactor,
2178-
originOffset,
2179-
levelElevation,
2180-
toPascalPoint,
2181-
opts.swapYZ,
2182-
)
2183-
let polygon: [number, number][] | null = null
2184-
let ceilingHeight = DEFAULT_LEVEL_HEIGHT
21852218
try {
2186-
const worldMat = space.ObjectPlacement?.value
2187-
? resolveWorldTransform(ifcApi, modelID, space.ObjectPlacement.value)
2188-
: identity()
2189-
const body = getBodyExtrusionData(ifcApi, modelID, space)
2190-
const extrusionMat = getExtrusionPosition(ifcApi, modelID, space)
2191-
if (body.profilePoints && body.profilePoints.length >= 3) {
2192-
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
2193-
polygon = body.profilePoints.map((point) => {
2194-
const scene = worldToScene(transformPoint3(combinedMat, [point[0], point[1], 0]))
2195-
return [scene[0], scene[1]] as [number, number]
2196-
})
2197-
const first = polygon[0]
2198-
const last = polygon.at(-1)
2199-
if (
2200-
polygon.length > 3 &&
2201-
last &&
2202-
Math.abs(first[0] - last[0]) < 1e-6 &&
2203-
Math.abs(first[1] - last[1]) < 1e-6
2204-
) {
2205-
polygon.pop()
2219+
const spaceExpressId = spaces.get(i)
2220+
if (expressIdToNodeId.has(spaceExpressId)) continue
2221+
const space = ifcApi.GetLine(modelID, spaceExpressId)
2222+
const parentNodeId = resolveElementParent(spaceExpressId)
2223+
const primitives = importedMeshPrimitivesFor(spaceExpressId)
2224+
let polygon: [number, number][] | null = null
2225+
let ceilingHeight = DEFAULT_LEVEL_HEIGHT
2226+
try {
2227+
const worldMat = space.ObjectPlacement?.value
2228+
? resolveWorldTransform(ifcApi, modelID, space.ObjectPlacement.value)
2229+
: identity()
2230+
const body = getBodyExtrusionData(ifcApi, modelID, space)
2231+
const extrusionMat = getExtrusionPosition(ifcApi, modelID, space)
2232+
if (body.profilePoints && body.profilePoints.length >= 3) {
2233+
const combinedMat = extrusionMat ? multiply(worldMat, extrusionMat) : worldMat
2234+
polygon = body.profilePoints.map((point) => {
2235+
const scene = worldToScene(transformPoint3(combinedMat, [point[0], point[1], 0]))
2236+
return [scene[0], scene[1]] as [number, number]
2237+
})
2238+
const first = polygon[0]
2239+
const last = polygon.at(-1)
2240+
if (
2241+
polygon.length > 3 &&
2242+
last &&
2243+
Math.abs(first[0] - last[0]) < 1e-6 &&
2244+
Math.abs(first[1] - last[1]) < 1e-6
2245+
) {
2246+
polygon.pop()
2247+
}
22062248
}
2249+
if (body.depth) ceilingHeight = body.depth * unitFactor
2250+
} catch {
2251+
/* mesh fallback below */
2252+
}
2253+
polygon ??= meshFootprint(primitives)
2254+
if (!polygon || polygon.length < 3) continue
2255+
if (primitives.length > 0) {
2256+
const ys = primitives.flatMap((primitive) =>
2257+
primitive.positions.filter((_, index) => index % 3 === 1),
2258+
)
2259+
if (ys.length > 0) ceilingHeight = Math.max(...ys) - Math.min(...ys)
22072260
}
2208-
if (body.depth) ceilingHeight = body.depth * unitFactor
2209-
} catch {
2210-
/* mesh fallback below */
2211-
}
2212-
polygon ??= meshFootprint(primitives)
2213-
if (!polygon || polygon.length < 3) continue
2214-
if (primitives.length > 0) {
2215-
const ys = primitives.flatMap((primitive) =>
2216-
primitive.positions.filter((_, index) => index % 3 === 1),
2217-
)
2218-
if (ys.length > 0) ceilingHeight = Math.max(...ys) - Math.min(...ys)
2219-
}
22202261

2221-
const nodeId = generateId('zone')
2222-
const zone = tryParse(ZoneNode, 'zone', {
2223-
object: 'node',
2224-
id: nodeId,
2225-
type: 'zone',
2226-
name: space.LongName?.value || space.Name?.value || `Space ${i + 1}`,
2227-
parentId: parentNodeId,
2228-
visible: true,
2229-
polygon,
2230-
spaceRole: 'room',
2231-
roomNumber:
2232-
space.LongName?.value && space.Name?.value !== space.LongName?.value
2233-
? space.Name.value
2234-
: '',
2235-
ceilingHeight: Math.max(0.1, ceilingHeight),
2236-
metadata: buildMetadata({
2237-
ifcType: 'IFCSPACE',
2238-
expressID: spaceExpressId,
2239-
globalId: space.GlobalId?.value,
2240-
predefinedType: space.PredefinedType?.value,
2241-
}),
2242-
})
2243-
expressIdToNodeId.set(spaceExpressId, nodeId)
2244-
nodes[nodeId] = zone
2245-
if (parentNodeId && nodes[parentNodeId]) {
2246-
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
2262+
const spaceName = space.Name?.value
2263+
const longName = space.LongName?.value
2264+
const nodeId = generateId('zone')
2265+
const zone = tryParse(ZoneNode, 'zone', {
2266+
object: 'node',
2267+
id: nodeId,
2268+
type: 'zone',
2269+
name: longName || spaceName || `Space ${i + 1}`,
2270+
parentId: parentNodeId,
2271+
visible: true,
2272+
polygon,
2273+
spaceRole: 'room',
2274+
roomNumber: longName && spaceName !== longName ? (spaceName ?? '') : '',
2275+
ceilingHeight: Math.max(0.1, ceilingHeight),
2276+
metadata: buildMetadata({
2277+
ifcType: 'IFCSPACE',
2278+
expressID: spaceExpressId,
2279+
globalId: space.GlobalId?.value,
2280+
predefinedType: space.PredefinedType?.value,
2281+
}),
2282+
})
2283+
expressIdToNodeId.set(spaceExpressId, nodeId)
2284+
nodes[nodeId] = zone
2285+
if (parentNodeId && nodes[parentNodeId]) {
2286+
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
2287+
}
2288+
importedSpaceCount++
2289+
} catch {
2290+
/* skip malformed space */
22472291
}
2248-
importedSpaceCount++
22492292
}
22502293
} catch {
22512294
/* IFC schema may not expose spaces */
@@ -2291,16 +2334,7 @@ export async function convertIfcToPascal(
22912334
if (expressIdToNodeId.has(expressId)) continue
22922335
const element = ifcApi.GetLine(modelID, expressId)
22932336
const parentNodeId = resolveElementParent(expressId)
2294-
const primitives = extractImportedMeshPrimitives(
2295-
ifcApi,
2296-
modelID,
2297-
expressId,
2298-
unitFactor,
2299-
originOffset,
2300-
elementLevelElevation(expressId),
2301-
toPascalPoint,
2302-
opts.swapYZ,
2303-
)
2337+
const primitives = importedMeshPrimitivesFor(expressId)
23042338
if (primitives.length === 0) continue
23052339

23062340
const nodeId = generateId('imesh')

0 commit comments

Comments
 (0)