From 52f72333bcfb51684822071f60e51593a1f2c2d3 Mon Sep 17 00:00:00 2001 From: Murat Gocmen <8277046+muratdbc@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:52:33 -0700 Subject: [PATCH 1/2] Export wall assembly layers to IFC; stop unpainted PDF shapes being filled IFC: walls with an assembly were written as one "Core" IfcMaterialLayer of the total thickness, so receiving tools (Revit, Archicad) never saw the stucco / sheathing / framing / drywall stack. Each assembly layer is now an IfcMaterialLayer with its material, thickness, role and IFC4 category (LoadBearing for the core, Insulation, Finish), ordered from the front face so an exterior-faced stack stays outward on both wall directions. The layer set takes the preset label. Walls without an assembly keep the single Core layer. PDF: paintStyledGeometry built a path for geometry with neither fill nor stroke (zone outlines) and never ended it, so the next fill (the walls) painted it too and rooms printed as solid wall-coloured blocks in floorplan and sheet exports. The unpainted path is now ended with 'n'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0129pCwGotaTiKv5JgBv8vaC --- .../floorplan-pdfkit-renderer.test.ts | 39 ++++++ .../floorplan/floorplan-pdfkit-renderer.ts | 4 + .../ifc-converter/src/export/scene-to-ifc.ts | 80 ++++++++++-- .../tests/export-wall-assembly.test.ts | 120 ++++++++++++++++++ 4 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 packages/ifc-converter/tests/export-wall-assembly.test.ts 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/scene-to-ifc.ts b/packages/ifc-converter/src/export/scene-to-ifc.ts index 91ee87cc11..8c011a137e 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, @@ -843,6 +845,47 @@ 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 + 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 +990,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' ? bool(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..80d2fff978 --- /dev/null +++ b/packages/ifc-converter/tests/export-wall-assembly.test.ts @@ -0,0 +1,120 @@ +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('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$/) + } + }) +}) From 3cdaf32b79ad928d5991c1a60df051354d220602 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 23:06:30 +0000 Subject: [PATCH 2/2] ifc-converter: reverse unknown-side exterior stacks; write IsVentilated as IfcLogical An exterior-faced assembly on a wall whose exterior side is undetermined now starts at the back face, matching core's `?? -1` fallback in wall-layer-bands, so IFC layers sit on the same faces as the 3D view. IfcMaterialLayer.IsVentilated is an IfcLogical in IFC4; air layers now emit IFCLOGICAL(.T.) instead of IFCBOOLEAN(.T.). Co-authored-by: Murat Gocmen <8277046+muratdbc@users.noreply.github.com> --- .../ifc-converter/src/export/ifc-model.ts | 1 + .../ifc-converter/src/export/scene-to-ifc.ts | 6 ++-- .../tests/export-wall-assembly.test.ts | 28 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) 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 8c011a137e..7365772693 100644 --- a/packages/ifc-converter/src/export/scene-to-ifc.ts +++ b/packages/ifc-converter/src/export/scene-to-ifc.ts @@ -53,6 +53,7 @@ import { identifier, int, label, + logical, type PlanFrame, ratio, relativeFrame, @@ -877,7 +878,8 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult { 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 + const startsAtBack = + assembly.face === 'exterior' && (resolveWallExteriorSide(wall) ?? -1) === -1 const layers = startsAtBack ? [...assembly.layers].reverse() : assembly.layers const preset = getWallAssemblyPreset(assembly.presetId) return { @@ -1006,7 +1008,7 @@ export function buildIfcExport(input: IfcExportInput): IfcExportResult { 'IFCMATERIALLAYER', layerMaterial(l.material ?? l.role, category), l.thickness, - l.role === 'air' ? bool(true) : null, + l.role === 'air' ? logical(true) : null, l.id, l.role, category, diff --git a/packages/ifc-converter/tests/export-wall-assembly.test.ts b/packages/ifc-converter/tests/export-wall-assembly.test.ts index 80d2fff978..6851df6c9f 100644 --- a/packages/ifc-converter/tests/export-wall-assembly.test.ts +++ b/packages/ifc-converter/tests/export-wall-assembly.test.ts @@ -106,6 +106,34 @@ describe('wall assemblies in IFC', () => { }) } + 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(),