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
150 changes: 146 additions & 4 deletions packages/fragments/src/Utils/ifc-splitter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string>(wallCount).fill("IFCWALL"));

interface SinkState {
text: string;
closed: boolean;
Expand Down Expand Up @@ -96,6 +102,142 @@ 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.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);
Expand Down
88 changes: 70 additions & 18 deletions packages/fragments/src/Utils/ifc-splitter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ import { streamAsyncIterator } from "../ifc-stream";
// Exported interfaces
// ---------------------------------------------------------------------------

export interface IfcSplitterConfig {
/**
* @default {@link ELEMENT_TYPES}
*/
elementTypes?: string[];
/**
* @default {@link SPATIAL_TYPES}
*/
spatialTypes?: string[];
/**
* @see {@link listIdxByType}
* @returns the index of the argument to parse as a ref list
*/
listArgIndex?: (ifcType: string) => number | undefined;
}

interface IfcSplitterResolvedConfig {
/**
* @see {@link IfcSplitterConfig.elementTypes}
*/
elementTypes: Set<string>;
/**
* @see {@link IfcSplitterConfig.spatialTypes}
*/
spatialTypes: Set<string>;
/**
* @see {@link IfcSplitterConfig.listArgIndex}
*/
listArgIndex: (ifcType: string) => number | undefined;
}

export interface IfcSplitterIO {
/**
* @param path
Expand Down Expand Up @@ -123,7 +154,12 @@ interface RelEntry {
// ---------------------------------------------------------------------------
// IFC element categories we consider "splittable building elements"
// ---------------------------------------------------------------------------
const ELEMENT_TYPES: Set<string> = 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",
Expand Down Expand Up @@ -177,23 +213,30 @@ const ELEMENT_TYPES: Set<string> = new Set([
"IFCGEOGRAPHICELEMENT",
"IFCPROXY",
"IFCMECHANICALFASTENER",
]);
] as const);

const SPATIAL_TYPES: Set<string> = 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
* "related objects". Getting this wrong causes the rewriter to read the wrong
* 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;
Expand Down Expand Up @@ -459,11 +502,11 @@ function buildAggregateMap(
return { parentToChildren, childToParent, aggregateRelIds };
}

function traverseSpatialStructure(index: LineIndex) {
function traverseSpatialStructure(index: LineIndex, spatialTypes: Set<string>) {
const spatialIds = new Set<number>();
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<number>();
for (const sid of spatialIds) {
Expand Down Expand Up @@ -765,10 +808,16 @@ async function abortWriters(

export class IfcSplitter {
protected readonly io: IfcSplitterIO;
protected readonly config: IfcSplitterResolvedConfig;
protected readonly eventTarget: EventTarget;

constructor(ifcSplitterIO: IfcSplitterIO) {
constructor(ifcSplitterIO: IfcSplitterIO, config: IfcSplitterConfig = {}) {
this.io = ifcSplitterIO;
this.config = {
elementTypes: new Set(config.elementTypes ?? ELEMENT_TYPES),
spatialTypes: new Set(config.spatialTypes ?? SPATIAL_TYPES),
listArgIndex: config.listArgIndex ?? listIdxByType,
};
this.eventTarget = new EventTarget();
}

Expand Down Expand Up @@ -809,7 +858,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
Expand All @@ -824,7 +873,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
Expand All @@ -841,11 +890,14 @@ export class IfcSplitter {
const cluster = getCluster(eid, vfMap, aggMap);
const elementCluster = new Set<number>();
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)
Expand Down Expand Up @@ -879,8 +931,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*)/);
Expand Down Expand Up @@ -1013,7 +1065,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
Expand All @@ -1026,7 +1078,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
Expand Down Expand Up @@ -1078,8 +1130,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;

Expand Down
Loading