From a93f3c97e7bdb333c9b5661a91884c1643bbd46d Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 20 Aug 2026 16:11:30 +0300 Subject: [PATCH 1/3] feat(split): config --- .../fragments/src/Utils/ifc-splitter/index.ts | 52 ++++++++++++++----- .../fragments/src/Utils/ifc-splitter/node.ts | 29 ++++++----- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/packages/fragments/src/Utils/ifc-splitter/index.ts b/packages/fragments/src/Utils/ifc-splitter/index.ts index 46982fe8..290087a0 100644 --- a/packages/fragments/src/Utils/ifc-splitter/index.ts +++ b/packages/fragments/src/Utils/ifc-splitter/index.ts @@ -16,6 +16,22 @@ import { streamAsyncIterator } from "../ifc-stream"; // Exported interfaces // --------------------------------------------------------------------------- +export interface IfcSplitterConfig { + /** + * @default {@link ELEMENT_TYPES} + */ + elementTypes?: Set; + /** + * @default {@link SPATIAL_TYPES} + */ + spatialTypes?: Set; + /** + * @see {@link listIdxByType} + * @returns the index of the argument to parse as a ref list + */ + listArgIndex?: (ifcType: string) => number | undefined; +} + export interface IfcSplitterIO { /** * @param path @@ -459,11 +475,11 @@ function buildAggregateMap( return { parentToChildren, childToParent, aggregateRelIds }; } -function traverseSpatialStructure(index: LineIndex) { +function traverseSpatialStructure(index: LineIndex, spatialTypes: Set) { const spatialIds = new Set(); for (let id = 0; id <= index.maxId; id++) { const type = index.getType(id); - if (type && SPATIAL_TYPES.has(type)) spatialIds.add(id); + if (type && spatialTypes.has(type)) spatialIds.add(id); } const sharedIds = new Set(); for (const sid of spatialIds) { @@ -765,10 +781,17 @@ async function abortWriters( export class IfcSplitter { protected readonly io: IfcSplitterIO; + protected readonly config: Required; protected readonly eventTarget: EventTarget; - constructor(ifcSplitterIO: IfcSplitterIO) { + constructor(ifcSplitterIO: IfcSplitterIO, config?: IfcSplitterConfig) { this.io = ifcSplitterIO; + this.config = { + elementTypes: ELEMENT_TYPES, + spatialTypes: SPATIAL_TYPES, + listArgIndex: listIdxByType, + ...config, + }; this.eventTarget = new EventTarget(); } @@ -809,7 +832,7 @@ export class IfcSplitter { // 2. Identify spatial structure (shared in all files) const spatialStart = performance.now(); - const sharedIds = traverseSpatialStructure(index); + const sharedIds = traverseSpatialStructure(index, this.config.spatialTypes); this.emitProgressEvent("spatial", spatialStart); // 3. Build void/fill coupling map @@ -824,7 +847,7 @@ export class IfcSplitter { // 4. Identify all building elements const classifyStart = performance.now(); - const allElementIds = index.getAll(ELEMENT_TYPES); + const allElementIds = index.getAll(this.config.elementTypes); this.emitProgressEvent("classify", classifyStart); // 4b. Build aggregation map @@ -841,11 +864,14 @@ export class IfcSplitter { const cluster = getCluster(eid, vfMap, aggMap); const elementCluster = new Set(); for (const cid of cluster) { - if (allElementIds.has(cid)) elementCluster.add(cid); + if (allElementIds.has(cid)) { + elementCluster.add(cid); + assigned.add(cid); + } } clusters.push(elementCluster); - for (const cid of elementCluster) assigned.add(cid); } + assigned.clear(); this.emitProgressEvent("cluster", clusterStart); // 6. Distribute clusters into N groups (greedy bin packing) @@ -879,8 +905,8 @@ export class IfcSplitter { const argsStr = extractArgsString(raw); if (!argsStr) continue; const args = splitIfcArgs(argsStr); - const listIdx = listIdxByType(type); - if (args.length <= listIdx) continue; + const listIdx = this.config.listArgIndex(type) ?? -1; + if (listIdx < 0 || args.length <= listIdx) continue; const listRefs = extractRefs(args[listIdx]); if (listRefs.length === 0) continue; const idMatch = raw!.match(/^(#\d+\s*=\s*)/); @@ -1013,7 +1039,7 @@ export class IfcSplitter { // 2. Identify spatial structure (shared) const spatialStart = performance.now(); - const sharedIds = traverseSpatialStructure(index); + const sharedIds = traverseSpatialStructure(index, this.config.spatialTypes); this.emitProgressEvent("spatial", spatialStart); // 3. Build maps @@ -1026,7 +1052,7 @@ export class IfcSplitter { this.emitProgressEvent("style-maps", styleMapsStart); const classifyStart = performance.now(); - const allElementIds = index.getAll(ELEMENT_TYPES); + const allElementIds = index.getAll(this.config.elementTypes); this.emitProgressEvent("classify", classifyStart); // 4. Cluster: expand void/fill + aggregation for requested elements @@ -1078,8 +1104,8 @@ export class IfcSplitter { const argsStr = extractArgsString(raw); if (!argsStr) continue; const args = splitIfcArgs(argsStr); - const listIdx = listIdxByType(type); - if (args.length <= listIdx) continue; + const listIdx = this.config.listArgIndex(type) ?? -1; + if (listIdx < 0 || args.length <= listIdx) continue; const listRefs = extractRefs(args[listIdx]); if (listRefs.length === 0) continue; diff --git a/packages/fragments/src/Utils/ifc-splitter/node.ts b/packages/fragments/src/Utils/ifc-splitter/node.ts index 799f164e..0a475820 100644 --- a/packages/fragments/src/Utils/ifc-splitter/node.ts +++ b/packages/fragments/src/Utils/ifc-splitter/node.ts @@ -4,22 +4,25 @@ import { mkdir, open } from "node:fs/promises"; import { dirname } from "node:path"; import { Writable } from "node:stream"; import { IfcDecoderStream } from "../ifc-stream"; -import { IfcSplitter } from "./index"; +import { IfcSplitter, IfcSplitterConfig } from "./index"; export class IfcSplitterNode extends IfcSplitter { - constructor() { - super({ - readableStream: async (path) => - (await openAsBlob(path, { type: "text/plain" })) - .stream() - .pipeThrough(new IfcDecoderStream()), + constructor(config?: IfcSplitterConfig) { + super( + { + readableStream: async (path) => + (await openAsBlob(path, { type: "text/plain" })) + .stream() + .pipeThrough(new IfcDecoderStream()), - writableStream: async (path) => { - await mkdir(dirname(path), { recursive: true }); - const fileHandle = await open(path, "w"); - const nodeWritable = fileHandle.createWriteStream(); - return Writable.toWeb(nodeWritable) as WritableStream; + writableStream: async (path) => { + await mkdir(dirname(path), { recursive: true }); + const fileHandle = await open(path, "w"); + const nodeWritable = fileHandle.createWriteStream(); + return Writable.toWeb(nodeWritable) as WritableStream; + }, }, - }); + config, + ); } } From fdd0af4ae998a8236ab278f448bf3b0d0a58a6c6 Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 10 Sep 2026 14:32:46 +0300 Subject: [PATCH 2/3] feat(split): export config defaults; harden config merge Review follow-up on #268: - export ELEMENT_TYPES, SPATIAL_TYPES and listIdxByType so consumers can extend the defaults instead of only replacing them - {@link listIdxByType} in IfcSplitterConfig now resolves - merge the config field by field, so an explicit `undefined` can no longer overwrite a default and poison Required - cover the merge and each option's effect on the output with tests Co-Authored-By: Claude Opus 5 (1M context) --- .../src/Utils/ifc-splitter/index.test.ts | 162 +++++++++++++++++- .../fragments/src/Utils/ifc-splitter/index.ts | 52 ++++-- 2 files changed, 197 insertions(+), 17 deletions(-) diff --git a/packages/fragments/src/Utils/ifc-splitter/index.test.ts b/packages/fragments/src/Utils/ifc-splitter/index.test.ts index 7909028b..285e0b80 100644 --- a/packages/fragments/src/Utils/ifc-splitter/index.test.ts +++ b/packages/fragments/src/Utils/ifc-splitter/index.test.ts @@ -2,11 +2,15 @@ import { readFile } from "fs/promises"; import * as path from "path"; import { expect, test, vi } from "vitest"; import { + ELEMENT_TYPES, IfcSplitter, + IfcSplitterConfig, IfcSplitterGroupsEvent, IfcSplitterIO, IfcSplitterProgressEvent, IfcSplitterWarningEvent, + listIdxByType, + SPATIAL_TYPES, } from "."; import { SingleThreadedFragmentsModel } from "../../FragmentsModels"; import { IfcImporter } from "../../Importers"; @@ -23,20 +27,22 @@ const assetDir = path.resolve( const webIfcDir = path.dirname(import.meta.resolve("web-ifc")); -const syntheticIfcWithWalls = (wallCount: number) => +const syntheticIfc = (types: string[]) => [ "ISO-10303-21;", "HEADER;", "ENDSEC;", "DATA;", - ...Array.from( - { length: wallCount }, - (_, i) => `#${i + 1}=IFCWALL('guid${i + 1}',$,$,$,$,$,$,$);`, + ...types.map( + (type, i) => `#${i + 1}=${type}('guid${i + 1}',$,$,$,$,$,$,$);`, ), "ENDSEC;", "END-ISO-10303-21;", ].join("\n"); +const syntheticIfcWithWalls = (wallCount: number) => + syntheticIfc(new Array(wallCount).fill("IFCWALL")); + interface SinkState { text: string; closed: boolean; @@ -96,6 +102,154 @@ class MemoryIO implements IfcSplitterIO { } } +/** The config the constructor merged with the defaults */ +const mergedConfigOf = (config?: IfcSplitterConfig) => + // protected field + // eslint-disable-next-line dot-notation + new IfcSplitter(new MemoryIO(""), config)["config"]; + +test.each<[string, readonly string[]]>([ + ["ELEMENT_TYPES", ELEMENT_TYPES], + ["SPATIAL_TYPES", SPATIAL_TYPES], +])("%s is frozen", (_, types) => { + const mutable = types as string[]; + const before = [...types]; + + expect(Object.isFrozen(types)).toBe(true); + // Modules are strict mode, so a write to a frozen array throws instead of + // failing silently. + expect(() => mutable.push("IFCMYELEMENT")).toThrow(TypeError); + expect(() => { + mutable[0] = "IFCMYELEMENT"; + }).toThrow(TypeError); + expect(() => mutable.pop()).toThrow(TypeError); + expect(types).toEqual(before); +}); + +test("the merged config copies the type arrays instead of aliasing them", () => { + const elementTypes = ["IFCANNOTATION"]; + + const merged = mergedConfigOf({ elementTypes }); + merged.elementTypes.add("IFCWALL"); + merged.spatialTypes.clear(); + + // A frozen default would have thrown; a caller's array would have grown. + expect(elementTypes).toEqual(["IFCANNOTATION"]); + expect(mergedConfigOf().spatialTypes).toEqual(new Set(SPATIAL_TYPES)); +}); + +test.each<[string, IfcSplitterConfig | undefined]>([ + ["is omitted", undefined], + ["is empty", {}], + [ + "declares its fields out as undefined", + { + elementTypes: undefined, + spatialTypes: undefined, + listArgIndex: undefined, + }, + ], +])("config falls back to the defaults when it %s", (_, config) => { + const merged = mergedConfigOf(config); + + expect(merged.elementTypes).toEqual(new Set(ELEMENT_TYPES)); + expect(merged.spatialTypes).toEqual(new Set(SPATIAL_TYPES)); + expect(merged.listArgIndex).toBe(listIdxByType); +}); + +test("config overrides only the fields it declares", () => { + const elementTypes = ["IFCANNOTATION"]; + const listArgIndex = () => 1; + + const merged = mergedConfigOf({ elementTypes, listArgIndex }); + + expect(merged.elementTypes).toEqual(new Set(elementTypes)); + expect(merged.listArgIndex).toBe(listArgIndex); + expect(merged.spatialTypes).toEqual(new Set(SPATIAL_TYPES)); +}); + +// The merged config has to actually reach the passes that use it, so each +// option is checked against the lines it puts in (or keeps out of) the output. +const linesOf = (state: SinkState | undefined) => + [...state!.text.matchAll(/^#\d+=\w+/gm)].map(([line]) => line); + +test("elementTypes decides what counts as a splittable element", async () => { + const source = syntheticIfc(["IFCWALL", "IFCANNOTATION"]); + const [byDefault, extended] = await Promise.all( + [undefined, { elementTypes: ["IFCANNOTATION"] }].map(async (config) => { + const io = new MemoryIO(source); + await new IfcSplitter(io, config).split("in.ifc", 1, () => "out.ifc"); + return linesOf(io.sinks.get("out.ifc")); + }), + ); + + expect(byDefault).toEqual(["#1=IFCWALL"]); + expect(extended).toEqual(["#2=IFCANNOTATION"]); +}); + +test("spatialTypes decides what is shared across every group", async () => { + const source = syntheticIfc(["IFCWALL", "IFCWALL", "IFCBUILDINGSTOREY"]); + const [byDefault, none] = await Promise.all( + [undefined, { spatialTypes: [] }].map(async (config) => { + const io = new MemoryIO(source); + await new IfcSplitter(io, config).split( + "in.ifc", + 2, + (groupId) => `out_${groupId}.ifc`, + ); + return [...io.sinks.values()].map(linesOf); + }), + ); + + expect(byDefault).toEqual([ + ["#1=IFCWALL", "#3=IFCBUILDINGSTOREY"], + ["#2=IFCWALL", "#3=IFCBUILDINGSTOREY"], + ]); + expect(none).toEqual([["#1=IFCWALL"], ["#2=IFCWALL"]]); +}); + +test("listArgIndex returning undefined skips the type entirely", async () => { + const source = [ + "ISO-10303-21;", + "HEADER;", + "ENDSEC;", + "DATA;", + "#1=IFCWALL('guid1',$,$,$,$,$,$,$);", + "#2=IFCPROPERTYSET('guid2',$,'Pset',$,(#3));", + "#3=IFCPROPERTYSINGLEVALUE('P',$,IFCLABEL('v'),$);", + "#4=IFCRELDEFINESBYPROPERTIES('guid4',$,$,$,(#1),#2);", + "ENDSEC;", + "END-ISO-10303-21;", + ].join("\n"); + const [byDefault, skipped] = await Promise.all( + [ + undefined, + { + // Delegating to the exported default for everything else. + listArgIndex: (ifcType: string) => + ifcType === "IFCRELDEFINESBYPROPERTIES" + ? undefined + : listIdxByType(ifcType), + }, + ].map(async (config) => { + const io = new MemoryIO(source); + await new IfcSplitter(io, config).split("in.ifc", 1, () => "out.ifc"); + return linesOf(io.sinks.get("out.ifc")); + }), + ); + + expect(byDefault).toEqual([ + "#1=IFCWALL", + "#2=IFCPROPERTYSET", + "#3=IFCPROPERTYSINGLEVALUE", + "#4=IFCRELDEFINESBYPROPERTIES", + ]); + expect( + skipped, + "Dropping the relation drops everything it pulled in", + ).toEqual(["#1=IFCWALL"]); +}); + test("split releases every output writer when the write pass fails", async () => { const io = new MemoryIO(syntheticIfcWithWalls(2), 2); const splitter = new IfcSplitter(io); diff --git a/packages/fragments/src/Utils/ifc-splitter/index.ts b/packages/fragments/src/Utils/ifc-splitter/index.ts index 290087a0..482bf5bd 100644 --- a/packages/fragments/src/Utils/ifc-splitter/index.ts +++ b/packages/fragments/src/Utils/ifc-splitter/index.ts @@ -20,11 +20,11 @@ export interface IfcSplitterConfig { /** * @default {@link ELEMENT_TYPES} */ - elementTypes?: Set; + elementTypes?: string[]; /** * @default {@link SPATIAL_TYPES} */ - spatialTypes?: Set; + spatialTypes?: string[]; /** * @see {@link listIdxByType} * @returns the index of the argument to parse as a ref list @@ -32,6 +32,21 @@ export interface IfcSplitterConfig { listArgIndex?: (ifcType: string) => number | undefined; } +interface IfcSplitterResolvedConfig { + /** + * @see {@link IfcSplitterConfig.elementTypes} + */ + elementTypes: Set; + /** + * @see {@link IfcSplitterConfig.spatialTypes} + */ + spatialTypes: Set; + /** + * @see {@link IfcSplitterConfig.listArgIndex} + */ + listArgIndex: (ifcType: string) => number | undefined; +} + export interface IfcSplitterIO { /** * @param path @@ -139,7 +154,12 @@ interface RelEntry { // --------------------------------------------------------------------------- // IFC element categories we consider "splittable building elements" // --------------------------------------------------------------------------- -const ELEMENT_TYPES: Set = new Set([ + +/** + * The default {@link IfcSplitterConfig.elementTypes}. + * Exported so it can be extended rather than replaced. + */ +export const ELEMENT_TYPES = Object.freeze([ "IFCWALL", "IFCWALLSTANDARDCASE", "IFCWALLELEMENTEDCASE", @@ -193,14 +213,18 @@ const ELEMENT_TYPES: Set = new Set([ "IFCGEOGRAPHICELEMENT", "IFCPROXY", "IFCMECHANICALFASTENER", -]); +] as const); -const SPATIAL_TYPES: Set = new Set([ +/** + * The default {@link IfcSplitterConfig.spatialTypes}. + * Exported so it can be extended rather than replaced. + */ +export const SPATIAL_TYPES = Object.freeze([ "IFCPROJECT", "IFCSITE", "IFCBUILDING", "IFCBUILDINGSTOREY", -]); +] as const); /** * Returns the argument index at which a given IFC type stores its list of @@ -208,8 +232,11 @@ const SPATIAL_TYPES: Set = new Set([ * field, end up with an empty list, and skip the line entirely — dropping all * its transitive dependencies (property sets, materials, styles, etc.) from * the split output. + * + * The default {@link IfcSplitterConfig.listArgIndex}. Exported so an override + * can delegate to it for the types it doesn't care about. */ -const listIdxByType = (type: string): number => { +export const listIdxByType = (type: string): number => { switch (type) { case "IFCRELAGGREGATES": return 5; @@ -781,16 +808,15 @@ async function abortWriters( export class IfcSplitter { protected readonly io: IfcSplitterIO; - protected readonly config: Required; + protected readonly config: IfcSplitterResolvedConfig; protected readonly eventTarget: EventTarget; - constructor(ifcSplitterIO: IfcSplitterIO, config?: IfcSplitterConfig) { + constructor(ifcSplitterIO: IfcSplitterIO, config: IfcSplitterConfig = {}) { this.io = ifcSplitterIO; this.config = { - elementTypes: ELEMENT_TYPES, - spatialTypes: SPATIAL_TYPES, - listArgIndex: listIdxByType, - ...config, + elementTypes: new Set(config.elementTypes ?? ELEMENT_TYPES), + spatialTypes: new Set(config.spatialTypes ?? SPATIAL_TYPES), + listArgIndex: config.listArgIndex ?? listIdxByType, }; this.eventTarget = new EventTarget(); } From b38816db65dddb1f52b85a745ae273936a3ecd2c Mon Sep 17 00:00:00 2001 From: ShaMan123 Date: Thu, 10 Sep 2026 15:40:14 +0300 Subject: [PATCH 3/3] rm dead test --- .../fragments/src/Utils/ifc-splitter/index.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/fragments/src/Utils/ifc-splitter/index.test.ts b/packages/fragments/src/Utils/ifc-splitter/index.test.ts index 285e0b80..f9fb6249 100644 --- a/packages/fragments/src/Utils/ifc-splitter/index.test.ts +++ b/packages/fragments/src/Utils/ifc-splitter/index.test.ts @@ -126,18 +126,6 @@ test.each<[string, readonly string[]]>([ expect(types).toEqual(before); }); -test("the merged config copies the type arrays instead of aliasing them", () => { - const elementTypes = ["IFCANNOTATION"]; - - const merged = mergedConfigOf({ elementTypes }); - merged.elementTypes.add("IFCWALL"); - merged.spatialTypes.clear(); - - // A frozen default would have thrown; a caller's array would have grown. - expect(elementTypes).toEqual(["IFCANNOTATION"]); - expect(mergedConfigOf().spatialTypes).toEqual(new Set(SPATIAL_TYPES)); -}); - test.each<[string, IfcSplitterConfig | undefined]>([ ["is omitted", undefined], ["is empty", {}],