diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts index e9c9613dda..3cee28dbe2 100644 --- a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts @@ -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', diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts index 8b5bd1149d..5b2dc8293d 100644 --- a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts @@ -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 { diff --git a/packages/ifc-converter/src/export/ifc-model.ts b/packages/ifc-converter/src/export/ifc-model.ts index 821ff6483a..a55a5ecec3 100644 --- a/packages/ifc-converter/src/export/ifc-model.ts +++ b/packages/ifc-converter/src/export/ifc-model.ts @@ -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 } diff --git a/packages/ifc-converter/src/export/scene-to-ifc.ts b/packages/ifc-converter/src/export/scene-to-ifc.ts index 91ee87cc11..7365772693 100644 --- a/packages/ifc-converter/src/export/scene-to-ifc.ts +++ b/packages/ifc-converter/src/export/scene-to-ifc.ts @@ -12,6 +12,7 @@ import { getLevelElevations, getOpeningWallCut, getRenderableSlabPolygon, + getWallAssemblyPreset, getWallBodyCenterOffset, getWallCurveFrameAt, getWallCurveLength, @@ -27,6 +28,7 @@ import { measureStair, prepareSlabPolygonContext, resolveCeilingHeight, + resolveWallExteriorSide, type SlabNode, type StairNode, type StairSegmentNode, @@ -51,6 +53,7 @@ import { identifier, int, label, + logical, type PlanFrame, ratio, relativeFrame, @@ -843,6 +846,48 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult { const layerUsages = new Map() const wallMaterial = step.add('IFCMATERIAL', 'Wall', null, null) + // One IfcMaterial per (material, role) used by an assembly layer. + const layerMaterials = new Map() + 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 + const preset = getWallAssemblyPreset(assembly.presetId) + return { + name: preset?.label ?? null, + layers: layers.filter((layer) => layer.thickness > 0), + } + } + const wallsByLevel = new Map() for (const wall of ofType('wall')) if (wall.parentId) pushTo(wallsByLevel, wall.parentId, wall) @@ -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 = { diff --git a/packages/ifc-converter/tests/export-wall-assembly.test.ts b/packages/ifc-converter/tests/export-wall-assembly.test.ts new file mode 100644 index 0000000000..6851df6c9f --- /dev/null +++ b/packages/ifc-converter/tests/export-wall-assembly.test.ts @@ -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 { + const modelID = api.OpenModel(new TextEncoder().encode(ifc)) + try { + const out = new Map() + 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 = 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 = 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$/) + } + }) +})