Skip to content
Draft
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
171 changes: 149 additions & 22 deletions packages/core/src/openbim/IDSSpecifications/src/facets/PartOf.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,52 @@
import type * as FRAGS from "@thatopen/fragments";
import { Components } from "../../../../core/Components";
import { IDSFacet } from "./Facet";
import {
IDSFacetParameter,
IDSPartOfRelations,
IDSSimpleCardinality,
IDSItemCheckResult,
} from "../types";
import { IDSEntity } from "./Entity";
import { ModelIdMap } from "../../../../fragments";
import {
FragmentsManager,
ModelIdMap,
ModelIdDataMap,
} from "../../../../fragments";
import { getParameterXML } from "../exporters/parameter";
import { XML } from "../../../../utils/xml";

// https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/partof-facet.md
/** The inverse, child-to-parent names produced by the IFC importer. */
const relations: Record<string, string[]> = {
IFCRELAGGREGATES: ["Decomposes"],
IFCRELASSIGNSTOGROUP: ["HasAssignments"],
IFCRELCONTAINEDINSPATIALSTRUCTURE: ["ContainedInStructure"],
IFCRELNESTS: ["Nests"],
IFCRELVOIDSELEMENT: ["VoidsElements"],
IFCRELFILLSELEMENT: ["FillsVoids"],
"IFCRELVOIDSELEMENT IFCRELFILLSELEMENT": ["FillsVoids", "VoidsElements"],
};

