Skip to content
Merged
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
21 changes: 12 additions & 9 deletions packages/ifc-converter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ export async function convertIfcToPascal(

// Maps to track relationships
const parentMap = new Map<number, number>()
const childrenMap = new Map<number, number[]>()
const childrenMap = new Map<number, Set<number>>()
const expressIdToNodeId = new Map<number, string>()

progress('Analyzing spatial relationships...', 20)
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1260,6 +1262,7 @@ export async function convertIfcToPascal(
})

nodes[nodeId] = windowNode
expressIdToNodeId.set(fillId, nodeId)
wallNode.children.push(nodeId)
}
} catch {
Expand Down Expand Up @@ -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,
Expand All @@ -1404,14 +1406,14 @@ export async function convertIfcToPascal(
}),
})
nodes[nodeId] = doorNode
expressIdToNodeId.set(fillId, nodeId)
if (parentNodeId && nodes[parentNodeId]) {
;(nodes[parentNodeId] as { children?: string[] }).children?.push(nodeId)
}
} else {
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,
Expand All @@ -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)
}
Expand Down
135 changes: 135 additions & 0 deletions packages/ifc-converter/tests/openings.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
37 changes: 28 additions & 9 deletions packages/nodes/src/wall/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type AnyNode,
type AnyNodeId,
hiddenWallPointerEventsHeld,
useLiveNodeOverrides,
useRegistry,
useScene,
type WallNode,
Expand All @@ -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'
Expand All @@ -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<ComponentProps<typeof WallTreatments>, 'levelData'>,
) {
const { node } = props
const selector = useMemo(
() => createWallTreatmentSelector(node, wallTreatmentProudOffsets(node)),
[node],
)
const levelData = useWallTreatmentLevelData(selector)
return levelData ? <WallTreatments {...props} levelData={levelData} /> : null
}

/**
* Thin wall renderer.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -172,12 +192,11 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
{...handlers}
/>

{treatmentLevelData && (
<WallTreatments
{hasWallTreatments(treatmentNode) && (
<WallTreatmentSubscription
childrenNodes={childNodes}
levelData={treatmentLevelData}
materials={extraMaterials}
node={node}
node={treatmentNode}
/>
)}

Expand Down
Loading
Loading