From 272386874dc79cd9bd53ba38006d08422f832d82 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:17:07 +1030 Subject: [PATCH 1/2] fix(ids): handle entities without a PredefinedType attribute --- .../IDSSpecifications/src/facets/Entity.ts | 12 ++- .../src/facets/entity.test.ts | 100 ++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts index 9e4f7293d..292c9e420 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts @@ -139,13 +139,16 @@ export class IDSEntity extends IDSFacet { checks?: IDSCheck[], ) { if (!this.predefinedType) return null; - if (!("value" in itemData.PredefinedType)) return null; const isRequirementUserDefined = typeof this.predefinedType.parameter === "string" && this.predefinedType.parameter === "USERDEFINED"; - let value = itemData.PredefinedType.value; + // Some entities (e.g. IfcSite in IFC4) have no PredefinedType attribute + // at all; their item data has no such key. Treat that as "no value", so + // the requirement fails instead of throwing. + const own = itemData.PredefinedType; + let value = own && "value" in own ? own.value : undefined; // TODO: Do not remember what is this for if (value === "USERDEFINED" && !isRequirementUserDefined) { @@ -168,7 +171,10 @@ export class IDSEntity extends IDSFacet { }); if (Array.isArray(data.IsTypedBy)) { const typeAttrs = data.IsTypedBy[0]; - if (typeAttrs && "value" in typeAttrs.PredefinedType) { + if ( + typeAttrs?.PredefinedType && + "value" in typeAttrs.PredefinedType + ) { value = typeAttrs.PredefinedType.value; if (value === "USERDEFINED" && !isRequirementUserDefined) { const attrNames = Object.keys(typeAttrs); diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts new file mode 100644 index 000000000..b1c2209f9 --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts @@ -0,0 +1,100 @@ +import * as FRAGS from "@thatopen/fragments"; +import * as fs from "fs"; +import * as path from "path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Components, ModelIdMap } from "../../../../index"; +import { loadSourceBundle } from "../../../../../tests/source-bundle"; + +// Needs the real `Components`: see `loadSourceBundle`. +const OBC = await loadSourceBundle(); + +const MODEL_ID = "type-psets"; + +// `tests/fixtures/type-psets.ifc`: an IfcSite (no PredefinedType attribute in +// IFC4) and walls whose PredefinedType is STANDARD (see property.test.ts). +const WALLS = [40, 41, 42, 43, 45, 46]; + +const convert = async () => { + const importer = new FRAGS.IfcImporter(); + const root = path.resolve(__dirname, "../../../../.."); + importer.wasm = { + path: `${path.resolve(root, "../../node_modules/web-ifc")}/`, + absolute: true, + }; + const ifc = fs.readFileSync( + path.join(root, "tests", "fixtures", "type-psets.ifc"), + ); + const bytes = await importer.process({ + bytes: new Uint8Array(ifc), + raw: true, + }); + return new FRAGS.SingleThreadedFragmentsModel(MODEL_ID, bytes, true); +}; + +describe("IDSEntity predefinedType", () => { + let components: Components; + let model: FRAGS.SingleThreadedFragmentsModel; + + beforeAll(async () => { + model = await convert(); + components = new OBC.Components(); + const fragments = components.get(OBC.FragmentsManager); + vi.spyOn(fragments, "list", "get").mockReturnValue( + new Map([[MODEL_ID, model]]) as never, + ); + }, 60_000); + + afterAll(() => { + vi.restoreAllMocks(); + components?.dispose(); + model?.dispose(); + }); + + const entity = (name: string, predefinedType: string) => { + const facet = new OBC.IDSEntity(components, { + type: "simple", + parameter: name, + }); + facet.predefinedType = { type: "simple", parameter: predefinedType }; + return facet; + }; + + const select = async (facet: InstanceType) => { + const collector: ModelIdMap = {}; + await facet.getEntities([new RegExp(MODEL_ID)], collector); + return [...(collector[MODEL_ID] ?? [])].sort((a, b) => a - b); + }; + + const passes = async ( + facet: InstanceType, + ids: number[], + ) => { + const results = new FRAGS.DataMap< + string, + FRAGS.DataMap + >(); + await facet.test({ [MODEL_ID]: new Set(ids) }, results as never, { + skipIfFails: false, + }); + return Object.fromEntries( + [...results.get(MODEL_ID)!.entries()].map(([id, r]) => [id, r.pass]), + ); + }; + + it("does not select an entity that has no PredefinedType attribute", async () => { + expect(await select(entity("IFCSITE", "USERDEFINED"))).toEqual([]); + }); + + it("fails, rather than throws, as a requirement on such an entity", async () => { + const sites = + (await model.getItemsOfCategories([/^IFCSITE$/])).IFCSITE ?? []; + expect(sites.length).toBe(1); + expect(await passes(entity("IFCSITE", "USERDEFINED"), sites)).toEqual({ + [sites[0]]: false, + }); + }); + + it("still matches entities that have the predefined type", async () => { + expect(await select(entity("IFCWALL", "STANDARD"))).toEqual(WALLS); + }); +}); From 358a9018fb97ff4faf4ed903321ff40529dad826 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:24:41 +1030 Subject: [PATCH 2/2] fix(ids): find the element's type through IsDefinedBy when inheriting its predefined type --- .../IDSSpecifications/src/facets/Entity.ts | 20 ++++++-- .../src/facets/entity.test.ts | 44 +++++++++++++----- .../tests/fixtures/type-predefinedtype.ifc | 46 +++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 packages/core/tests/fixtures/type-predefinedtype.ifc diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts index 292c9e420..4ad074abc 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Entity.ts @@ -167,10 +167,24 @@ export class IDSEntity extends IDSFacet { const model = fragments.list.get(modelId); if (model && "value" in itemData._localId) { const [data] = await model.getItemsData([itemData._localId.value], { - relations: { IsTypedBy: { attributes: true, relations: false } }, + relations: { + IsTypedBy: { attributes: true, relations: false }, + IsDefinedBy: { attributes: true, relations: false }, + }, }); - if (Array.isArray(data.IsTypedBy)) { - const typeAttrs = data.IsTypedBy[0]; + // The type is reached through IsTypedBy or, in models converted by + // IfcImporter, through IsDefinedBy (as in IDSProperty.getTypePsets). + const definitions = [ + ...(Array.isArray(data.IsTypedBy) ? data.IsTypedBy : []), + ...(Array.isArray(data.IsDefinedBy) ? data.IsDefinedBy : []), + ]; + const typeAttrs = definitions.find( + (definition) => + definition?._category && + "value" in definition._category && + /TYPE$/.test(String(definition._category.value)), + ); + if (typeAttrs) { if ( typeAttrs?.PredefinedType && "value" in typeAttrs.PredefinedType diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts index b1c2209f9..d8242fae6 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/entity.test.ts @@ -12,35 +12,42 @@ const MODEL_ID = "type-psets"; // `tests/fixtures/type-psets.ifc`: an IfcSite (no PredefinedType attribute in // IFC4) and walls whose PredefinedType is STANDARD (see property.test.ts). +// `type-predefinedtype.ifc` is the same file with wall #40's PredefinedType +// unset ($); its type, IfcWallType #52, is STANDARD. +const INHERITED_ID = "type-predefinedtype"; const WALLS = [40, 41, 42, 43, 45, 46]; -const convert = async () => { +const convert = async (fixture = "type-psets.ifc", modelId = MODEL_ID) => { const importer = new FRAGS.IfcImporter(); const root = path.resolve(__dirname, "../../../../.."); importer.wasm = { path: `${path.resolve(root, "../../node_modules/web-ifc")}/`, absolute: true, }; - const ifc = fs.readFileSync( - path.join(root, "tests", "fixtures", "type-psets.ifc"), - ); + const ifc = fs.readFileSync(path.join(root, "tests", "fixtures", fixture)); const bytes = await importer.process({ bytes: new Uint8Array(ifc), raw: true, }); - return new FRAGS.SingleThreadedFragmentsModel(MODEL_ID, bytes, true); + return new FRAGS.SingleThreadedFragmentsModel(modelId, bytes, true); }; describe("IDSEntity predefinedType", () => { let components: Components; let model: FRAGS.SingleThreadedFragmentsModel; + let inherited: FRAGS.SingleThreadedFragmentsModel; + beforeAll(async () => { model = await convert(); + inherited = await convert("type-predefinedtype.ifc", INHERITED_ID); components = new OBC.Components(); const fragments = components.get(OBC.FragmentsManager); vi.spyOn(fragments, "list", "get").mockReturnValue( - new Map([[MODEL_ID, model]]) as never, + new Map([ + [MODEL_ID, model], + [INHERITED_ID, inherited], + ]) as never, ); }, 60_000); @@ -48,6 +55,7 @@ describe("IDSEntity predefinedType", () => { vi.restoreAllMocks(); components?.dispose(); model?.dispose(); + inherited?.dispose(); }); const entity = (name: string, predefinedType: string) => { @@ -59,25 +67,29 @@ describe("IDSEntity predefinedType", () => { return facet; }; - const select = async (facet: InstanceType) => { + const select = async ( + facet: InstanceType, + modelId = MODEL_ID, + ) => { const collector: ModelIdMap = {}; - await facet.getEntities([new RegExp(MODEL_ID)], collector); - return [...(collector[MODEL_ID] ?? [])].sort((a, b) => a - b); + await facet.getEntities([new RegExp(`^${modelId}$`)], collector); + return [...(collector[modelId] ?? [])].sort((a, b) => a - b); }; const passes = async ( facet: InstanceType, ids: number[], + modelId = MODEL_ID, ) => { const results = new FRAGS.DataMap< string, FRAGS.DataMap >(); - await facet.test({ [MODEL_ID]: new Set(ids) }, results as never, { + await facet.test({ [modelId]: new Set(ids) }, results as never, { skipIfFails: false, }); return Object.fromEntries( - [...results.get(MODEL_ID)!.entries()].map(([id, r]) => [id, r.pass]), + [...results.get(modelId)!.entries()].map(([id, r]) => [id, r.pass]), ); }; @@ -97,4 +109,14 @@ describe("IDSEntity predefinedType", () => { it("still matches entities that have the predefined type", async () => { expect(await select(entity("IFCWALL", "STANDARD"))).toEqual(WALLS); }); + + it("takes the predefined type from the element's type when its own is unset", async () => { + // IDS: an occurrence without its own PredefinedType inherits its type's. + expect(await select(entity("IFCWALL", "STANDARD"), INHERITED_ID)).toEqual( + WALLS, + ); + expect( + await passes(entity("IFCWALL", "STANDARD"), [40], INHERITED_ID), + ).toEqual({ 40: true }); + }); }); diff --git a/packages/core/tests/fixtures/type-predefinedtype.ifc b/packages/core/tests/fixtures/type-predefinedtype.ifc new file mode 100644 index 000000000..32f7d2e5c --- /dev/null +++ b/packages/core/tests/fixtures/type-predefinedtype.ifc @@ -0,0 +1,46 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1'); +FILE_NAME('type-predefinedtype.ifc','2026-09-28T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Project',$,$,$,$,(#12),#7); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#7=IFCUNITASSIGNMENT((#2)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCDIRECTION((0.,0.,1.)); +#10=IFCDIRECTION((1.,0.,0.)); +#11=IFCAXIS2PLACEMENT3D(#8,#9,#10); +#12=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#11,$); +#20=IFCSITE('1YvctVUKr0kugbFTf53O9L',$,'Site',$,$,#21,$,$,.ELEMENT.,$,$,$,$,$); +#21=IFCLOCALPLACEMENT($,#11); +#22=IFCRELAGGREGATES('2YvctVUKr0kugbFTf53O9L',$,$,$,#1,(#20)); +#30=IFCLOCALPLACEMENT(#21,#11); +#31=IFCCARTESIANPOINT((1.,1.,1.)); +#32=IFCBLOCK(#11,1.,1.,1.); +#33=IFCCSGSOLID(#32); +#34=IFCSHAPEREPRESENTATION(#12,'Body','CSG',(#33)); +#35=IFCPRODUCTDEFINITIONSHAPE($,$,(#34)); +#40=IFCWALL('3YvctVUKr0kugbFTf53O9L',$,'Typed wall A',$,$,#30,#35,$,$); +#41=IFCWALL('4YvctVUKr0kugbFTf53O9L',$,'Typed wall B',$,$,#30,#35,$,.STANDARD.); +#42=IFCWALL('5YvctVUKr0kugbFTf53O9L',$,'Instance pset wall',$,$,#30,#35,$,.STANDARD.); +#43=IFCWALL('6YvctVUKr0kugbFTf53O9L',$,'Bare wall',$,$,#30,#35,$,.STANDARD.); +#45=IFCWALL('DYvctVUKr0kugbFTf53O9L',$,'Typed wall overriding IsExternal',$,$,#30,#35,$,.STANDARD.); +#46=IFCWALL('EYvctVUKr0kugbFTf53O9L',$,'Typed wall with another prop in the same pset',$,$,#30,#35,$,.STANDARD.); +#44=IFCRELCONTAINEDINSPATIALSTRUCTURE('7YvctVUKr0kugbFTf53O9L',$,$,$,(#40,#41,#42,#43,#45,#46),#20); +#50=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$); +#51=IFCPROPERTYSET('8YvctVUKr0kugbFTf53O9L',$,'Pset_WallCommon',$,(#50)); +#52=IFCWALLTYPE('9YvctVUKr0kugbFTf53O9L',$,'Exterior type',$,$,(#51),$,$,$,.STANDARD.); +#53=IFCRELDEFINESBYTYPE('AYvctVUKr0kugbFTf53O9L',$,$,$,(#40,#41,#45,#46),#52); +#60=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.T.),$); +#61=IFCPROPERTYSET('BYvctVUKr0kugbFTf53O9L',$,'Pset_WallCommon',$,(#60)); +#62=IFCRELDEFINESBYPROPERTIES('CYvctVUKr0kugbFTf53O9L',$,$,$,(#42),#61); +#70=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.F.),$); +#71=IFCPROPERTYSET('FYvctVUKr0kugbFTf53O9L',$,'Pset_WallCommon',$,(#70)); +#72=IFCRELDEFINESBYPROPERTIES('GYvctVUKr0kugbFTf53O9L',$,$,$,(#45),#71); +#80=IFCPROPERTYSINGLEVALUE('LoadBearing',$,IFCBOOLEAN(.T.),$); +#81=IFCPROPERTYSET('HYvctVUKr0kugbFTf53O9L',$,'Pset_WallCommon',$,(#80)); +#82=IFCRELDEFINESBYPROPERTIES('IYvctVUKr0kugbFTf53O9L',$,$,$,(#46),#81); +ENDSEC; +END-ISO-10303-21;