/** IDS 1.0 transitive part-of matching, including non-geometric objects. */
export class IDSPartOf extends IDSFacet {
facetType = "PartOf" as const;

private _entityFacet: IDSEntity;

private _entity: {
name: IDSFacetParameter;
predefinedType?: IDSFacetParameter;
};

set entity(value: {
name: IDSFacetParameter;
predefinedType?: IDSFacetParameter;
}) {
this._entity = value;
const { name, predefinedType } = value;
this._entityFacet = new IDSEntity(this._components, name);
this._entityFacet.predefinedType = predefinedType;
this._entityFacet = new IDSEntity(this._components, value.name);
this._entityFacet.predefinedType = value.predefinedType;
}

get entity() {
return this._entity;
}

relation?: IDSPartOfRelations;

cardinality: IDSSimpleCardinality = "required";

constructor(
Expand All @@ -48,19 +59,135 @@ export class IDSPartOf extends IDSFacet {
this._entityFacet.predefinedType = entity.predefinedType;
}

serialize() {
return "";
serialize(type: "applicability" | "requirement" = "requirement") {
const relation = this.relation
? ` relation="${XML.escape(this.relation)}"`
: "";
const cardinality =
type === "requirement" ? ` cardinality="${this.cardinality}"` : "";
const instructions =
type === "requirement" && this.instructions
? ` instructions="${XML.escape(this.instructions)}"`
: "";
return `<partOf${relation}${cardinality}${instructions}><entity>${getParameterXML("Name", this.entity.name)}${getParameterXML("PredefinedType", this.entity.predefinedType)}</entity></partOf>`;
}

private async evaluate(items: ModelIdMap) {
const matchingParents: ModelIdMap = {};
await this._entityFacet.getEntities(
Object.keys(items).map(
(id) => new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`),
),
matchingParents,
);
const fragments = this._components.get(FragmentsManager);
const results: ModelIdMap = {};
const names = this.relation
? relations[this.relation]
: [...new Set(Object.values(relations).flat())];
if (!names)
throw new Error(`Unsupported IDS partOf relation: ${this.relation}`);
const options = {
attributesDefault: true,
relations: Object.fromEntries(
names.map((name) => [name, { attributes: true, relations: false }]),
),
};
for (const [modelId, ids] of Object.entries(items)) {
const model = fragments.list.get(modelId);
if (!model) continue;
const targets = matchingParents[modelId] ?? new Set<number>();
if (!targets.size) continue;
// Per evaluation: never retain stale relations after a model edit.
const cache = new Map<number, FRAGS.ItemData | undefined>();
const read = async (id: number) => {
if (!cache.has(id))
cache.set(id, (await model.getItemsData([id], options))[0]);
return cache.get(id);
};
for (const id of ids) {
const visited = new Set<number>([id]);
const pending = [id];
let found = false;
while (pending.length && !found) {
const item = await read(pending.pop()!);
if (!item) continue;
for (const name of names) {
const parents = item[name];
if (!Array.isArray(parents)) continue;
for (const parent of parents) {
const parentId = Array.isArray(parent._localId)
? undefined
: parent._localId?.value;
if (typeof parentId !== "number" || visited.has(parentId))
continue;
visited.add(parentId);
if (targets.has(parentId)) {
found = true;
break;
}
pending.push(parentId);
}
if (found) break;
}
}
if (found) (results[modelId] ??= new Set()).add(id);
}
}
return results;
}

async getEntities(_modelIds: RegExp[], _collector: ModelIdMap) {
// const fragments = this._components.get(FragmentsManager);
// const items: ModelIdMap = {};
// await this._entityFacet.getEntities(modelIds, items);
// for (const [modelId, localIds] of Object.entries(items)) {
// const isValidModel = modelIds.find((regex) => regex.test(modelId));
// if (!isValidModel) continue;
// }
async getEntities(modelIds: RegExp[], collector: ModelIdMap) {
const items: ModelIdMap = {};
for (const [id, model] of this._components.get(FragmentsManager).list) {
if (
!modelIds.some((regex) => {
regex.lastIndex = 0;
return regex.test(id);
})
)
continue;
items[id] = new Set(
Object.values(await model.getItemsOfCategories([/.*/])).flat(),
);
}
const matches = await this.evaluate(items);
for (const [id, ids] of Object.entries(matches)) {
collector[id] ??= new Set();
const target = collector[id];
for (const localId of ids) target.add(localId);
}
}

async test(_items: ModelIdMap) {}
async test(
items: ModelIdMap,
collector: ModelIdDataMap<IDSItemCheckResult>,
config = { skipIfFails: false },
) {
const matches = await this.evaluate(items);
for (const [modelId, ids] of Object.entries(items)) {
const model = this._components.get(FragmentsManager).list.get(modelId);
if (!model) continue;
const data = await model.getItemsData([...ids], {
attributesDefault: true,
});
for (const item of data) {
if (Array.isArray(item._localId)) continue;
const checks = this.getItemChecks(
collector,
modelId,
item,
config.skipIfFails,
);
if (!checks) continue;
const matched = matches[modelId]?.has(item._localId.value) ?? false;
checks.push({
parameter: "Entity",
requiredValue: this.entity.name,
currentValue: matched,
pass: this.cardinality === "prohibited" ? !matched : matched,
});
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as FRAGS from "@thatopen/fragments";
import * as WEBIFC from "web-ifc";
import * as fs from "fs";
import * as path from "path";
import { describe, expect, it, vi } from "vitest";
import { loadSourceBundle } from "../../../../../tests/source-bundle";

const OBC = await loadSourceBundle();
const root = path.resolve(__dirname, "../../../../..");
const fixtures = path.join(root, "tests/fixtures/ids-partof");
const files = fs
.readdirSync(fixtures)
.filter((name) => /^(pass|fail)-.*\.ids$/.test(name));
describe("IDSPartOf buildingSMART conformance", () => {
it.each(files)(
"%s",
async (name) => {
const importer = new FRAGS.IfcImporter();
importer.wasm = {
path: `${path.resolve(root, "../../node_modules/web-ifc")}/`,
absolute: true,
};
importer.includeRelationNames = true;
const ifc = fs.readFileSync(
path.join(fixtures, name.replace(/\.ids$/, ".ifc")),
"utf8",
);
for (const [, category] of ifc.matchAll(
/#\d+\s*=\s*(IFC[A-Z0-9_]+)\s*\(/g,
)) {
const code = WEBIFC[category as keyof typeof WEBIFC];
if (typeof code === "number") importer.classes.abstract.add(code);
}
for (const [code, child, parent] of [
[WEBIFC.IFCRELASSIGNSTOGROUP, "HasAssignments", "IsGroupedBy"],
[WEBIFC.IFCRELVOIDSELEMENT, "VoidsElements", "HasOpenings"],
[WEBIFC.IFCRELFILLSELEMENT, "FillsVoids", "HasFillings"],
] as const)
importer.relations.set(code, {
forRelated: child,
forRelating: parent,
});
const bytes = await importer.process({
bytes: new TextEncoder().encode(ifc),
raw: true,
});
const model = new FRAGS.SingleThreadedFragmentsModel(
"partof",
bytes,
true,
);
const components = new OBC.Components();
const spy = vi
.spyOn(components.get(OBC.FragmentsManager), "list", "get")
.mockReturnValue(new Map([["partof", model]]) as never);
try {
const manager = components.get(OBC.IDSSpecifications);
const specs = manager.load(
fs.readFileSync(path.join(fixtures, name), "utf8"),
);
let pass = true;
for (const spec of specs) {
const results = await spec.test([/^partof$/], { skipIfFails: false });
for (const items of results.values())
for (const item of items.values()) if (!item.pass) pass = false;
}
expect(pass).toBe(name.startsWith("pass-"));
const xml = manager.export({ title: "Part of" }, specs);
expect(xml).toContain("<partOf");
expect(manager.load(xml).length).toBe(specs.length);
} finally {
spy.mockRestore();
components.dispose();
model.dispose();
}
},
60_000,
);
});
3 changes: 2 additions & 1 deletion packages/core/src/openbim/IDSSpecifications/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type IDSPartOfRelations =
| "IFCRELCONTAINEDINSPATIALSTRUCTURE"
| "IFCRELNESTS"
| "IFCRELVOIDSELEMENT"
| "IFCRELFILLSELEMENT";
| "IFCRELFILLSELEMENT"
| "IFCRELVOIDSELEMENT IFCRELFILLSELEMENT";

// required must match
// prohibited mustn't match
Expand Down
6 changes: 6 additions & 0 deletions packages/core/tests/fixtures/ids-partof/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# LICENSE

(c) buildingSMART International Ltd.

This work is licensed under the Creative Commons Attribution-NoDerivatives 4.0 International License.
To view a copy of this license, visit [http://creativecommons.org/licenses/by-nd/4.0/](http://creativecommons.org/licenses/by-nd/4.0/).
8 changes: 8 additions & 0 deletions packages/core/tests/fixtures/ids-partof/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# buildingSMART IDS partOf fixtures

Copied unchanged from buildingSMART/IDS, commit `870f9c4e6e8f414e737b4d84ca1aa9b46fc6c8f3`,
`Documentation/ImplementersDocumentation/TestCases/partof`.

Source: https://github.com/buildingSMART/IDS/tree/870f9c4e6e8f414e737b4d84ca1aa9b46fc6c8f3/Documentation/ImplementersDocumentation/TestCases/partof

Copyright buildingSMART International and contributors. See LICENSE.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<ids xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/1.0/ids.xsd" xmlns="http://standards.buildingsmart.org/IDS">
<info>
<title>A group entity must match exactly 1/2</title>
<description>Generated via code automation in the Ids Repository on github.</description>
</info>
<specifications>
<specification name="A group entity must match exactly 1/2" ifcVersion="IFC2X3 IFC4">
<applicability maxOccurs="unbounded">
<entity>
<name>
<simpleValue>IFCELEMENTASSEMBLY</simpleValue>
</name>
</entity>
</applicability>
<requirements>
<partOf relation="IFCRELASSIGNSTOGROUP">
<entity>
<name>
<simpleValue>IFCGROUP</simpleValue>
</name>
</entity>
</partOf>
</requirements>
</specification>
</specifications>
</ids>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('','2022-10-07T13:48:44',(),(),'IfcOpenShell v0.7.0-dc67287d','IfcOpenShell v0.7.0-dc67287d','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCELEMENTASSEMBLY('1hqIFTRjfV6AWq_bMtnZwI',$,$,$,$,$,$,$,$,$);
#2=IFCINVENTORY('0eA6m4fELI9QBIhP3wiLAp',$,$,$,$,$,$,$,$,$,$);
#3=IFCRELASSIGNSTOGROUP('05rScmOVzMoQXOfbYdtLYj',$,$,$,(#1),$,#2);
ENDSEC;
END-ISO-10303-21;
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ids xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/1.0/ids.xsd" xmlns="http://standards.buildingsmart.org/IDS">
<info>
<title>A non aggregated element fails an aggregate relationship</title>
<description>Generated via code automation in the Ids Repository on github.</description>
</info>
<specifications>
<specification name="A non aggregated element fails an aggregate relationship" ifcVersion="IFC2X3 IFC4">
<applicability maxOccurs="unbounded">
<entity>
<name>
<simpleValue>IFCWALL</simpleValue>
</name>
</entity>
</applicability>
<requirements>
<partOf relation="IFCRELAGGREGATES">
<entity>
<name>
<xs:restriction base="xs:string">
<xs:pattern value=".*" />
</xs:restriction>
</name>
</entity>
</partOf>
</requirements>
</specification>
</specifications>
</ids>
Loading
Loading