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
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ describe('renderFloorplanGeometryToPdfKit', () => {
expect(pdf).toMatch(/f\*/)
})

test('ends an unpainted shape so the next fill does not paint it', async () => {
// Zones emit fill/stroke 'none' outlines; walls fill right after them.
const geometry = {
kind: 'group',
children: [
{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
fill: 'none',
stroke: 'none',
},
{
kind: 'polygon',
points: [
[0, 0],
[1, 0],
[1, 0.2],
[0, 0.2],
],
fill: '#374151',
},
],
} as FloorplanGeometry

const pdf = await renderTestPdf(geometry)
const ops = pdf.split('\n')
const firstClose = ops.indexOf('h')

// the unpainted outline is closed and discarded ('n') before the filled one starts
expect(firstClose).toBeGreaterThan(-1)
expect(ops[firstClose + 1]).toBe('n')
expect(pdf).toMatch(/\bf\n/)
})

test('writes a data-url PNG image without resolving it as an asset', async () => {
const geometry = {
kind: 'image',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ function paintStyledGeometry(
if (fill && stroke) raw.fillAndStroke(fill, stroke, fillRule)
else if (fill) raw.fill(fill, fillRule)
else if (stroke) raw.stroke(stroke)
// Unpainted shape (fill and stroke both none, e.g. zone hit areas): end the
// path without painting ('n'). Otherwise the open path is carried into the
// next fill and printed in that shape's colour.
else raw.addContent('n')
}

function isPaint(value: unknown): boolean {
Expand Down
1 change: 1 addition & 0 deletions packages/ifc-converter/src/export/ifc-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export const label = (value: string) => typed('IFCLABEL', value)
export const identifier = (value: string) => typed('IFCIDENTIFIER', value)
export const text = (value: string) => typed('IFCTEXT', value)
export const bool = (value: boolean) => typed('IFCBOOLEAN', value)
export const logical = (value: boolean) => typed('IFCLOGICAL', value)
export const length = (value: number) => typed('IFCLENGTHMEASURE', value)
export const ratio = (value: number) => typed('IFCPOSITIVERATIOMEASURE', value)
export { DERIVED, enumValue, int }
82 changes: 69 additions & 13 deletions packages/ifc-converter/src/export/scene-to-ifc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getLevelElevations,
getOpeningWallCut,
getRenderableSlabPolygon,
getWallAssemblyPreset,
getWallBodyCenterOffset,
getWallCurveFrameAt,
getWallCurveLength,
Expand All @@ -27,6 +28,7 @@ import {
measureStair,
prepareSlabPolygonContext,
resolveCeilingHeight,
resolveWallExteriorSide,
type SlabNode,
type StairNode,
type StairSegmentNode,
Expand All @@ -51,6 +53,7 @@ import {
identifier,
int,
label,
logical,
type PlanFrame,
ratio,
relativeFrame,
Expand Down Expand Up @@ -843,6 +846,48 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult {
const layerUsages = new Map<string, { usage: StepRef; walls: StepRef[] }>()
const wallMaterial = step.add('IFCMATERIAL', 'Wall', null, null)

// One IfcMaterial per (material, role) used by an assembly layer.
const layerMaterials = new Map<string, StepRef>()
const layerMaterial = (name: string, category: string | null) => {
const key = `${name}|${category ?? ''}`
let ref = layerMaterials.get(key)
if (!ref) {
ref = step.add('IFCMATERIAL', name, null, category)
layerMaterials.set(key, ref)
}
return ref
}
// IFC4's suggested layer categories.
const layerCategory = (role: string, core: boolean) =>
core
? 'LoadBearing'
: role === 'insulation'
? 'Insulation'
: role === 'finish' || role === 'lining'
? 'Finish'
: null
/**
* The wall's assembly as IFC layers, ordered from Pascal's front face (+n),
* which is where an AXIS2/POSITIVE usage starting at -faces.a begins. Null
* when the wall has no assembly or its layers don't add up to the body.
*/
const wallAssemblyLayers = (wall: WallNode, thickness: number) => {
const assembly = wall.assembly
if (!assembly?.layers.length) return null
const sum = assembly.layers.reduce((total, layer) => total + layer.thickness, 0)
if (Math.abs(sum - thickness) > 1e-4) return null
// Layers run from the front face unless the stack starts at the exterior
// and the exterior is the back face.
const startsAtBack =
assembly.face === 'exterior' && (resolveWallExteriorSide(wall) ?? -1) === -1
const layers = startsAtBack ? [...assembly.layers].reverse() : assembly.layers
Comment thread
cursor[bot] marked this conversation as resolved.
const preset = getWallAssemblyPreset(assembly.presetId)
return {
name: preset?.label ?? null,
layers: layers.filter((layer) => layer.thickness > 0),
}
}

const wallsByLevel = new Map<string, WallNode[]>()
for (const wall of ofType<WallNode>('wall'))
if (wall.parentId) pushTo(wallsByLevel, wall.parentId, wall)
Expand Down Expand Up @@ -947,23 +992,34 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult {
// Pascal's body spans [b, a] along its left normal; that normal is
// IFC -Y in the wall frame, so the first layer starts at -a.
const offset = -faces.a
const usageKey = `${thickness.toFixed(6)}|${offset.toFixed(6)}`
const stack = wallAssemblyLayers(wall, thickness)
const stackKey = stack
? stack.layers
.map((l) => `${l.role}:${l.material ?? ''}:${l.thickness.toFixed(6)}:${l.core ? 1 : 0}`)
.join(',')
: 'core'
const usageKey = `${thickness.toFixed(6)}|${offset.toFixed(6)}|${stackKey}`
let usage = layerUsages.get(usageKey)
if (!usage) {
const layer = step.add(
'IFCMATERIALLAYER',
wallMaterial,
thickness,
null,
'Core',
null,
null,
null,
)
const layers = stack
? stack.layers.map((l) => {
const category = layerCategory(l.role, l.core === true)
return step.add(
'IFCMATERIALLAYER',
layerMaterial(l.material ?? l.role, category),
l.thickness,
l.role === 'air' ? logical(true) : null,
l.id,
l.role,
category,
null,
)
})
: [step.add('IFCMATERIALLAYER', wallMaterial, thickness, null, 'Core', null, null, null)]
const layerSet = step.add(
'IFCMATERIALLAYERSET',
[layer],
`Wall ${Math.round(thickness * 1000)} mm`,
layers,
stack?.name ?? `Wall ${Math.round(thickness * 1000)} mm`,
null,
)
usage = {
Expand Down
148 changes: 148 additions & 0 deletions packages/ifc-converter/tests/export-wall-assembly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
type AnyNode,
getWallAssemblyPreset,
resolveWallExteriorSide,
type WallNode,
wallAssemblyPatch,
} from '@pascal-app/core'
import * as WebIFC from 'web-ifc'
import { buildIfcExport } from '../src/export'
import { roomWithOpenings, wallsOn } from './export-scenes'

const wasmPath = `${dirname(fileURLToPath(import.meta.resolve('web-ifc')))}/`
const api = new WebIFC.IfcAPI()

beforeAll(async () => {
api.SetWasmPath(wasmPath, true)
await api.Init()
})

type LayerOut = { material: string; thickness: number; category: string | null }

/** Each wall's IFC layer stack, in IFC order, by wall GlobalId. */
function wallLayers(ifc: string): Map<string, { setName: string; layers: LayerOut[] }> {
const modelID = api.OpenModel(new TextEncoder().encode(ifc))
try {
const out = new Map<string, { setName: string; layers: LayerOut[] }>()
const rels = api.GetLineIDsWithType(modelID, WebIFC.IFCRELASSOCIATESMATERIAL)
for (let i = 0; i < rels.size(); i++) {
const rel = api.GetLine(modelID, rels.get(i), true)
const usage = rel.RelatingMaterial
if (usage?.type !== WebIFC.IFCMATERIALLAYERSETUSAGE) continue
const set = usage.ForLayerSet
const layers = set.MaterialLayers.map((l: any) => ({
material: l.Material.Name.value,
thickness: l.LayerThickness.value,
category: l.Category?.value ?? null,
}))
for (const obj of rel.RelatedObjects) {
out.set(obj.GlobalId.value, { setName: set.LayerSetName.value, layers })
}
}
return out
} finally {
api.CloseModel(modelID)
}
}

function exteriorStuccoScene(flipFirstWall: boolean) {
const nodes: Record<string, AnyNode> = roomWithOpenings()
const level = Object.values(nodes).find((n) => n.type === 'level')!
const preset = getWallAssemblyPreset('exterior-2x6-stucco')!
wallsOn(nodes, level.id).forEach((wall, index) => {
let next = { ...wall } as WallNode
if (flipFirstWall && index === 0) {
next = {
...next,
start: wall.end,
end: wall.start,
frontSide: wall.backSide,
backSide: wall.frontSide,
}
}
nodes[wall.id] = {
...next,
...wallAssemblyPatch({ ...preset.assembly, face: 'exterior' }),
} as AnyNode
})
return nodes
}

describe('wall assemblies in IFC', () => {
for (const flip of [false, true]) {
test(`writes one IFC layer per assembly layer, exterior side outward${flip ? ' (one wall reversed)' : ''}`, () => {
const nodes = exteriorStuccoScene(flip)
const walls = Object.values(nodes).filter((n) => n.type === 'wall') as WallNode[]
expect(walls.every((w) => resolveWallExteriorSide(w) !== null)).toBe(true)

const { ifc } = buildIfcExport({ nodes, timestamp: new Date(Date.UTC(2026, 0, 1)) })
const stacks = [...wallLayers(ifc).values()]
expect(stacks).toHaveLength(walls.length)

const preset = getWallAssemblyPreset('exterior-2x6-stucco')!
const total = preset.assembly.layers.reduce((s, l) => s + l.thickness, 0)
for (const { setName, layers } of stacks) {
expect(setName).toBe(preset.label)
expect(layers.map((l) => l.material).sort()).toEqual(
preset.assembly.layers.map((l) => l.material ?? l.role).sort(),
)
expect(layers.reduce((s, l) => s + l.thickness, 0)).toBeCloseTo(total, 6)
expect(layers.find((l) => l.material === 'wood')?.category).toBe('LoadBearing')
}

// IFC layer 1 sits on Pascal's front face (+n), so the stack starts at
// the stucco when the front face is exterior and at the drywall when
// the back face is. Reversing one wall puts walls on both sides.
const sides = new Set(walls.map((w) => resolveWallExteriorSide(w)))
if (flip) expect(sides.size).toBe(2)
const firsts = stacks.map((s) => s.layers[0]!.material).sort()
const expected = walls
.map((w) => (resolveWallExteriorSide(w) === 1 ? 'stucco' : 'drywall'))
.sort()
expect(firsts).toEqual(expected)
})
}

test('an undetermined exterior side starts the stack at the back face, as core does', () => {
const nodes = exteriorStuccoScene(false)
for (const node of Object.values(nodes)) {
if (node.type === 'wall')
nodes[node.id] = { ...node, frontSide: 'unknown', backSide: 'unknown' }
}
const walls = Object.values(nodes).filter((n) => n.type === 'wall') as WallNode[]
expect(walls.every((w) => resolveWallExteriorSide(w) === null)).toBe(true)

const { ifc } = buildIfcExport({ nodes, timestamp: new Date(Date.UTC(2026, 0, 1)) })
for (const { layers } of wallLayers(ifc).values()) {
expect(layers[0]!.material).toBe('drywall')
}
})

test('air layers are ventilated as an IfcLogical', () => {
const nodes: Record<string, AnyNode> = roomWithOpenings()
const level = Object.values(nodes).find((n) => n.type === 'level')!
const preset = getWallAssemblyPreset('exterior-2x6-brick')!
for (const wall of wallsOn(nodes, level.id)) {
nodes[wall.id] = { ...wall, ...wallAssemblyPatch(preset.assembly) } as AnyNode
}
const { ifc } = buildIfcExport({ nodes, timestamp: new Date(Date.UTC(2026, 0, 1)) })
const airLayers = ifc.split('\n').filter((line) => /IFCMATERIALLAYER\(.*'air'/.test(line))
expect(airLayers.length).toBeGreaterThan(0)
for (const line of airLayers) expect(line).toContain('IFCLOGICAL(.T.)')
})

test('walls without an assembly keep the single core layer', () => {
const { ifc } = buildIfcExport({
nodes: roomWithOpenings(),
timestamp: new Date(Date.UTC(2026, 0, 1)),
})
for (const { setName, layers } of wallLayers(ifc).values()) {
expect(layers).toHaveLength(1)
expect(layers[0]!.material).toBe('Wall')
expect(setName).toMatch(/^Wall \d+ mm$/)
}
})
})