diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts index 40f16f01..d259a75e 100644 --- a/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/Property.ts @@ -92,6 +92,7 @@ export class IDSProperty extends IDSFacet { !( "value" in set._localId && "value" in set._category && + set.Name && "value" in set.Name ) ) { @@ -303,7 +304,7 @@ export class IDSProperty extends IDSFacet { const sets = await this.getPsets(item); const matchingSets = sets.filter((set) => { - if (!("value" in set.Name)) return false; + if (!(set.Name && "value" in set.Name)) return false; const result = this.evalRequirement( set.Name.value, this.propertySet, @@ -345,6 +346,7 @@ export class IDSProperty extends IDSFacet { } const matchingProps = props.filter((item) => { + if (!(item._category && item.Name)) return false; if (!("value" in item._category && "value" in item.Name)) return false; if (this._unsupportedTypes.includes(item._category.value)) { @@ -389,7 +391,7 @@ export class IDSProperty extends IDSFacet { private getPropertyListName(item: FRAGS.ItemData) { let propsListName: "HasProperties" | "Quantities" | undefined; - if (!("value" in item._category)) return propsListName; + if (!(item._category && "value" in item._category)) return propsListName; if (item._category.value === "IFCPROPERTYSET") propsListName = "HasProperties"; @@ -399,10 +401,33 @@ export class IDSProperty extends IDSFacet { return propsListName; } - private getValueKey(item: FRAGS.ItemData) { - return Object.keys(item).find( - (name) => /Value/.test(name) || /Values/.test(name), - ); + // Every value attribute of a property: NominalValue, ListValues, + // LowerBoundValue / UpperBoundValue / SetPointValue, EnumerationValues, + // DefiningValues / DefinedValues, and the quantities' *Value. Relations + // (arrays of items) and unset attributes are left out. + private getValueAttrs(item: FRAGS.ItemData) { + const attrs: { value: unknown; type?: string }[] = []; + for (const [name, attr] of Object.entries(item)) { + if (!/Values?$/.test(name) || !attr || Array.isArray(attr)) continue; + if (!("value" in attr) || attr.value === null || attr.value === undefined) + continue; + attrs.push(attr as { value: unknown; type?: string }); + } + return attrs; + } + + private describeValues(values: (string | number | boolean)[]) { + if (values.length === 0) return null; + if (values.length === 1) return values[0]; + return values.join(", "); + } + + // IDS: a property with several values (list, bounded, enumerated, table) + // matches when any one of them does. + private getValues(item: FRAGS.ItemData) { + return this.getValueAttrs(item).flatMap((attr) => + Array.isArray(attr.value) ? attr.value : [attr.value], + ) as (string | number | boolean)[]; } private getTypePsets(item: FRAGS.ItemData) { @@ -435,13 +460,13 @@ export class IDSProperty extends IDSFacet { const mergedTypeSets = new Set(); for (const definition of item.IsDefinedBy) { - if (!("value" in definition.Name)) continue; + if (!(definition.Name && "value" in definition.Name)) continue; const definitionName = definition.Name.value; const propsListName = this.getPropertyListName(definition); if (!(definitionName && propsListName)) continue; const typeSet = typeSets.find((set) => { - if (!("value" in set.Name)) return false; + if (!(set.Name && "value" in set.Name)) return false; return set.Name.value === definitionName; }); @@ -452,10 +477,10 @@ export class IDSProperty extends IDSFacet { Array.isArray(definition.HasProperties) ) { for (const prop of typeSet.HasProperties) { - if (!("value" in prop.Name)) continue; + if (!(prop.Name && "value" in prop.Name)) continue; const name = prop.Name.value; const existingProp = definition.HasProperties.find((p) => { - if (!("value" in p.Name)) return false; + if (!(p.Name && "value" in p.Name)) return false; return p.Name.value === name; }); if (!existingProp) definition.HasProperties.push(prop); @@ -476,67 +501,40 @@ export class IDSProperty extends IDSFacet { return sets; } - // IFCPROPERTYBOUNDEDVALUE are not supported yet - // IFCPROPERTYTABLEVALUE are not supported yet // TODO: Work must to be done to convert numerical value units to IDS-nominated standard units https://github.com/buildingSMART/IDS/blob/development/Documentation/UserManual/units.md private evalValue(item: FRAGS.ItemData, checks?: IDSCheck[]) { - const valueKey = this.getValueKey(item) as any; - const valueAttr = item[valueKey]; - if (!("value" in valueAttr)) return false; + const values = this.getValues(item); if (this.value) { - if (!valueKey) { - checks?.push({ - parameter: "Value", - currentValue: null, - pass: false, - requiredValue: this.value, - }); - return false; - } - - const facetValue = structuredClone(this.value); - // The IDS XML parser coerces numeric-looking simpleValues to JS numbers - // (e.g. "410.20257" becomes 410.20257). When the actual IFC value is a - // string (IFCTEXT, IFCIDENTIFIER, IFCLABEL, ...), a strict comparison - // against that number always fails, so align the facet value to a string - // whenever the property value is a string (issue #731). - if (typeof valueAttr.value === "string" && facetValue.type === "simple") { - facetValue.parameter = String(facetValue.parameter); + const facetValue = this.value; + const match = values.find((value) => { + const parameter = structuredClone(facetValue); + // The IDS XML parser coerces numeric-looking simpleValues to JS numbers + // (e.g. "410.20257" becomes 410.20257). When the actual IFC value is a + // string (IFCTEXT, IFCIDENTIFIER, IFCLABEL, ...), a strict comparison + // against that number always fails, so align the facet value to a + // string whenever the property value is a string (issue #731). + if (typeof value === "string" && parameter.type === "simple") { + parameter.parameter = String(parameter.parameter); + } + return this.evalRequirement(value, parameter, "Value"); + }); + const pass = match !== undefined; + if (checks) { + this.addCheckResult( + { + parameter: "Value", + currentValue: pass ? match : this.describeValues(values), + pass, + requiredValue: facetValue, + }, + checks, + ); } - - // if ( - // (item.type === WEBIFC.IFCPROPERTYLISTVALUE || - // item.type === WEBIFC.IFCPROPERTYENUMERATEDVALUE) && - // Array.isArray(valueAttr) - // ) { - // const values = valueAttr.map((value) => value.value); - // const matchingValue = valueAttr.find((value) => { - // if (!facetValue) return false; - // return this.evalRequirement(value.value, facetValue, "Value"); - // }); - - // checks?.push({ - // currentValue: values as any, - // pass: !!matchingValue, - // parameter: "Value", - // requiredValue: facetValue.parameter, - // }); - - // return !!matchingValue; - // } - - const result = this.evalRequirement( - valueAttr.value, - facetValue, - "Value", - checks, - ); - - return result; + return pass; } - if (!valueKey) return true; + if (values.length === 0) return true; // IDSDocs: Values with a logical unknown always fail // if (valueAttr.type === 3 && valueAttr.value === 2) { @@ -550,7 +548,7 @@ export class IDSProperty extends IDSFacet { // } // IDSDocs: An empty string is considered false - if (typeof valueAttr.value === "string" && valueAttr.value.trim() === "") { + if (values.every((v) => typeof v === "string" && v.trim() === "")) { checks?.push({ parameter: "Value", currentValue: "", @@ -565,51 +563,24 @@ export class IDSProperty extends IDSFacet { private evalDataType(item: FRAGS.ItemData, checks?: IDSCheck[]) { if (!this.dataType) return true; - const valueKey = this.getValueKey(item); - - if (!(valueKey && "value" in item[valueKey])) { - checks?.push({ - parameter: "DataType", - currentValue: null, - pass: false, - requiredValue: this.dataType, - }); - return false; - } - - const valueAttr = item[valueKey]; - - // TODO: Redo this - // if ( - // (item.type === WEBIFC.IFCPROPERTYLISTVALUE || - // item.type === WEBIFC.IFCPROPERTYENUMERATEDVALUE) && - // Array.isArray(valueAttr) && - // valueAttr[0] - // ) { - // const valueType = valueAttr[0].name; - // const result = this.evalRequirement( - // valueType, - // { - // type: "simple", - // parameter: this.dataType, - // }, - // "DataType", - // checks, - // ); - // return result; - // } - - const result = this.evalRequirement( - (valueAttr as any).type ?? null, - { - type: "simple", - parameter: this.dataType, - }, - "DataType", - checks, + const types = this.getValueAttrs(item).map((attr) => attr.type ?? null); + const dataType = { type: "simple" as const, parameter: this.dataType }; + const match = types.find((type) => + this.evalRequirement(type, dataType, "DataType"), ); - - return result; + const pass = match !== undefined; + if (checks) { + this.addCheckResult( + { + parameter: "DataType", + currentValue: pass ? match : types[0] ?? null, + pass, + requiredValue: dataType, + }, + checks, + ); + } + return pass; } private evalURI() { diff --git a/packages/core/src/openbim/IDSSpecifications/src/facets/property-values.test.ts b/packages/core/src/openbim/IDSSpecifications/src/facets/property-values.test.ts new file mode 100644 index 00000000..57ab3926 --- /dev/null +++ b/packages/core/src/openbim/IDSSpecifications/src/facets/property-values.test.ts @@ -0,0 +1,116 @@ +import * as FRAGS from "@thatopen/fragments"; +import * as WEBIFC from "web-ifc"; +import * as fs from "fs"; +import * as path from "path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Components } from "../../../../index"; +import { loadSourceBundle } from "../../../../../tests/source-bundle"; + +// Needs the real `Components`: see `loadSourceBundle`. +const OBC = await loadSourceBundle(); + +// `tests/fixtures/property-values.ifc`: wall 10 with a list (A, B), a bounded +// (1 to 5, set point 3), an enumerated (EXISTING, DEMOLISH, of NEW, EXISTING, +// DEMOLISH) and a table (K -> 2) property in Test_Values. +const WALL = 10; +const MULTI_VALUE_CLASSES = [ + WEBIFC.IFCPROPERTYLISTVALUE, + WEBIFC.IFCPROPERTYBOUNDEDVALUE, + WEBIFC.IFCPROPERTYENUMERATEDVALUE, + WEBIFC.IFCPROPERTYENUMERATION, + WEBIFC.IFCPROPERTYTABLEVALUE, +]; + +const convert = async (id: string, multiValue: boolean) => { + const importer = new FRAGS.IfcImporter(); + if (multiValue) { + for (const c of MULTI_VALUE_CLASSES) importer.classes.abstract.add(c); + } + 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", "property-values.ifc"), + ); + const bytes = await importer.process({ + bytes: new Uint8Array(ifc), + raw: true, + }); + return new FRAGS.SingleThreadedFragmentsModel(id, bytes, true); +}; + +describe("IDSProperty on list, bounded, enumerated and table values", () => { + let components: Components; + const models = new Map(); + + beforeAll(async () => { + // "plain": the importer's default classes, which leave these properties + // out, so the pset refers to items without data. + models.set("plain", await convert("plain", false)); + models.set("multi", await convert("multi", true)); + components = new OBC.Components(); + vi.spyOn( + components.get(OBC.FragmentsManager), + "list", + "get", + ).mockReturnValue(models as never); + }, 60_000); + + afterAll(() => { + vi.restoreAllMocks(); + components?.dispose(); + for (const model of models.values()) model.dispose(); + }); + + const passes = async ( + modelId: string, + baseName: string, + value?: string | number, + dataType?: string, + ) => { + const facet = new OBC.IDSProperty( + components, + { type: "simple", parameter: "Test_Values" }, + { type: "simple", parameter: baseName }, + ); + if (value !== undefined) facet.value = { type: "simple", parameter: value }; + if (dataType) facet.dataType = dataType; + const results = new FRAGS.DataMap< + string, + FRAGS.DataMap + >(); + await facet.test({ [modelId]: new Set([WALL]) }, results as never, { + skipIfFails: false, + }); + return results.get(modelId)!.get(WALL)!.pass; + }; + + it("does not throw on properties the importer left out; they fail as missing", async () => { + for (const name of ["Codes", "Range", "Status", "Lookup"]) { + expect(await passes("plain", name)).toBe(false); + } + }); + + it("passes when any value of the property matches", async () => { + expect(await passes("multi", "Codes", "B")).toBe(true); + expect(await passes("multi", "Codes", "C")).toBe(false); + expect(await passes("multi", "Range", 3)).toBe(true); + expect(await passes("multi", "Range", 5)).toBe(true); + expect(await passes("multi", "Range", 4)).toBe(false); + expect(await passes("multi", "Status", "DEMOLISH")).toBe(true); + // In the enumeration, but not a value of the property. + expect(await passes("multi", "Status", "NEW")).toBe(false); + expect(await passes("multi", "Lookup", "K")).toBe(true); + expect(await passes("multi", "Lookup", 2)).toBe(true); + }); + + it("checks the data type of the values", async () => { + expect(await passes("multi", "Range", undefined, "IFCLENGTHMEASURE")).toBe( + true, + ); + expect(await passes("multi", "Codes", undefined, "IFCLABEL")).toBe(true); + expect(await passes("multi", "Codes", undefined, "IFCTEXT")).toBe(false); + }); +}); diff --git a/packages/core/tests/fixtures/property-values.ifc b/packages/core/tests/fixtures/property-values.ifc new file mode 100644 index 00000000..b0a6dcd6 --- /dev/null +++ b/packages/core/tests/fixtures/property-values.ifc @@ -0,0 +1,20 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1'); +FILE_NAME('property-values.ifc','2026-10-11T00:00:00',(''),(''),'','',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('0YvctVUKr0kugbFTf53O9L',$,'Property values',$,$,$,$,$,#3); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#3=IFCUNITASSIGNMENT((#2)); +#10=IFCWALL('3ZYW59sxj8lei475l7EhLU',$,'Wall',$,$,$,$,$,$); +#20=IFCPROPERTYSET('2HlE6XRD5D2u$q88QiyvHI',$,'Test_Values',$,(#21,#22,#23,#25)); +#21=IFCPROPERTYLISTVALUE('Codes',$,(IFCLABEL('A'),IFCLABEL('B')),$); +#22=IFCPROPERTYBOUNDEDVALUE('Range',$,IFCLENGTHMEASURE(5.),IFCLENGTHMEASURE(1.),$,IFCLENGTHMEASURE(3.)); +#23=IFCPROPERTYENUMERATEDVALUE('Status',$,(IFCLABEL('EXISTING'),IFCLABEL('DEMOLISH')),#24); +#24=IFCPROPERTYENUMERATION('Status',(IFCLABEL('NEW'),IFCLABEL('EXISTING'),IFCLABEL('DEMOLISH')),$); +#25=IFCPROPERTYTABLEVALUE('Lookup',$,(IFCLABEL('K')),(IFCLENGTHMEASURE(2.)),$,$,$,$); +#30=IFCRELDEFINESBYPROPERTIES('0Ct3Bn6AT7C9JzD1QjCWxu',$,$,$,(#10),#20); +ENDSEC; +END-ISO-10303-21;