diff --git a/src/ifctester/README.md b/src/ifctester/README.md new file mode 100644 index 0000000000..ae533658ae --- /dev/null +++ b/src/ifctester/README.md @@ -0,0 +1,3 @@ +# ifctester + +Author, test, and see reports from IDS audits on the command line, as a webapp, or as a library. diff --git a/src/ifctester/ifctester/__init__.py b/src/ifctester/ifctester/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/ifctester/ifctester/__main__.py b/src/ifctester/ifctester/__main__.py new file mode 100644 index 0000000000..8409d44080 --- /dev/null +++ b/src/ifctester/ifctester/__main__.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +# IfcTester - IDS based model auditing +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcTester. +# +# IfcTester is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcTester is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcTester. If not, see . + +import time +import argparse + +from . import ids +from . import reporter +import ifcopenshell + +parser = argparse.ArgumentParser(description="Uses an IDS to audit an IFC") +parser.add_argument("ids", type=str, help="Path to an IDS") +parser.add_argument("ifc", type=str, help="Path to an IFC") +parser.add_argument( + "-r", "--reporter", type=str, help="The reporting method to view audit results", default="Console" +) +args = parser.parse_args() + +start = time.time() +specs = ids.open(args.ids) +ifc = ifcopenshell.open(args.ifc) +print("Finished loading:", time.time() - start) +start = time.time() +specs.validate(ifc) +print("Finished validating:", time.time() - start) +start = time.time() +reporter.Console(specs).report() diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py new file mode 100644 index 0000000000..8118379599 --- /dev/null +++ b/src/ifctester/ifctester/ids.py @@ -0,0 +1,858 @@ +# IfcTester - IDS based model auditing +# Copyright (C) 2021 Artur Tomczak , Thomas Krijnen , Dion Moult +# +# This file is part of IfcTester. +# +# IfcTester is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcTester is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcTester. If not, see . + +import os +import re +import datetime +import builtins +import ifcopenshell.util.unit +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.classification +from xmlschema import XMLSchema +from xmlschema import etree_tostring +from xmlschema.validators import identities +from xml.etree import ElementTree as ET + + +cwd = os.path.dirname(os.path.realpath(__file__)) +schema = XMLSchema(os.path.join(cwd, "ids.xsd")) + + +def open(filepath): + """Use to open ids.xml files + + :param filepath: ids file path + :type filepath: str + :return: IDS file as a python object + """ + # schema.validate(filepath) + return Ids().parse( + schema.decode(filepath, strip_namespaces=True, namespaces={"": "http://standards.buildingsmart.org/IDS"}) + ) + + +class Ids: + def __init__( + self, + title="Untitled", + copyright=None, + version=None, + description=None, + author=None, + date=None, + purpose=None, + milestone=None, + ): + self.specifications = [] + self.info = {} + self.info["title"] = title or "Untitled" + if copyright: + self.info["copyright"] = copyright + if version: + self.info["version"] = version + if description: + self.info["description"] = description + if author and "@" in author: + self.info["author"] = author + if date: + try: + self.info["date"] = datetime.date.fromisoformat(date).isoformat() + except ValueError: + pass + if purpose: + self.info["purpose"] = purpose + if milestone: + self.info["milestone"] = milestone + + def asdict(self): + ids_dict = { + "@xmlns": "http://standards.buildingsmart.org/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/ids_05.xsd", + "info": self.info, + "specifications": {"specification": []}, + } + for spec in self.specifications: + ids_dict["specifications"]["specification"].append(spec.asdict()) + return ids_dict + + def parse(self, data): + for attribute in ["title", "copyright", "version", "description", "author"]: + value = data["info"].get(attribute) + if value: + self.info[attribute] = value + xml_specs = data["specifications"]["specification"] + if not isinstance(xml_specs, list): + xml_specs = [xml_specs] + for xml_spec in xml_specs: + spec = Specification() + spec.parse(xml_spec) + self.specifications.append(spec) + return self + + def to_string(self): + ns = {"": "http://standards.buildingsmart.org/IDS"} + return etree_tostring(schema.encode(self.asdict()), namespaces=ns) + + def to_xml(self, filepath="output.xml"): + ET.register_namespace("", "http://standards.buildingsmart.org/IDS") + ET.ElementTree(schema.encode(self.asdict())).write(filepath, encoding="utf-8", xml_declaration=True) + return schema.is_valid(filepath) + + def validate(self, ifc_file): + for specification in self.specifications: + specification.applicable_entities.clear() + specification.failed_entities = set() + for facet in specification.requirements: + facet.failed_entities.clear() + specification.status = None + + filtered_elements = {} + + for i, specification in enumerate(self.specifications): + if ifc_file.schema not in specification.ifcVersion: + continue + + elements = [] + for facet in specification.applicability: + elements = facet.filter(ifc_file, elements) + + for element in elements: + is_applicable = True + for facet in specification.applicability: + if isinstance(facet, Entity): + continue + if not bool(facet(element)): + is_applicable = False + break + if not is_applicable: + continue + specification.applicable_entities.append(element) + for facet in specification.requirements: + result = facet(element) + if not bool(result): + specification.failed_entities.add(element) + facet.failed_entities.append(element) + facet.failed_reasons.append(str(result)) + + for specification in self.specifications: + specification.status = True + if specification.failed_entities: + specification.status = False + elif specification.minOccurs != 0 and not specification.applicable_entities: + specification.status = False + elif len(specification.applicable_entities) > (specification.maxOccurs or 1): + specification.status = False + + +class Specification: + def __init__( + self, + name="Unnamed", + minOccurs=None, + maxOccurs=None, + ifcVersion=["IFC2X3", "IFC4"], + identifier=None, + description=None, + instructions=None, + ): + self.name = name or "Unnamed" + self.applicability = [] + self.requirements = [] + self.minOccurs = minOccurs + self.maxOccurs = maxOccurs + self.ifcVersion = ifcVersion + self.identifier = identifier + self.description = description + self.instructions = instructions + + self.applicable_entities = [] + self.status = None + + def asdict(self): + results = { + "@name": self.name, + "@ifcVersion": self.ifcVersion, + "applicability": {}, + "requirements": {}, + } + for attribute in ["identifier", "description", "instructions", "minOccurs", "maxOccurs"]: + value = getattr(self, attribute) + if value: + results[f"@{attribute}"] = value + for clause_type in ["applicability", "requirements"]: + clause = getattr(self, clause_type) + if not clause: + continue + for facet in clause: + facet_type = type(facet).__name__ + facet_type = facet_type[0].lower() + facet_type[1:] + if facet_type in results[clause_type]: + results[clause_type][facet_type].append(facet.asdict()) + else: + results[clause_type][facet_type] = [facet.asdict()] + return results + + def parse(self, ids_dict): + self.name = ids_dict.get("@name", "") + self.minOccurs = ids_dict["@minOccurs"] + self.maxOccurs = ids_dict["@maxOccurs"] + self.ifcVersion = ids_dict["@ifcVersion"] + self.applicability = self.parse_clause(ids_dict["applicability"]) + self.requirements = self.parse_clause(ids_dict["requirements"]) + return self + + def parse_clause(self, clause): + results = [] + for name, facets in clause.items(): + if name not in ["entity", "attribute", "classification", "partOf", "property", "material"]: + continue + if not isinstance(facets, list): + facets = [facets] + for facet_xml in facets: + facet = globals()[name.capitalize()]().parse(facet_xml) + results.append(facet) + return results + + +class Facet: + def __init__(self, *parameters): + self.failed_entities = [] + self.failed_reasons = [] + for i, name in enumerate(self.parameters): + setattr(self, name.replace("@", ""), parameters[i]) + + def asdict(self): + results = {} + for name in self.parameters: + value = getattr(self, name.replace("@", "")) + if value is not None: + results[name] = value if "@" in name else self.to_ids_value(value) + return results + + def parse(self, xml): + for name, value in xml.items(): + name = name.replace("@", "") + if isinstance(value, dict) and "simpleValue" in value.keys(): + setattr(self, name, value["simpleValue"]) + elif isinstance(value, dict) and "restriction" in value.keys(): + setattr(self, name, Restriction().parse(value["restriction"][0])) + # TODO handle more than one restriction: return [restriction(r) for r in v["restriction"]] + else: + setattr(self, name, value) + return self + + def filter(self, ifc_file, elements): + return [e for e in elements if self(e)] + + def to_string(self, clause_type): + if clause_type == "applicability": + templates = self.applicability_templates + elif clause_type == "requirement": + templates = self.requirement_templates + + for template in templates: + for key in self.parameters: + key = key.replace("@", "") + value = getattr(self, key) + key_variable = "{" + key + "}" + if value is not None and key_variable in template: + template = template.replace(key_variable, str(value)) + if "{" not in template: + return template + + def to_ids_value(self, parameter): + if isinstance(parameter, str): + parameter_dict = {"simpleValue": parameter} + elif isinstance(parameter, Restriction): + parameter_dict = {"xs:restriction": [parameter.asdict()]} + elif isinstance(parameter, list): + restrictions = {"@base": "xs:" + parameter[0].base} + for p in parameter: + x = p.asdict() + restrictions[list(x)[1]] = x[list(x)[1]] + parameter_dict = {"xs:restriction": [restrictions]} + else: + raise Exception(str(parameter) + " was not able to be converted into 'Parameter_dict'") + return parameter_dict + + +class Entity(Facet): + def __init__(self, name="IFCWALL", predefinedType=None, instructions=None): + self.parameters = ["name", "predefinedType", "@instructions"] + self.applicability_templates = [ + "All {name} data of type {predefinedType}", + "All {name} data", + ] + self.requirement_templates = [ + "Shall be {name} data of type {predefinedType}", + "Shall be {name} data", + ] + super().__init__(name, predefinedType, instructions) + + def filter(self, ifc_file, elements): + if isinstance(self.name, str): + results = ifc_file.by_type(self.name, include_subtypes=False) + else: + results = [] + ifc_classes = [t for t in ifc_file.wrapped_data.types() if t.upper() == self.name] + [results.append(ifc_file.by_type(ifc_class, include_subtypes=False)) for ifc_class in ifc_classes] + if self.predefinedType: + return [r for r in results if self(r)] + return results + + def __call__(self, inst, logger=None): + is_pass = inst.is_a().upper() == self.name + reason = None + + if not is_pass: + reason = {"type": "NAME", "actual": inst.is_a().upper()} + + if is_pass and self.predefinedType: + predefined_type = ifcopenshell.util.element.get_predefined_type(inst) + is_pass = predefined_type == self.predefinedType + + if not is_pass: + reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} + + return EntityResult(is_pass, reason) + + +class Attribute(Facet): + def __init__(self, name="Name", value=None, minOccurs=None, maxOccurs=None, instructions=None): + self.parameters = ["name", "value", "@minOccurs", "@maxOccurs", "@instructions"] + self.applicability_templates = [ + "Data where the {name} is {value}", + "Data where the {name} is provided", + ] + self.requirement_templates = [ + "The {name} shall be {value}", + "The {name} shall be provided", + ] + super().__init__(name, value, minOccurs, maxOccurs, instructions) + + def __call__(self, inst, logger=None): + def get_values(element, name): + if isinstance(name, str): + return [getattr(element, name, None)] + return [v for k, v in element.get_info().items() if k == name] + + element_type = ifcopenshell.util.element.get_type(inst) + + if isinstance(self.name, str): + type_value = getattr(element_type, self.name, None) if element_type else None + occurrence_value = getattr(inst, self.name, None) + names = [self.name] + values = [occurrence_value if occurrence_value is not None else type_value] + else: + if element_type: + info = element_type.get_info() + info.update({k: v for k, v in inst.get_info().items() if v is not None}) + else: + info = inst.get_info() + names = [] + values = [] + for k, v in info.items(): + if k == self.name: + names.append(k) + values.append(v) + + is_pass = bool(values) + reason = None + + if not is_pass: + reason = {"type": "NOVALUE"} + + if is_pass: + for i, value in enumerate(values): + if value is None: + is_pass = False + reason = {"type": "FALSEY", "actual": value} + elif value == "": + is_pass = False + reason = {"type": "FALSEY", "actual": value} + elif value == tuple(): + is_pass = False + reason = {"type": "FALSEY", "actual": value} + else: + argument_index = inst.wrapped_data.get_argument_index(names[i]) + try: + attribute_type = inst.attribute_type(argument_index) + if attribute_type == "LOGICAL" and value == "UNKNOWN": + is_pass = False + reason = {"type": "FALSEY", "actual": value} + except: + if names[i] in inst.wrapped_data.get_inverse_attribute_names(): + is_pass = False + reason = {"type": "INVALID"} + if not is_pass: + break + + if is_pass and self.value: + for value in values: + if isinstance(value, ifcopenshell.entity_instance): + is_pass = False + reason = {"type": "VALUE", "actual": value} + break + elif isinstance(self.value, str) and isinstance(value, str): + if value != self.value: + is_pass = False + reason = {"type": "VALUE", "actual": value} + break + elif isinstance(self.value, str): + cast_value = cast_to_value(self.value, value) + if value != cast_value: + is_pass = False + reason = {"type": "VALUE", "actual": value} + break + elif value != self.value: + is_pass = False + reason = {"type": "VALUE", "actual": value} + break + + return AttributeResult(is_pass, reason) + + +class Classification(Facet): + def __init__(self, value=None, system=None, uri=None, minOccurs=None, maxOccurs=None, instructions=None): + self.parameters = ["value", "system", "@uri", "@minOccurs", "@maxOccurs", "@instructions"] + self.applicability_templates = [ + "Data having a {system} reference of {value}", + "Data classified using {system}", + "Data classified as {value}", + ] + self.requirement_templates = [ + "Shall have a {system} reference of {value}", + "Shall be classified using {system}", + "Shall be classified as {value}", + ] + super().__init__(value, system, uri, minOccurs, maxOccurs, instructions) + + def __call__(self, inst, logger=None): + leaf_references = ifcopenshell.util.classification.get_references(inst) + + references = leaf_references.copy() + for leaf_reference in leaf_references: + references.update(ifcopenshell.util.classification.get_inherited_references(leaf_reference)) + + is_pass = bool(references) + reason = None + + if not is_pass: + reason = {"type": "NOVALUE"} + + if is_pass and self.value: + values = [getattr(r, "Identification", getattr(r, "ItemReference", None)) for r in references] + is_pass = any([self.value == v for v in values]) + if not is_pass: + reason = {"type": "VALUE", "actual": values} + + if is_pass and self.system: + systems = [ifcopenshell.util.classification.get_classification(r).Name for r in references] + is_pass = any([self.system == s for s in systems]) + if not is_pass: + reason = {"type": "SYSTEM", "actual": systems} + + return ClassificationResult(is_pass, reason) + + +class PartOf(Facet): + def __init__(self, entity="IfcSystem"): + self.parameters = ["@entity"] + self.applicability_templates = ["An element part of a {entity}"] + self.requirement_templates = ["Must be part of a {entity}"] + super().__init__(entity) + + def __call__(self, inst, logger=None): + if self.entity == "IfcElementAssembly": + is_pass = False + aggregate = ifcopenshell.util.element.get_aggregate(inst) + while aggregate is not None: + if aggregate.is_a() == "IfcElementAssembly": + is_pass = True + break + aggregate = ifcopenshell.util.element.get_aggregate(aggregate) + else: + is_pass = False + for rel in getattr(inst, "HasAssignments", []) or []: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.is_a(self.entity): + is_pass = True + + return PartOfResult(is_pass, "TODO") + + +class Property(Facet): + def __init__( + self, + propertySet="Property_Set", + name="PropertyName", + value=None, + measure=None, + uri=None, + minOccurs=None, + maxOccurs=None, + instructions=None, + ): + self.parameters = [ + "propertySet", + "name", + "value", + "@measure", + "@uri", + "@minOccurs", + "@maxOccurs", + "@instructions", + ] + self.applicability_templates = [ + "Elements with {name} data of {value} in the dataset {propertySet}", + "Elements with {name} data in the dataset {propertySet}", + ] + self.requirement_templates = [ + "{name} data shall be {value} and in the dataset {propertySet}", + "{name} data shall be provided in the dataset {propertySet}", + ] + super().__init__(propertySet, name, value, measure, uri, minOccurs, maxOccurs, instructions) + + def __call__(self, inst, logger=None): + all_psets = ifcopenshell.util.element.get_psets(inst) + + if isinstance(self.propertySet, str): + pset = all_psets.get(self.propertySet, None) + psets = {self.propertySet: pset} if pset else {} + else: + psets = {k: v for k, v in all_psets.items() if k == self.propertySet} + + is_pass = bool(psets) + + if is_pass: + props = {} + for pset_name, pset_props in psets.items(): + props[pset_name] = {} + if isinstance(self.name, str): + prop = pset_props.get(self.name) + if prop: + props[pset_name][self.name] = prop + else: + props[pset_name] = {k: v for k, v in pset_props.items() if k == self.name} + + if not bool(props[pset_name]): + is_pass = False + break + + if self.measure: + pset_entity = inst.wrapped_data.file.by_id(pset_props["id"]) + for prop_entity in pset_entity.HasProperties: + if ( + prop_entity.Name not in props[pset_name].keys() + or not prop_entity.is_a("IfcPropertySingleValue") + or prop_entity.NominalValue is None + ): + continue + + data_type = prop_entity.NominalValue.is_a().replace("Ifc", "").replace("Measure", "") + + if data_type != self.measure: + is_pass = False + break + + unit = ifcopenshell.util.unit.get_property_unit(prop_entity, inst.wrapped_data.file) + + props[pset_name][prop_entity.Name] = ifcopenshell.util.unit.convert( + prop_entity.NominalValue.wrappedValue, + getattr(unit, "Prefix", None), + unit.Name, + None, + ifcopenshell.util.unit.si_type_names[unit.UnitType], + ) + + if not is_pass: + break + + if self.value: + if any([v != self.value for v in props[pset_name].values()]): + is_pass = False + break + return PropertyResult(is_pass, "todo") + + +class Material(Facet): + def __init__(self, value=None, uri=None, minOccurs=None, maxOccurs=None, instructions=None): + self.parameters = ["value", "@uri", "@minOccurs", "@maxOccurs", "@instructions"] + self.applicability_templates = [ + "All data with a {value} material", + "All data with a material", + ] + self.requirement_templates = [ + "Shall shall have a material of {value}", + "Shall have a material", + ] + super().__init__(value, uri, minOccurs, maxOccurs, instructions) + + def __call__(self, inst, logger=None): + material = ifcopenshell.util.element.get_material(inst, should_skip_usage=True) + + is_pass = material is not None + reason = None + + if not is_pass: + reason = {"type": "NOVALUE"} + + if is_pass and self.value: + if material.is_a("IfcMaterial"): + values = {material.Name, getattr(material, "Category")} + elif material.is_a("IfcMaterialList"): + values = set() + for mat in material.Materials or []: + values.update([mat.Name, getattr(mat, "Category")]) + elif material.is_a("IfcMaterialLayerSet"): + values = {material.LayerSetName} + for item in material.MaterialLayers or []: + values.update([item.Name, item.Category, item.Material.Name, getattr(item.Material, "Category")]) + elif material.is_a("IfcMaterialProfileSet"): + values = {material.Name} + for item in material.MaterialProfiles or []: + values.update([item.Name, item.Category, item.Material.Name, getattr(item.Material, "Category")]) + elif material.is_a("IfcMaterialConstituentSet"): + values = {material.Name} + for item in material.MaterialConstituents or []: + values.update([item.Name, item.Category, item.Material.Name, getattr(item.Material, "Category")]) + + is_pass = False + for value in values: + if value == self.value: + is_pass = True + break + + if not is_pass: + reason = {"type": "VALUE", "actual": values} + + return MaterialResult(is_pass, reason) + + +def cast_to_value(from_value, to_value): + try: + target_type = type(to_value).__name__ + if target_type == "int": + # Casting str -> float -> int means that notation like '1e3' is preserved + return int(float(from_value)) + elif target_type == "bool": + if from_value == "TRUE": + return True + elif from_value == "FALSE": + return False + return builtins.__dict__[target_type](from_value) + except ValueError: + pass + + +class Restriction: + def __init__(self, options="", type="pattern", base="string"): + if type in ["enumeration", "pattern", "bounds"]: + self.type = type + self.base = base + self.options = options + if ( + (type == "enumeration" and isinstance(options, list)) + or (type == "bounds" and isinstance(options, dict)) + or (type == "pattern" and isinstance(options, str)) + ): + self.options = options + else: + raise Exception("Options were not properly defined.") + + def parse(self, ids_dict): + if ids_dict: + try: + self.base = ids_dict["@base"][3:] + except KeyError: + self.base = "String" + + for n in ids_dict: + if n == "enumeration": + self.type = "enumeration" + self.options = [] + for x in ids_dict[n]: + self.options.append(x["@value"]) + elif n[-7:] == "clusive": + self.type = "bounds" + self.options = {} + self.options.append({n: ids_dict[n]["@value"]}) + elif n[-5:] == "ength": + self.type = "length" + if n[3:6] == "min": + self.options.append(">=") + elif n[3:6] == "max": + self.options.append("<=") + else: + self.options.append("==") + self.options[-1] += str(ids_dict[n]["@value"]) + elif n == "pattern": + self.type = "pattern" + self.options = ids_dict[n]["@value"] + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace + elif n == "@base": + pass + else: + print("Error! Restriction not implemented") + return self + + def asdict(self): + rest_dict = {"@base": "xs:" + self.base} + if self.type == "enumeration": + for option in self.options: + if "xs:enumeration" not in rest_dict: + rest_dict["xs:enumeration"] = [{"@value": option}] + else: + rest_dict["xs:enumeration"].append({"@value": option}) + elif self.type == "bounds": + for option in self.options: + rest_dict["xs:" + option] = [{"@value": str(self.options[option]), "@fixed": False}] + elif self.type == "pattern": + if "xs:pattern" not in rest_dict: + rest_dict["xs:pattern"] = [{"@value": self.options}] + else: + rest_dict["xs:pattern"].append({"@value": self.options}) + return rest_dict + + def __eq__(self, other): + result = False + if self and (other or other == 0): + if self.type == "enumeration" and self.base == "bool": + self.options = [x.lower() for x in self.options] + result = str(other).lower() in self.options + elif self.type == "enumeration": + result = other in [cast_to_value(o, other) for o in self.options] + elif self.type == "bounds": + result = True + for sign in self.options.keys(): + if sign == "minInclusive" and other < self.options[sign]: + result = False + elif sign == "maxInclusive" and other > self.options[sign]: + result = False + elif sign == "minExclusive" and other <= self.options[sign]: + result = False + elif sign == "maxExclusive" and other >= self.options[sign]: + result = False + elif self.type == "length": + for op in self.options: + if eval(str(len(other)) + op): # TODO eval not safe? + result = True + elif self.type == "pattern": + if isinstance(self.options, list): + # TODO handle case with multiple pattern options + translated_pattern = identities.translate_pattern(self.options[0]) + else: + translated_pattern = identities.translate_pattern(self.options) + regex_pattern = re.compile(translated_pattern) + if regex_pattern.fullmatch(other) is not None: + result = True + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace + return result + + def __str__(self): + if self.type == "enumeration": + msg = "one of '%s'" % "' or '".join(self.options) + elif self.type == "bounds": + bounds = { + "minInclusive": "larger or equal ", + "maxInclusive": "smaller or equal ", + "minExclusive": "larger than ", + "maxExclusive": "smaller than ", + } + msg = "of value %s" % ", and ".join([bounds[x] + str(self.options[x]) for x in self.options]) + elif self.type == "length": + msg = "with %s letters" % " and ".join(self.options) + elif self.type == "pattern": + msg = "with pattern '%s'" % self.options + # TODO add fractionDigits + # TODO add totalDigits + # TODO add whiteSpace + return msg + + +class Result: + def __init__(self, is_pass, reason=None): + self.is_pass = is_pass + self.reason = reason + + def __bool__(self): + return self.is_pass + + def __str__(self): + return "" if self.is_pass else self.to_string() + + def to_string(self): + return str(self.reason) or "The requirements were not met for some inexplicable reason. Good luck!" + + +class EntityResult(Result): + def to_string(self): + if self.reason["type"] == "NAME": + return f"The entity class \"{self.reason['actual']}\" does not meet the required IFC class" + elif self.reason["type"] == "PREDEFINEDTYPE": + return f"The predefined type \"{str(self.reason['actual'])}\" does not meet the required type" + + +class AttributeResult(Result): + def to_string(self): + if self.reason["type"] == "NOVALUE": + return "The required attribute did not exist" + elif self.reason["type"] == "FALSEY": + return f"The attribute value \"{str(self.reason['actual'])}\" is empty" + elif self.reason["type"] == "INVALID": + return f"An invalid attribute name was specified in the IDS" + elif self.reason["type"] == "VALUE": + return f"The attribute value \"{str(self.reason['actual'])}\" does not match the requirement" + + +class ClassificationResult(Result): + def to_string(self): + if self.reason["type"] == "NOVALUE": + return "The entity has no classification" + elif self.reason["type"] == "VALUE": + return f"The found references \"{str(self.reason['actual'])}\" do not match the requirements" + elif self.reason["type"] == "VALUE": + return f"The references \"{str(self.reason['actual'])}\" do not match the requirements" + elif self.reason["type"] == "system": + return f"The systems \"{str(self.reason['actual'])}\" do not match the requirements" + + +class PartOfResult(Result): + def to_string(self): + return "TODO" + + +class PropertyResult(Result): + def to_string(self): + return "TODO" + + +class MaterialResult(Result): + def to_string(self): + if self.reason["type"] == "NOVALUE": + return "The entity has no material" + elif self.reason["type"] == "VALUE": + return ( + f"The material names and categories of \"{str(self.reason['actual'])}\" does not match the requirement" + ) diff --git a/src/ifctester/ifctester/ids.xsd b/src/ifctester/ifctester/ids.xsd new file mode 100644 index 0000000000..98e754d218 --- /dev/null +++ b/src/ifctester/ifctester/ids.xsd @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + See the documentation and default units of these measures on https://github.com/buildingSMART/IDS/blob/master/Documentation/Physical_Quantities_and_Units.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Make sure 'Name' value of requirements entity is the same as the 'applicability' node, or a wildcard (inclusive pattern). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Author of the IDS can provide an identifier to the IDS. Beware: this cannot be enforced/assumed as (global) unique. + + + + + + Author of the IDS can leave instructions for the authors of the IFC. This text could/should be displayed in the BIM/IFC authoring tool. + + + + + + + + + diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py new file mode 100644 index 0000000000..6678a7a8ae --- /dev/null +++ b/src/ifctester/ifctester/reporter.py @@ -0,0 +1,285 @@ +# IfcTester - IDS based model auditing +# Copyright (C) 2021 Artur Tomczak , Thomas Krijnen , Dion Moult +# +# This file is part of IfcTester. +# +# IfcTester is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcTester is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcTester. If not, see . + +import os +import sys +import logging +import numpy as np +import ifcopenshell.util.placement +from bcf.v2.bcfxml import BcfXml +from bcf.v2 import data as bcf + + +class Console: + def __init__(self, ids, use_colour=True): + self.ids = ids + self.use_colour = use_colour + self.colours = { + "red": "\033[1;31m", + "blue": "\033[1;34m", + "cyan": "\033[1;36m", + "green": "\033[0;32m", + "yellow": "\033[0;33m", + "purple": "\033[0;95m", + "grey": "\033[0;90m", + "reset": "\033[0;0m", + "bold": "\033[;1m", + "reverse": "\033[;7m", + } + + def report(self): + self.set_style("bold", "blue") + print(self.ids.info.get("title", "Untitled IDS")) + for specification in self.ids.specifications: + self.report_specification(specification) + self.set_style("reset") + + def report_specification(self, specification): + if specification.status is True: + self.set_style("bold", "green") + print("[PASS] ", end="") + elif specification.status is False: + self.set_style("bold", "red") + print("[FAIL] ", end="") + elif specification.status is None: + self.set_style("bold", "yellow") + print("[UNTESTED] ", end="") + + self.set_style("bold") + total = len(specification.applicable_entities) + total_successes = total - len(specification.failed_entities) + print(f"({total_successes}/{total}) ", end="") + + if specification.minOccurs != 0: + print(f"*", end="") + + print(specification.name) + + self.set_style("cyan") + print(" " * 4 + "Applies to:") + self.set_style("reset") + + for applicability in specification.applicability: + print(" " * 8 + applicability.to_string("applicability")) + + + if not total and specification.status is False: + return + + self.set_style("cyan") + print(" " * 4 + "Requirements:") + self.set_style("reset") + + for requirement in specification.requirements: + self.set_style("reset") + self.set_style("red") if requirement.failed_entities else self.set_style("green") + print(" " * 8 + requirement.to_string("requirement")) + self.set_style("reset") + for i, element in enumerate(requirement.failed_entities[0:10]): + print(" " * 12, end="") + self.report_reason(requirement.failed_reasons[i], element) + if len(requirement.failed_entities) > 10: + print(" " * 12 + f"... {len(requirement.failed_entities)} in total ...") + self.set_style("reset") + + def report_reason(self, reason, element): + is_bold = False + for substring in reason.split("\""): + if is_bold: + self.set_style("purple") + else: + self.set_style("reset") + print(substring, end="") + is_bold = not is_bold + self.set_style("grey") + print(" - " + str(element)) + self.set_style("reset") + + def set_style(self, *colours): + if self.use_colour: + sys.stdout.write("".join([self.colours[c] for c in colours])) + + +class JsonReporter: + def __init__(self, specifications): + self.specifications = specifications + self.results = [] + + def report(self): + for specification in self.specifications: + self.results.append(self.report_specification(specification)) + return self.results + + def report_specification(self, specification): + return {"status": specification.status} + + +class SimpleHandler(logging.StreamHandler): + """Logging handler listing all cases in python list.""" + + def __init__(self, report_valid=False): + """Logging handler listing all cases in python list. + + :param report_valid: True if you want to list all the compliant cases as well, defaults to False + :type report_valid: bool, optional + """ + logging.StreamHandler.__init__(self) + self.statements = [] + if report_valid: + self.setLevel(logging.DEBUG) + else: + self.setLevel(logging.ERROR) + + def emit(self, mymsg): + """Triggered on each use of logging with the Simple handler enabled. + + :param log_content: default logger message + :type log_content: string|dict + """ + self.statements.append(mymsg.msg) + + +class CsvHandler(logging.StreamHandler): + """Logging handler listing all cases in csv file.""" + + def __init__(self, filepath="./Report.csv", report_valid=False): + """Logging handler listing all cases in csv file. + + :param report_valid: True if you want to list all the compliant cases as well, defaults to False + :type report_valid: bool, optional + """ + import csv + + logging.StreamHandler.__init__(self) + if report_valid: + self.setLevel(logging.INFO) + else: + self.setLevel(logging.ERROR) + self.file = open(filepath, "w", encoding="UTF8", newline="") + self.csvwriter = csv.writer(self.file) + self.csvwriter.writerow(["guid", "result", "sentence"]) # header + + def emit(self, mymsg): + """Triggered on each use of logging with the Simple handler enabled. + + :param log_content: default logger message + :type log_content: string|dict + """ + # BUG bytes-like object is required, not 'str' + self.csvwriter.writerow(mymsg.msg) + + def flush(self): + self.file.close() + + +class BcfHandler(logging.StreamHandler): + """Logging handler for creation of BCF report files. + + :param project_name: defaults to "IDS Project" + :type project_name: str, optional + :param author: Email of the person creating the BCF report, defaults to "your@email.com" + :type author: str, optional + :param filepath: Path to save the BCF report, defaults to None + :type filepath: str, optional + :param report_valid: True if you want to list all the compliant cases as well, defaults to False + :type report_valid: bool, optional + + Example:: + + bcf_handler = BcfHandler( + project_name="Default IDS Project", + author="your@email.com", + filepath="example.bcf", + ) + logger = logging.getLogger("IDS_Logger") + logging.basicConfig(level=logging.INFO, format="%(message)s") + logger.addHandler(bcf_handler) + """ + + def __init__(self, project_name="IDS Project", author="your@email.com", filepath=None, report_valid=False): + + logging.StreamHandler.__init__(self) + if report_valid: + self.setLevel(logging.INFO) + else: + self.setLevel(logging.ERROR) + self.bcf = BcfXml() + self.bcf.author = author + self.bcf.new_project() + self.bcf.project.name = project_name + self.filepath = filepath + self.bcf.edit_project() + + def emit(self, log_content): + """Triggered on each use of logging with the BCF handler enabled. + + :param log_content: default logger message + :type log_content: string|dict + """ + topic = bcf.Topic() + topic.title = log_content.msg["sentence"].split(".\n")[1] + topic.description = log_content.msg["sentence"].split(".\n")[0] + self.bcf.add_topic(topic) + # try: # Add viewpoint and link to ifc object + viewpoint = bcf.Viewpoint() + viewpoint.perspective_camera = bcf.PerspectiveCamera() + ifc_elem = log_content.msg["ifc_element"] + # ifc_elem = ifc_file.by_guid(log_content.msg["guid"]) + target_position = np.array(ifcopenshell.util.placement.get_local_placement(ifc_elem.ObjectPlacement)) + target_position = target_position[:, 3][0:3] + camera_position = target_position + np.array((5, 5, 5)) + viewpoint.perspective_camera.camera_view_point.x = camera_position[0] + viewpoint.perspective_camera.camera_view_point.y = camera_position[1] + viewpoint.perspective_camera.camera_view_point.z = camera_position[2] + camera_direction = camera_position - target_position + camera_direction = camera_direction / np.linalg.norm(camera_direction) + camera_right = np.cross(np.array([0.0, 0.0, 1.0]), camera_direction) + camera_right = camera_right / np.linalg.norm(camera_right) + camera_up = np.cross(camera_direction, camera_right) + camera_up = camera_up / np.linalg.norm(camera_up) + rotation_transform = np.zeros((4, 4)) + rotation_transform[0, :3] = camera_right + rotation_transform[1, :3] = camera_up + rotation_transform[2, :3] = camera_direction + rotation_transform[-1, -1] = 1 + translation_transform = np.eye(4) + translation_transform[:3, -1] = -camera_position + look_at_transform = np.matmul(rotation_transform, translation_transform) + mat = np.linalg.inv(look_at_transform) + viewpoint.perspective_camera.camera_direction.x = mat[0][2] * -1 + viewpoint.perspective_camera.camera_direction.y = mat[1][2] * -1 + viewpoint.perspective_camera.camera_direction.z = mat[2][2] * -1 + viewpoint.perspective_camera.camera_up_vector.x = mat[0][1] + viewpoint.perspective_camera.camera_up_vector.y = mat[1][1] + viewpoint.perspective_camera.camera_up_vector.z = mat[2][1] + viewpoint.components = bcf.Components() + c = bcf.Component() + c.ifc_guid = log_content.msg["guid"] + viewpoint.components.selection.append(c) + viewpoint.components.visibility = bcf.ComponentVisibility() + viewpoint.components.visibility.default_visibility = True + viewpoint.snapshot = None + self.bcf.add_viewpoint(topic, viewpoint) + + def flush(self): + """Saves the BCF report to file. Triggered at the end of the validation process.""" + if not self.filepath: + self.filepath = os.getcwd() + r"\IDS_report.bcf" + if not (self.filepath.endswith(".bcf") or self.filepath.endswith(".bcfzip")): + self.filepath = self.filepath + r"\IDS_report.bcf" + self.bcf.save_project(self.filepath) diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py new file mode 100644 index 0000000000..1b1f07ec41 --- /dev/null +++ b/src/ifctester/test/ids_doc_generator.py @@ -0,0 +1,129 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import os +import re +import unittest +import functools +import itertools +import ifcopenshell +import test_ids +from xml.dom.minidom import parseString +from ifcopenshell import ids, template, validate + +outdir = "build" + + +class DocGenerator: + def __init__(self): + self.facet = None + self.testcases = {} + + def __call__(self, name, *, facet, inst, expected): + if not name: + return + + result = "pass" if expected is True else "fail" + + f = inst.wrapped_data.file + + # Validate the file created and loop over the issues, fixing them + # one by one. + l = validate.json_logger() + validate.validate(f, l) + for issue in l.statements: + if "GlobalId" in issue["message"]: + issue["instance"].GlobalId = ifcopenshell.guid.new() + elif "PredefinedType" in issue["message"]: + ty = re.findall("\\(.+?\\)", issue["message"])[0][1:-1].split(", ")[0] + issue["instance"].PredefinedType = ty + elif "IfcMaterialList" in issue["message"]: + issue["instance"].Materials = [f.createIfcMaterial("Concrete", None, "CONCRETE")] + else: + raise Exception("About to emit invalid example data:", issue) + + # ifc_text = "\n".join([f"{e} /* Testcase */" if e == inst else str(e) for e in f]) + lines = f.wrapped_data.to_string().split("\n")[7:-3] + ifc_text = "\n".join([f"{l} /* Testcase */" if f"#{inst.id()}=" in l else l for l in lines]) + basename = f"{result}-" + re.sub("[^0-9a-zA-Z]", "_", name.lower()) + + # Write IFC to disk + f.write(os.path.join(outdir, f"{basename}.ifc")) + + # Create an IDS with the applicability selecting exactly + # the entity type passed to us in `inst`. + specs = ids.ids(title=name) + spec = ids.specification(name=name) + spec.add_applicability(ids.entity.create(name=inst.is_a())) + spec.add_requirement(facet) + specs.specifications.append(spec) + + # Write IDS to disk + with open(os.path.join(outdir, f"{basename}.ids"), "w", encoding="utf-8") as ids_file: + ids_file.write(specs.to_string()) + + xml_text = "\n".join( + l + for l in parseString(specs.to_string()) + .getElementsByTagName("requirements")[0] + .childNodes[1] + .toprettyxml() + .split("\n") + if l.strip() + ).replace("\t", " ") + + self.testcases.setdefault(self.facet, []).append( + {"name": name, "ids": xml_text, "ifc": ifc_text, "basename": basename, "result": result, "id": inst.id()} + ) + + assert bool(facet(inst)) is expected + + def set_facet(self, facet): + self.facet = facet + + +test_ids.run = DocGenerator() +test_ids.set_facet = test_ids.run.set_facet + +suite = unittest.TestLoader().discover(".", pattern="test_ids.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) + +for facet, testcases in test_ids.run.testcases.items(): + with open(os.path.join(outdir, f"testcases-{facet}.md"), "w") as f: + write = functools.partial(print, file=f) + write(f"# {facet.capitalize()} testcases") + write() + write( + "These testcases are designed to help describe behaviour in edge cases and ambiguities. All valid IDS implementations must demonstrate identical behaviour to these test cases." + ) + write() + for testcase in testcases: + write(f"## [{testcase['result'].upper()}] {testcase['name']}") + write() + write("~~~xml") + write(testcase["ids"]) + write("~~~") + write() + write("~~~lua") + write(testcase["ifc"]) + write("~~~") + write() + write( + f"[Sample IDS]({testcase['basename']}.ids) - [Sample IFC: {testcase['id']}]({testcase['basename']}.ifc)" + ) + write() diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py new file mode 100644 index 0000000000..31f521542e --- /dev/null +++ b/src/ifctester/test/test_ids.py @@ -0,0 +1,1385 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import os +import pytest +import logging +import unittest +import tempfile +import xmlschema +import ifcopenshell +import ifcopenshell.api +from bcf import bcfxml +from ifcopenshell import ids + +IFC_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IFC/", "IFC4_Wall_3_with_properties.ifc") +IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_all_fields.xml") + +logger = logging.getLogger("IDS_Logger") +# logging.basicConfig(level=logging.INFO, format="%(message)s") +# logging.basicConfig(filename=os.path.join(os.path.dirname(__file__), "log.txt"), level=logging.INFO, format="%(message)s") + +file = open(os.path.join(tempfile.gettempdir(), "test.ifc"), "w") +file.write(IFC_URL) +file.close() +ifc_file = ifcopenshell.open(IFC_URL) +os.remove(os.path.join(tempfile.gettempdir(), "test.ifc")) + + +def set_facet(facet): + pass + +def run(name, *, facet, inst, expected): + assert bool(facet(inst)) is expected + + +class TestIdsParsing(unittest.TestCase): + def test_parse_basic_ids(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_all_fields.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual(type(ids_file).__name__, "ids") + + def test_parse_entity_facet(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_entity.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "IfcWall") + + def test_parse_predefinedType_facet(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_predefinedtype.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual( + ids_file.specifications[0].requirements[0].node["predefinedType"]["simpleValue"], "CLADDING" + ) + + def test_parse_property_facet(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_property.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual( + ids_file.specifications[0].requirements[0].node["propertySet"]["simpleValue"], "Test_PropertySet" + ) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual(ids_file.specifications[0].requirements[0].node["value"]["simpleValue"], "Test_Value") + + def test_parse_material_facet(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_material.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["value"]["simpleValue"], "Test_Material") + + def test_parse_classification_facet(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_classification.xml") + ids_file = ids.open(IDS_URL) + self.assertEqual( + ids_file.specifications[0].requirements[0].node["value"]["simpleValue"], "Test_Classification" + ) + self.assertEqual(ids_file.specifications[0].requirements[0].node["system"]["simpleValue"], "Test_System") + + def test_failing_on_opening_invalid_ids_data(self): + with pytest.raises(xmlschema.validators.exceptions.XMLSchemaValidationError): + ids.open("""""") + + """ Saving parsed IDS to IDS.xml """ + + def test_parsed_ids_to_xml(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files", "IDS", "IDS_Wall_needs_all_fields.xml") + ids_file = ids.open(IDS_URL) + fn = "output.xml" + result = ids_file.to_xml(fn) + assert os.path.isfile(fn) + os.remove(fn) + self.assertTrue(result) + + def test_parsed_ids_to_string(self): + return # TODO + IDS_URL = os.path.join(os.path.dirname(__file__), "Sample-BIM-Files", "IDS", "IDS_Wall_needs_all_fields.xml") + ids_file = ids.open(IDS_URL) + output = ids_file.to_string() + assert output and "http://standards.buildingsmart.org/IDS" in output + + """ Parsing IDS files with restrictions """ + + def test_parse_restrictions_enumeration(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), + "Sample-BIM-Files/IDS/", + "IDS_Wall_needs_property_with_restriction_enumeration.xml", + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + [ + x["@value"] + for x in ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["enumeration"] + ], + ["testA", "testB"], + ) + + def test_parse_restrictions_bounds(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_property_with_restriction_bounds.xml" + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["minInclusive"]["@value"], + "0", + ) + + def test_parse_restrictions_pattern_simple(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_property_with_restriction_pattern.xml" + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["pattern"]["@value"], + "[A-Z]{2,4}", + ) + + +class TestIdsAuthoring(unittest.TestCase): + def test_create_an_ids_with_minimal_information(self): + specs = ids.Ids() + assert specs.asdict() == { + "@xmlns": "http://standards.buildingsmart.org/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/ids_05.xsd", + "info": {"title": "Untitled"}, + "specifications": {"specification": []}, + } + + def test_create_an_ids_with_all_possible_information(self): + specs = ids.Ids( + title="title", + copyright="copyright", + version="version", + description="description", + author="author@test.com", + date="2020-01-01", + purpose="purpose", + milestone="milestone", + ) + assert specs.asdict() == { + "@xmlns": "http://standards.buildingsmart.org/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/ids_05.xsd", + "info": { + "title": "title", + "copyright": "copyright", + "version": "version", + "description": "description", + "author": "author@test.com", + "date": "2020-01-01", + "purpose": "purpose", + "milestone": "milestone", + }, + "specifications": {"specification": []}, + } + + def test_check_invalid_ids_information(self): + specs = ids.Ids(title=None, author="author", date="9999-99-99") + assert specs.asdict() == { + "@xmlns": "http://standards.buildingsmart.org/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/ids_05.xsd", + "info": {"title": "Untitled"}, + "specifications": {"specification": []}, + } + + def test_authoring_an_ids_with_no_specifications_is_invalid(self): + specs = ids.Ids() + with pytest.raises(xmlschema.validators.exceptions.XMLSchemaChildrenValidationError): + specs.to_string() + + def test_create_specification_with_minimal_information(self): + spec = ids.Specification() + assert spec.asdict() == { + "@name": "Unnamed", + "@ifcVersion": ["IFC2X3", "IFC4"], + "applicability": {}, + "requirements": {}, + } + + def test_create_specification_with_all_possible_information(self): + spec = ids.Specification( + name="name", + minOccurs="0", + maxOccurs="unbounded", + ifcVersion="IFC4", + identifier="identifier", + description="description", + instructions="instructions", + ) + assert spec.asdict() == { + "@name": "name", + "@minOccurs": "0", + "@maxOccurs": "unbounded", + "@ifcVersion": "IFC4", + "@identifier": "identifier", + "@description": "description", + "@instructions": "instructions", + "applicability": {}, + "requirements": {}, + } + + def test_creating_an_entity_facet(self): + facet = ids.Entity(name="IfcName") + assert facet.asdict() == {"name": {"simpleValue": "IfcName"}} + facet = ids.Entity(name="IfcName", predefinedType="predefinedType", instructions="instructions") + assert facet.asdict() == { + "name": {"simpleValue": "IfcName"}, + "predefinedType": {"simpleValue": "predefinedType"}, + "@instructions": "instructions", + } + + def test_filtering_using_an_entity_facet(self): + set_facet("entity") + + ifc = ifcopenshell.file() + facet = ids.Entity(name="IFCRABBIT") + run("Invalid entities always fail", facet=facet, inst=ifc.createIfcWall(), expected=False) + + ifc = ifcopenshell.file() + facet = ids.Entity(name="IFCWALL") + run("A matching entity should pass", facet=facet, inst=ifc.createIfcWall(), expected=True) + ifc = ifcopenshell.file() + run( + "An matching entity should pass regardless of predefined type", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="SOLIDWALL"), + expected=True, + ) + ifc = ifcopenshell.file() + run( + "An entity not matching the specified class should fail", + facet=facet, + inst=ifc.createIfcSlab(), + expected=False, + ) + # TODO: Some votes to prefer inheritance (For: Moult, Evandro, Artur) + # Possible argument: CAD tools don't understand IFC + ifc = ifcopenshell.file() + run( + "Subclasses are not considered as matching", + facet=facet, + inst=ifc.createIfcWallStandardCase(), + expected=False, + ) + + # TODO But in that case why are the enumerations for things like partOf using the IFC capitalisation? + facet = ids.Entity(name="IfcWall") + ifc = ifcopenshell.file() + run( + "Entities must be specified as uppercase strings", + facet=facet, + inst=ifc.createIfcWall(), + expected=False, + ) + + facet = ids.Entity(name="IFCWALL", predefinedType="SOLIDWALL") + ifc = ifcopenshell.file() + run( + "A matching predefined type should pass", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="SOLIDWALL"), + expected=True, + ) + ifc = ifcopenshell.file() + run( + "A null predefined type should always fail a specified predefined types", + facet=facet, + inst=ifc.createIfcWall(), + expected=False, + ) + ifc = ifcopenshell.file() + run( + "An entity not matching a specified predefined type will fail", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="PARTITIONING"), + expected=False, + ) + + facet = ids.Entity(name="IFCWALL", predefinedType="solidwall") + ifc = ifcopenshell.file() + run( + "A predefined type from an enumeration must be uppercase", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="SOLIDWALL"), + expected=False, + ) + + facet = ids.Entity(name="IFCWALL", predefinedType="WALDO") + ifc = ifcopenshell.file() + run( + "A predefined type may specify a user-defined object type", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="WALDO"), + expected=True, + ) + + facet = ids.Entity(name="IFCWALL", predefinedType="WALDO") + ifc = ifcopenshell.file() + run( + "User-defined types are checked case sensitively", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="waldo"), + expected=False, + ) + + facet = ids.Entity(name="IFCWALLTYPE", predefinedType="WALDO") + ifc = ifcopenshell.file() + run( + "A predefined type may specify a user-defined element type", + facet=facet, + inst=ifc.createIfcWallType(PredefinedType="USERDEFINED", ElementType="WALDO"), + expected=True, + ) + + facet = ids.Entity(name="IFCTASKTYPE", predefinedType="TASKY") + ifc = ifcopenshell.file() + run( + "A predefined type may specify a user-defined process type", + facet=facet, + inst=ifc.createIfcTaskType(PredefinedType="USERDEFINED", ProcessType="TASKY"), + expected=True, + ) + + facet = ids.Entity(name="IFCWALL", predefinedType="USERDEFINED") + ifc = ifcopenshell.file() + run( + "A predefined type must always specify a meaningful type, not USERDEFINED itself", + facet=facet, + inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="WALDO"), + expected=False, + ) + + ifc = ifcopenshell.file() + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType", predefined_type="X") + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + facet = ids.Entity(name="IFCWALL", predefinedType="X") + run("Inherited predefined types should pass", facet=facet, inst=wall, expected=True) + + ifc = ifcopenshell.file() + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall", predefined_type="X") + wall_type = ifcopenshell.api.run( + "root.create_entity", ifc, ifc_class="IfcWallType", predefined_type="NOTDEFINED" + ) + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + facet = ids.Entity(name="IFCWALL", predefinedType="X") + run("Overridden predefined types should pass", facet=facet, inst=wall, expected=True) + + restriction = ids.Restriction(options=["IFCWALL", "IFCSLAB"], type="enumeration") + facet = ids.Entity(name=restriction) + ifc = ifcopenshell.file() + run("Entities can be specified as an enumeration 1/3", facet=facet, inst=ifc.createIfcWall(), expected=True) + ifc = ifcopenshell.file() + run("Entities can be specified as an enumeration 2/3", facet=facet, inst=ifc.createIfcSlab(), expected=True) + ifc = ifcopenshell.file() + run("Entities can be specified as an enumeration 3/3", facet=facet, inst=ifc.createIfcBeam(), expected=False) + + restriction = ids.Restriction(options="IFC.*TYPE", type="pattern") + facet = ids.Entity(name=restriction) + ifc = ifcopenshell.file() + run("Entities can be specified as a XSD regex pattern 1/2", facet=facet, inst=ifc.createIfcWall(), expected=False) + ifc = ifcopenshell.file() + run("Entities can be specified as a XSD regex pattern 2/2", facet=facet, inst=ifc.createIfcWallType(), expected=True) + + restriction = ids.Restriction(options="FOO.*", type="pattern") + facet = ids.Entity(name="IFCWALL", predefinedType=restriction) + ifc = ifcopenshell.file() + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall", predefined_type="FOOBAR") + run("Restrictions an be specified for the predefined type 1/3", facet=facet, inst=wall, expected=True) + ifc = ifcopenshell.file() + wall2 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall", predefined_type="FOOBAZ") + run("Restrictions an be specified for the predefined type 2/3", facet=facet, inst=wall2, expected=True) + ifc = ifcopenshell.file() + wall3 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall", predefined_type="BAZFOO") + run("Restrictions an be specified for the predefined type 3/3", facet=facet, inst=wall3, expected=False) + + def test_creating_an_attribute_facet(self): + attribute = ids.Attribute(name="name") + assert attribute.asdict() == {"name": {"simpleValue": "name"}} + attribute = ids.Attribute(name="name", value="value") + assert attribute.asdict() == {"name": {"simpleValue": "name"}, "value": {"simpleValue": "value"}} + attribute = ids.Attribute( + name="name", value="value", minOccurs="0", maxOccurs="unbounded", instructions="instructions" + ) + assert attribute.asdict() == { + "name": {"simpleValue": "name"}, + "value": {"simpleValue": "value"}, + "@minOccurs": "0", + "@maxOccurs": "unbounded", + "@instructions": "instructions", + } + + def test_filtering_using_an_attribute_facet(self): + set_facet("attribute") + + ifc = ifcopenshell.file() + facet = ids.Attribute(name="Foobar") + run("Invalid attribute names always fail", facet=facet, inst=ifc.createIfcWall(), expected=False) + + ifc = ifcopenshell.file() + facet = ids.Attribute(name="Name") + run("Attributes with a string value should pass", facet=facet, inst=ifc.createIfcWall(Name="Foobar"), expected=True) + ifc = ifcopenshell.file() + run("Attributes with null values always fail", facet=facet, inst=ifc.createIfcWall(), expected=False) + # The logic is that unfortunately most BIM users cannot differentiate between the two. + ifc = ifcopenshell.file() + run("Attributes with empty strings always fail", facet=facet, inst=ifc.createIfcWall(Name=""), expected=False) + + facet = ids.Attribute(name="CountValue") + ifc = ifcopenshell.file() + run("Attributes with a zero number have meaning and should pass", facet=facet, inst=ifc.createIfcQuantityCount(Name="Foobar", CountValue=0), expected=True) + + facet = ids.Attribute(name="IsCritical") + ifc = ifcopenshell.file() + element = ifc.createIfcTaskTime(IsCritical=True) + run("Attributes with a boolean true should pass", facet=facet, inst=element, expected=True) + element.IsCritical = False + run("Attributes with a boolean false should pass", facet=facet, inst=element, expected=True) + + facet = ids.Attribute(name="RelatingPriorities") + ifc = ifcopenshell.file() + run("Attributes with an empty list always fail", facet=facet, inst=ifc.createIfcRelConnectsPathElements(RelatingElement=ifc.createIfcWall(), RelatedElement=ifc.createIfcWall(), RelatingPriorities=[], RelatedPriorities=[], RelatedConnectionType="ATSTART", RelatingConnectionType="ATEND"), expected=False) + + facet = ids.Attribute(name="LayerStyles") + ifc = ifcopenshell.file() + item = ifc.createIfcCartesianPoint([0., 0., 0.]) + layer = ifc.createIfcPresentationLayerWithStyle("Foo", None, [item], None, True, False, False, []) + run("Attributes with an empty set always fail", facet=facet, inst=layer, expected=False) + + layer.LayerOn = "UNKNOWN" + facet = ids.Attribute(name="LayerOn") + run("Attributes with a logical unknown always fail", facet=facet, inst=layer, expected=False) + + facet = ids.Attribute(name="ScheduleDuration") + ifc = ifcopenshell.file() + element = ifc.createIfcTaskTime(ScheduleDuration="P0D") + run("Attributes with a zero duration should pass", facet=facet, inst=element, expected=True) + + facet = ids.Attribute(name="TaskTime") + ifc = ifcopenshell.file() + element = ifc.createIfcTask(IsMilestone=True, TaskTime=ifc.createIfcTaskTime()) + run("Attributes referencing an object should pass", facet=facet, inst=element, expected=True) + + facet = ids.Attribute(name="DiffuseColour") + ifc = ifcopenshell.file() + rgb = ifc.createIfcColourRgb(None, 1, 1, 1) + run("Attributes with a select referencing an object should pass", facet=facet, inst=ifc.createIfcSurfaceStyleRendering(SurfaceColour=rgb, ReflectanceMethod="FLAT", DiffuseColour=ifc.createIfcColourRgb(None, 1, 1, 1)), expected=True) + + ifc = ifcopenshell.file() + rgb = ifc.createIfcColourRgb(None, 1, 1, 1) + run("Attributes with a select referencing a primitive should pass", facet=facet, inst=ifc.createIfcSurfaceStyleRendering(SurfaceColour=rgb, ReflectanceMethod="FLAT", DiffuseColour=ifc.createIfcNormalisedRatioMeasure(0.5)), expected=True) + + ifc = ifcopenshell.file() + facet = ids.Attribute(name="EngagedIn") + person = ifc.createIfcPerson() + organisation = ifc.createIfcOrganization(Name="Foo") + ifc.createIfcPersonAndOrganization(ThePerson=person, TheOrganization=organisation) + run("Inverse attributes cannot be checked and always fail", facet=facet, inst=person, expected=False) + + ifc = ifcopenshell.file() + facet = ids.Attribute(name="Dim") + run("Derived attributes cannot be checked and always fail", facet=facet, inst=ifc.createIfcCartesianPoint([0., 0., 0.]), expected=False) + + facet = ids.Attribute(name="Name", value="Foobar") + ifc = ifcopenshell.file() + run("Attributes should check strings case sensitively 1/2", facet=facet, inst=ifc.createIfcWall(Name="Foobar"), expected=True) + ifc = ifcopenshell.file() + run("Attributes should check strings case sensitively 2/2", facet=facet, inst=ifc.createIfcWall(Name="foobar"), expected=False) + + ifc = ifcopenshell.file() + facet = ids.Attribute(name="Name", value="♫") + run("Non-ascii characters are treated without encoding", facet=facet, inst=ifc.createIfcWall(Name="♫"), expected=True) + + facet = ids.Attribute(name="TaskTime", value="Foobar") + ifc = ifcopenshell.file() + run("Value checks always fail for objects", facet=facet, inst=ifc.createIfcTask(IsMilestone=False, TaskTime=ifc.createIfcTaskTime()), expected=False) + + facet = ids.Attribute(name="DiffuseColour", value="Foobar") + ifc = ifcopenshell.file() + rgb = ifc.createIfcColourRgb(None, 1, 1, 1) + run("Value checks always fail for selects", facet=facet, inst=ifc.createIfcSurfaceStyleRendering(SurfaceColour=rgb, ReflectanceMethod="FLAT", DiffuseColour=ifc.createIfcNormalisedRatioMeasure(0.5)), expected=False) + + facet = ids.Attribute(name="Coordinates", value="Foobar") + ifc = ifcopenshell.file() + run("Value checks always fail for lists", facet=facet, inst=ifc.createIfcCartesianPoint([0., 0., 0.]), expected=False) + + # TODO continue from here + + global_id = ifcopenshell.guid.new() + facet = ids.Attribute(name="GlobalId", value=global_id) + ifc = ifcopenshell.file() + run("GlobalIds are treated as strings and not expanded", facet=facet, inst=ifc.createIfcWall(GlobalId=global_id), expected=True) + + # 255 characters + identifier = "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345" + facet = ids.Attribute(name="Identification", value=identifier + "_extra_characters") + ifc = ifcopenshell.file() + run("IDS does not handle string truncation such as for identifiers", facet=facet, inst=ifc.createIfcPerson(Identification=identifier), expected=False) + + facet = ids.Attribute(name="RefractionIndex", value="42") + ifc = ifcopenshell.file() + run("Numeric values are checked using type casting 1/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42), expected=True) + facet = ids.Attribute(name="RefractionIndex", value="42.") + ifc = ifcopenshell.file() + run("Numeric values are checked using type casting 2/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42.0), expected=True) + facet = ids.Attribute(name="RefractionIndex", value="42.0") + ifc = ifcopenshell.file() + run("Numeric values are checked using type casting 3/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42.0), expected=True) + facet = ids.Attribute(name="RefractionIndex", value="42") + ifc = ifcopenshell.file() + run("Numeric values are checked using type casting 4/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42.3), expected=False) + facet = ids.Attribute(name="RefractionIndex", value="42,3") + ifc = ifcopenshell.file() + run("Only specifically formatted numbers are allowed 1/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42.3), expected=False) + facet = ids.Attribute(name="RefractionIndex", value="123,4.5") + ifc = ifcopenshell.file() + run("Only specifically formatted numbers are allowed 2/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=1234.5), expected=False) + facet = ids.Attribute(name="RefractionIndex", value="1.2345e3") + ifc = ifcopenshell.file() + run("Only specifically formatted numbers are allowed 3/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=1234.5), expected=True) + facet = ids.Attribute(name="RefractionIndex", value="1.2345E3") + ifc = ifcopenshell.file() + run("Only specifically formatted numbers are allowed 4/4", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=1234.5), expected=True) + + facet = ids.Attribute(name="NumberOfRisers", value="42") + ifc = ifcopenshell.file() + run("Integers follow the same rules as numbers", facet=facet, inst=ifc.createIfcStairFlight(NumberOfRisers=42), expected=True) + facet = ids.Attribute(name="NumberOfRisers", value="42.0") + ifc = ifcopenshell.file() + run("Integers follow the same rules as numbers 2/2", facet=facet, inst=ifc.createIfcStairFlight(NumberOfRisers=42), expected=True) + + facet = ids.Attribute(name="NumberOfRisers", value="42.3") + ifc = ifcopenshell.file() + run("Integers are always floored when cast 1/2", facet=facet, inst=ifc.createIfcStairFlight(NumberOfRisers=42), expected=True) + facet = ids.Attribute(name="NumberOfRisers", value="42.7") + ifc = ifcopenshell.file() + run("Integers are always floored when cast 2/2", facet=facet, inst=ifc.createIfcStairFlight(NumberOfRisers=42), expected=True) + + facet = ids.Attribute(name="NumberOfRisers", value="42.7") + ifc = ifcopenshell.file() + run("Integers are always floored when cast 2/2", facet=facet, inst=ifc.createIfcStairFlight(NumberOfRisers=42), expected=True) + + facet = ids.Attribute(name="IsMilestone", value="TRUE") + ifc = ifcopenshell.file() + element = ifc.createIfcTask(IsMilestone=False) + run("Booleans must be specified as uppercase strings 1/3", facet=facet, inst=element, expected=False) + facet = ids.Attribute(name="IsMilestone", value="FALSE") + run("Booleans must be specified as uppercase strings 2/3", facet=facet, inst=element, expected=True) + facet = ids.Attribute(name="IsMilestone", value="False") + run("Booleans must be specified as uppercase strings 2/3", facet=facet, inst=element, expected=False) + + facet = ids.Attribute(name="EditionDate", value="2022-01-01") + ifc = ifcopenshell.file() + run("Dates are treated as strings 1/2", facet=facet, inst=ifc.createIfcClassification(Name="Name", EditionDate="2022-01-01"), expected=True) + ifc = ifcopenshell.file() + run("Dates are treated as strings 1/2", facet=facet, inst=ifc.createIfcClassification(Name="Name", EditionDate="2022-01-01+00:00"), expected=False) + + facet = ids.Attribute(name="ScheduleDuration", value="PT16H") + ifc = ifcopenshell.file() + run("Durations are treated as strings 1/2", facet=facet, inst=ifc.createIfcClassification(Name="Name", EditionDate="PT16H"), expected=False) + ifc = ifcopenshell.file() + run("Durations are treated as strings 2/2", facet=facet, inst=ifc.createIfcClassification(Name="Name", EditionDate="P2D"), expected=False) + + restriction = ids.Restriction(options=".*Name.*", type="pattern") + facet = ids.Attribute(name=restriction) + ifc = ifcopenshell.file() + run("Name restrictions may be used 1/4", facet=facet, inst=ifc.createIfcMaterialLayerSet(MaterialLayers=[ifc.createIfcMaterialLayer(LayerThickness=1)], LayerSetName="Foo"), expected=True) + ifc = ifcopenshell.file() + run("Name restrictions may be used 2/4", facet=facet, inst=ifc.createIfcMaterialConstituentSet(Name="Foo"), expected=True) + + restriction = ids.Restriction(options=["Name", "Description"], type="enumeration") + facet = ids.Attribute(name=restriction) + ifc = ifcopenshell.file() + run("Name restrictions may be used 3/4", facet=facet, inst=ifc.createIfcWall(Name="Foo"), expected=False) + ifc = ifcopenshell.file() + run("Name restrictions may be used 4/4", facet=facet, inst=ifc.createIfcWall(Name="Foo", Description="Bar"), expected=True) + + restriction = ids.Restriction(options=["Foo", "Bar"], type="enumeration") + facet = ids.Attribute(name="Name", value=restriction) + ifc = ifcopenshell.file() + run("Value restrictions may be used 1/3", facet=facet, inst=ifc.createIfcWall(Name="Foo"), expected=True) + ifc = ifcopenshell.file() + run("Value restrictions may be used 2/3", facet=facet, inst=ifc.createIfcWall(Name="Bar"), expected=True) + ifc = ifcopenshell.file() + run("Value restrictions may be used 3/3", facet=facet, inst=ifc.createIfcWall(Name="Foobar"), expected=False) + + ifc = ifcopenshell.file() + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + wall_type.Description = "Foobar" + run("Attributes are not inherited by the occurrence", facet=facet, inst=wall, expected=False) + + restriction = ids.Restriction(options=["42"], type="enumeration", base="string") + facet = ids.Attribute(name="RefractionIndex", value=restriction) + ifc = ifcopenshell.file() + run("Typecast checking may also occur within enumeration restrictions", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42), expected=True) + + restriction = ids.Restriction( + options={"minInclusive": 42, "maxInclusive": 42}, type="bounds", base="decimal" + ) + facet = ids.Attribute(name="RefractionIndex", value=restriction) + ifc = ifcopenshell.file() + run("Strict numeric checking may be done with a bounds restriction", facet=facet, inst=ifc.createIfcSurfaceStyleRefraction(RefractionIndex=42), expected=True) + + def test_creating_a_classification_facet(self): + facet = ids.Classification() + assert facet.asdict() == {} + facet = ids.Classification(value="value", system="system") + assert facet.asdict() == {"value": {"simpleValue": "value"}, "system": {"simpleValue": "system"}} + facet = ids.Classification( + value="value", + system="system", + uri="https://test.com", + minOccurs="0", + maxOccurs="unbounded", + instructions="instructions", + ) + assert facet.asdict() == { + "value": {"simpleValue": "value"}, + "system": {"simpleValue": "system"}, + "@uri": "https://test.com", + "@minOccurs": "0", + "@maxOccurs": "unbounded", + "@instructions": "instructions", + } + + def test_filtering_using_a_classification_facet(self): + set_facet("classification") + + library = ifcopenshell.file() + system_a = library.createIfcClassification(Name="Foobar") + ref1 = library.createIfcClassificationReference(Identification="1", ReferencedSource=system_a) + ref11 = library.createIfcClassificationReference(Identification="11", ReferencedSource=ref1) + ref2 = library.createIfcClassificationReference(Identification="2", ReferencedSource=system_a) + ref22 = library.createIfcClassificationReference(Identification="22", ReferencedSource=ref2) + system_b = library.createIfcClassification(Name="Foobaz") + refx = library.createIfcClassificationReference(Identification="X", ReferencedSource=system_b) + + ifc = ifcopenshell.file() + project = ifc.createIfcProject() + system_a = ifcopenshell.api.run("classification.add_classification", ifc, classification=system_a) + element0 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + element1 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + ifcopenshell.api.run( + "classification.add_reference", ifc, product=element1, reference=ref1, classification=system_a + ) + element11 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + ifcopenshell.api.run( + "classification.add_reference", ifc, product=element11, reference=ref11, classification=system_a + ) + element22 = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + ifcopenshell.api.run( + "classification.add_reference", + ifc, + product=element22, + reference=ref22, + classification=system_a, + is_lightweight=False, + ) + + facet = ids.Classification() + run("A classification facet with no data matches any classification 1/2", facet=facet, inst=element0, expected=False) + run("A classification facet with no data matches any classification 2/2", facet=facet, inst=element1, expected=True) + + facet = ids.Classification(value="1") + run("Values should match exactly if lightweight classifications are used", facet=facet, inst=element1, expected=True) + + facet = ids.Classification(value="2") + run("Values match subreferences if full classifications are used (e.g. EF_25_10 should match EF_25_10_25, EF_25_10_30, etc)", facet=facet, inst=element22, expected=True) + + facet = ids.Classification(system="Foobar") + run("Systems should match exactly 1/5", facet=facet, inst=project, expected=True) + run("Systems should match exactly 2/5", facet=facet, inst=element0, expected=False) + run("Systems should match exactly 3/5", facet=facet, inst=element1, expected=True) + run("Systems should match exactly 4/5", facet=facet, inst=element11, expected=True) + run("Systems should match exactly 5/5", facet=facet, inst=element22, expected=True) + + restriction = ids.Restriction(options="1.*", type="pattern") + facet = ids.Classification(value=restriction) + run("Restrictions can be used for values 1/3", facet=facet, inst=element1, expected=True) + run("Restrictions can be used for values 2/3", facet=facet, inst=element11, expected=True) + run("Restrictions can be used for values 3/3", facet=facet, inst=element22, expected=False) + + restriction = ids.Restriction(options="Foo.*", type="pattern") + facet = ids.Classification(system=restriction) + run("Restrictions can be used for systems 1/2", facet=facet, inst=element0, expected=False) + run("Restrictions can be used for systems 2/2", facet=facet, inst=element1, expected=True) + + facet = ids.Classification(system="Foobar", value="1") + run("Both system and value must match (all, not any) if specified 1/2", facet=facet, inst=element1, expected=True) + run("Both system and value must match (all, not any) if specified 2/2", facet=facet, inst=element11, expected=False) + + # IFC doesn't yet formally specify how inheritance and overrides work here. We follow these rules: + # https://github.com/buildingSMART/IFC4.3.x-development/issues/475 + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + ifcopenshell.api.run("classification.add_reference", ifc, product=wall, reference=ref11, classification=system_a) + ifcopenshell.api.run( + "classification.add_reference", ifc, product=wall_type, reference=ref22, classification=system_a + ) + + system_b = ifcopenshell.api.run("classification.add_classification", ifc, classification=system_b) + ifcopenshell.api.run( + "classification.add_reference", ifc, product=wall_type, reference=refx, classification=system_b + ) + + facet = ids.Classification(value="11") + run("Occurrences override the type classification per system 1/3", facet=facet, inst=wall, expected=True) + facet = ids.Classification(value="22") + run("Occurrences override the type classification per system 2/3", facet=facet, inst=wall, expected=False) + facet = ids.Classification(value="X") + run("Occurrences override the type classification per system 3/3", facet=facet, inst=wall, expected=True) + + def test_creating_a_property_facet(self): + facet = ids.Property() + assert facet.asdict() == { + "propertySet": {"simpleValue": "Property_Set"}, + "name": {"simpleValue": "PropertyName"}, + } + facet = ids.Property( + propertySet="propertySet", + name="name", + value="value", + measure="String", + uri="https://test.com", + minOccurs="0", + maxOccurs="unbounded", + instructions="instructions", + ) + assert facet.asdict() == { + "propertySet": {"simpleValue": "propertySet"}, + "name": {"simpleValue": "name"}, + "value": {"simpleValue": "value"}, + "@measure": "String", + "@uri": "https://test.com", + "@minOccurs": "0", + "@maxOccurs": "unbounded", + "@instructions": "instructions", + } + + def test_filtering_using_a_property_facet(self): + set_facet("property") + + ifc = ifcopenshell.file() + ifc.createIfcProject() + # Milli prefix used to check measurement conversions + lengthunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT", name="METRE", prefix="MILLI") + areaunit = ifcopenshell.api.run( + "unit.add_si_unit", ifc, unit_type="AREAUNIT", name="SQUARE_METRE", prefix="MILLI" + ) + volumeunit = ifcopenshell.api.run( + "unit.add_si_unit", ifc, unit_type="VOLUMEUNIT", name="CUBIC_METRE", prefix="MILLI" + ) + timeunit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="TIMEUNIT", name="SECOND") + ifcopenshell.api.run("unit.assign_unit", ifc, units=[lengthunit, areaunit, volumeunit, timeunit]) + + # A name check by itself only checks that a property is non-null and non empty string + # The logic is that unfortunately most BIM users cannot differentiate between the two. + facet = ids.Property(propertySet="Foo_Bar", name="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + run("", facet=facet, inst=element, expected=False) + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": None}) + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + run("", facet=facet, inst=element, expected=True) + + # A simple value checks an exact case-sensitive match + facet = ids.Property(propertySet="Foo_Bar", name="Foo", value="Bar") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Baz"}) + run("", facet=facet, inst=element, expected=False) + + # Simple values only check string matches + facet = ids.Property(propertySet="Foo_Bar", name="Foo", value="1") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "1"}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcInteger(1)}) + run("", facet=facet, inst=element, expected=False) + + # Restrictions are supported for property sets. If multiple are matched, all must satisfy requirements. + restriction = ids.Restriction(options="Foo_.*", type="pattern") + facet = ids.Property(propertySet=restriction, name="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + run("", facet=facet, inst=element, expected=True) + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Baz") + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + run("", facet=facet, inst=element, expected=True) + + # Restrictions are supported for names. If multiple are matched, all must satisfy requirements. + restriction = ids.Restriction(options="Foo.*", type="pattern") + facet = ids.Property(propertySet="Foo_Bar", name=restriction, value="x") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x"}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "x"}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "y"}) + run("", facet=facet, inst=element, expected=False) + + # Restrictions are supported for values. If multiple are matched, all must satisfy requirements. + restriction1 = ids.Restriction(options="Foo.*", type="pattern") + restriction2 = ids.Restriction(options=["x", "y"], type="enumeration") + facet = ids.Property(propertySet="Foo_Bar", name=restriction1, value=restriction2) + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "y"}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "z"}) + run("", facet=facet, inst=element, expected=False) + + # Restrictions may be used to check basic data primitives + restriction = ids.Restriction(options=[42.12], type="enumeration", base="decimal") + facet = ids.Property(propertySet="Foo_Bar", name="Foobar", value=restriction) + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": 42.12}) + run("", facet=facet, inst=element, expected=True) + restriction = ids.Restriction(options=[42], type="enumeration", base="integer") + facet = ids.Property(propertySet="Foo_Bar", name="Foobar", value=restriction) + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": 42}) + run("", facet=facet, inst=element, expected=True) + restriction = ids.Restriction(options=[True], type="enumeration", base="boolean") + facet = ids.Property(propertySet="Foo_Bar", name="Foobar", value=restriction) + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": True}) + run("", facet=facet, inst=element, expected=True) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": False}) + run("", facet=facet, inst=element, expected=False) + + # When measure is not specified, no unit conversion is done and only primitives are checked + restriction = ids.Restriction(options=[42.12], type="enumeration", base="decimal") + facet = ids.Property(propertySet="Foo_Bar", name="Foobar", value=restriction) + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": 42.12}) + run("", facet=facet, inst=element, expected=True) + + # Measure may be used to specify an IFC data type + restriction = ids.Restriction(options=[2], type="enumeration", base="decimal") + facet = ids.Property(propertySet="Foo_Bar", name="Foo", value=restriction, measure="Time") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcMassMeasure(2)}) + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcTimeMeasure(2)}) + run("", facet=facet, inst=element, expected=True) + + # Measure also implies that a unit matters, and so a conversion shall take place to SI units + restriction = ids.Restriction(options=[2], type="enumeration", base="decimal") + facet = ids.Property(propertySet="Foo_Bar", name="Foo", value=restriction, measure="Length") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcLengthMeasure(2)}) + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcLengthMeasure(2000)}) + run("", facet=facet, inst=element, expected=True) + + # The facet checks inherited properties from the type + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=wall_type, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + facet = ids.Property(propertySet="Foo_Bar", name="Foo") + run("", facet=facet, inst=wall, expected=True) + run("", facet=facet, inst=wall_type, expected=True) + + # The facet checks overriden properties from the occurrence + wall = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + wall_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=wall_type, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Baz"}) + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=wall, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) + facet = ids.Property(propertySet="Foo_Bar", name="Foo", value="Bar") + run("", facet=facet, inst=wall, expected=True) + run("", facet=facet, inst=wall_type, expected=False) + + def test_creating_a_material_facet(self): + facet = ids.Material() + assert facet.asdict() == {} + facet = ids.Material( + value="value", uri="https://test.com", minOccurs="0", maxOccurs="unbounded", instructions="instructions" + ) + assert facet.asdict() == { + "value": {"simpleValue": "value"}, + "@uri": "https://test.com", + "@minOccurs": "0", + "@maxOccurs": "unbounded", + "@instructions": "instructions", + } + + def test_filtering_using_a_material_facet(self): + set_facet("material") + + facet = ids.Material() + ifc = ifcopenshell.file() + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + run("Elements without a material always fail", facet=facet, inst=element, expected=False) + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material) + run("Elements with any material will pass an empty material facet", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + facet = ids.Material(value="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material) + run("Material with no data will fail a value check", facet=facet, inst=element, expected=False) + material.Name = "Foo" + run("A material name may pass the value check", facet=facet, inst=element, expected=True) + material.Name = "Bar" + material.Category = "Foo" + run("A material category may pass the value check", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + facet = ids.Material(value="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material_set = ifcopenshell.api.run("material.add_material_set", ifc, set_type="IfcMaterialList") + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material_set) + run("A material list with no data will fail a value check", facet=facet, inst=element, expected=False) + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.add_list_item", ifc, material_list=material_set, material=material) + material.Name = "Foo" + run("Any material Name in a list will pass a value check", facet=facet, inst=element, expected=True) + material.Name = "Bar" + material.Category = "Foo" + run("Any material Category in a list will pass a value check", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + facet = ids.Material(value="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material_set = ifcopenshell.api.run("material.add_material_set", ifc, set_type="IfcMaterialLayerSet") + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material_set) + material = ifcopenshell.api.run("material.add_material", ifc) + layer = ifcopenshell.api.run("material.add_layer", ifc, layer_set=material_set, material=material) + layer.Name = "Foo" + run("Any layer Name in a layer set will pass a value check", facet=facet, inst=element, expected=True) + layer.Name = "Bar" + layer.Category = "Foo" + run("Any layer Category in a layer set will pass a value check", facet=facet, inst=element, expected=True) + layer.Category = "Bar" + material.Name = "Foo" + run("Any material Name in a layer set will pass a value check", facet=facet, inst=element, expected=True) + material.Name = "Bar" + material.Category = "Foo" + run("Any material Category in a layer set will pass a value check", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + facet = ids.Material(value="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material_set = ifcopenshell.api.run("material.add_material_set", ifc, set_type="IfcMaterialProfileSet") + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material_set) + material = ifcopenshell.api.run("material.add_material", ifc) + profile = ifcopenshell.api.run("material.add_profile", ifc, profile_set=material_set, material=material) + profile.Name = "Foo" + profile.Profile = ifc.createIfcCircleProfileDef("AREA", None, None, 1) + run("Any profile Name in a profile set will pass a value check", facet=facet, inst=element, expected=True) + profile.Name = "Bar" + profile.Category = "Foo" + run("Any profile Category in a profile set will pass a value check", facet=facet, inst=element, expected=True) + profile.Category = "Bar" + material.Name = "Foo" + run("Any material Name in a profile set will pass a value check", facet=facet, inst=element, expected=True) + material.Name = "Bar" + material.Category = "Foo" + run("Any material category in a profile set will pass a value check", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + facet = ids.Material(value="Foo") + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material_set = ifcopenshell.api.run("material.add_material_set", ifc, set_type="IfcMaterialConstituentSet") + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material_set) + run("A constituent set with no data will fail a value check", facet=facet, inst=element, expected=False) + material = ifcopenshell.api.run("material.add_material", ifc) + constituent = ifcopenshell.api.run( + "material.add_constituent", ifc, constituent_set=material_set, material=material + ) + constituent.Name = "Foo" + run("Any constituent Name in a constituent set will pass a value check", facet=facet, inst=element, expected=True) + constituent.Name = "Bar" + constituent.Category = "Foo" + run("Any constituent Category in a constituent set will pass a value check", facet=facet, inst=element, expected=True) + constituent.Category = "Bar" + material.Name = "Foo" + run("Any material Name in a constituent set will pass a value check", facet=facet, inst=element, expected=True) + material.Name = "Bar" + material.Category = "Foo" + run("Any material Category in a constituent set will pass a value check", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=element, relating_type=element_type) + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element_type, material=material) + material.Name = "Foo" + facet = ids.Material(value="Foo") + run("Occurrences can inherit materials from their types", facet=facet, inst=element, expected=True) + + ifc = ifcopenshell.file() + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", ifc, related_object=element, relating_type=element_type) + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element_type, material=material) + material.Name = "Bar" + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material) + material.Name = "Foo" + facet = ids.Material(value="Foo") + run("Occurrences can override materials from their types", facet=facet, inst=element, expected=True) + + def test_creating_a_partof_facet(self): + facet = ids.PartOf() + assert facet.asdict() == {"@entity": "IfcSystem"} + facet = ids.PartOf(entity="IfcGroup") + assert facet.asdict() == {"@entity": "IfcGroup"} + + def test_filtering_using_a_partof_facet(self): + ifc = ifcopenshell.file() + + # An IfcElementAssembly entity only passes those who are part of an assembly + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + ifcopenshell.api.run("aggregate.assign_object", ifc, product=subelement, relating_object=element) + facet = ids.PartOf(entity="IfcElementAssembly") + run("", facet=facet, inst=element, expected=False) + run("", facet=facet, inst=subelement, expected=True) + + # An IfcElementAssembly strictly checks that the whole is an IfcElementAssembly class + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSlab") + subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcBeam") + ifcopenshell.api.run("aggregate.assign_object", ifc, product=subelement, relating_object=element) + facet = ids.PartOf(entity="IfcElementAssembly") + run("", facet=facet, inst=subelement, expected=False) + + # A nested subelement still passes so long as one of its parents is an IfcElementAssembly + # TODO nononono + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + subelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcSlab") + subsubelement = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcBeam") + ifcopenshell.api.run("aggregate.assign_object", ifc, product=subelement, relating_object=element) + ifcopenshell.api.run("aggregate.assign_object", ifc, product=subsubelement, relating_object=subelement) + facet = ids.PartOf(entity="IfcElementAssembly") + run("", facet=facet, inst=subsubelement, expected=True) + + # An IfcGroup only checks that a group is assigned without any other logic + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + group = ifcopenshell.api.run("group.add_group", ifc) + facet = ids.PartOf(entity="IfcGroup") + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + run("", facet=facet, inst=element, expected=True) + + # An IfcGroup can be passed by subtypes + # TODO: wrong, subtypes should not be matched + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + group = ifc.createIfcInventory() + facet = ids.PartOf(entity="IfcGroup") + ifcopenshell.api.run("group.assign_group", ifc, product=element, group=group) + run("", facet=facet, inst=element, expected=True) + + # An IfcSystem only checks that a system is assigned without any other logic + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + system = ifcopenshell.api.run("system.add_system", ifc) + facet = ids.PartOf(entity="IfcSystem") + run("", facet=facet, inst=element, expected=False) + ifcopenshell.api.run("system.assign_system", ifc, product=element, system=system) + run("", facet=facet, inst=element, expected=True) + + # An IfcSystem allows subtypes + # TODO: wrong, subtypes should not be matched + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcElementAssembly") + system = ifcopenshell.api.run("system.add_system", ifc, ifc_class="IfcDistributionSystem") + ifcopenshell.api.run("system.assign_system", ifc, product=element, system=system) + facet = ids.PartOf(entity="IfcSystem") + run("", facet=facet, inst=element, expected=True) + + """ Creating IDS with restrictions """ + + def test_create_restrictions_enumeration(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(ids.Entity(name="Test_Name")) + r = ids.Restriction(options=["testA", "testB"], type="enumeration") + m = ids.Material(value=r) + i.specifications[0].requirements.append(m) + self.assertEqual(i.specifications[0].requirements[0].value, "testA") + self.assertEqual(i.specifications[0].requirements[0].value, "testB") + self.assertNotEqual(i.specifications[0].requirements[0].value, "testC") + + def test_create_restrictions_bounds(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(ids.Entity(name="Test_Name")) + r = ids.Restriction(options={"minInclusive": 0, "maxExclusive": 10}, type="bounds", base="integer") + p = ids.Property(propertySet="Test", name="Test", value=r) + i.specifications[0].requirements.append(p) + self.assertEqual(i.specifications[0].requirements[0].value, 0) + self.assertEqual(i.specifications[0].requirements[0].value, 5) + self.assertNotEqual(i.specifications[0].requirements[0].value, -1) + self.assertNotEqual(i.specifications[0].requirements[0].value, 10) + + def test_create_restrictions_pattern_simple(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(ids.Entity(name="Test_Name")) + r = ids.Restriction(options="[A-Z]{2,4}", type="pattern") + p = ids.Property(propertySet="Test", name="Test", value=r) + i.specifications[0].requirements.append(p) + self.assertEqual(i.specifications[0].requirements[0].value, "XYZ") + self.assertNotEqual(i.specifications[0].requirements[0].value, "abc") + self.assertNotEqual(i.specifications[0].requirements[0].value, "ABCDE") + self.assertNotEqual(i.specifications[0].requirements[0].value, "A") + + def test_create_restrictions_pattern_advanced(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(ids.Entity(name="Test_Name")) + r = ids.Restriction(options="(Wanddurchbruch|Deckendurchbruch).*", type="pattern") + p = ids.Property(propertySet="Test", name="Test", value=r) + i.specifications[0].requirements.append(p) + self.assertEqual(i.specifications[0].requirements[0].value, "Wanddurchbruch") + self.assertEqual(i.specifications[0].requirements[0].value, "Deckendurchbruch") + self.assertNotEqual(i.specifications[0].requirements[0].value, "Deeckendurchbruch") + + def test_create_restrictions_pattern_utf(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(ids.Entity(name="Test_Name")) + r = ids.Restriction(options="èêóòâôæøåążźćęóʑʒʓʔʕʗʘʙʚʛʜʝʞ", type="pattern") + p = ids.Property(propertySet="Test", name="Test", value=r) + i.specifications[0].requirements.append(p) + self.assertEqual(i.specifications[0].requirements[0].value, "èêóòâôæøåążźćęóʑʒʓʔʕʗʘʙʚʛʜʝʞ") + + def test_created_ids_to_xml(self): + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + e = ids.Entity(name="Test_Name", predefinedType="Test_PredefinedType") + c = ids.Classification(value="Test_Value", system="Test_System") + m = ids.Material(value="Test_Value") + re = ids.Restriction(options=["testA", "testB"], type="enumeration") + rb = ids.Restriction(options={"minInclusive": 0, "maxExclusive": 10}, type="bounds", base="integer") + rp1 = ids.Restriction(options="[A-Z]{2,4}", type="pattern") + rp2 = ids.Restriction(options="èêóòâôæøåążźćęóʑʒʓʔʕʗʘʙʚʛʜʝʞ", type="pattern") + p1 = ids.Property(propertySet="Test_PropertySet", name="Test_Parameter", value=re) + p2 = ids.Property(propertySet="Test_PropertySet", name="Test_Parameter", value=rb) + p3 = ids.Property(propertySet="Test_PropertySet", name="Test_Parameter", value=rp1) + p4 = ids.Property(propertySet="Test_PropertySet", name="Test_Parameter", value=rp2) + p5 = ids.Property(propertySet="Test_PropertySet", name="Test_Parameter", value=[re, rb, rp1]) + i.specifications[0].applicability.append(e) + i.specifications[0].applicability.append(m) + i.specifications[0].requirements.append(c) + i.specifications[0].requirements.append(p1) + i.specifications[0].requirements.append(p2) + i.specifications[0].requirements.append(p3) + i.specifications[0].requirements.append(p4) + i.specifications[0].requirements.append(p5) + fn = "TEST_FILE.xml" + result = i.to_xml(fn) + os.remove(fn) + self.assertTrue(result) + + +class TestIfcValidation(unittest.TestCase): + def test_creating_a_minimal_ids_and_validating(self): + specs = ids.Ids(title="Title") + spec = ids.Specification(name="Name") + spec.applicability.append(ids.Entity(name="IFCWALL")) + spec.requirements.append(ids.Attribute(name="Name", value="Waldo")) + specs.specifications.append(spec) + assert "http://standards.buildingsmart.org/IDS" in specs.to_string() + assert spec.status == None + + model = ifcopenshell.file() + wall = model.createIfcWall() + waldo = model.createIfcWall(Name="Waldo") + specs.validate(model) + + assert spec.status == False + assert set(spec.applicable_entities) == {wall, waldo} + assert spec.requirements[0].failed_entities == [wall] + + def test_creating_multiple_specifications(self): + specs = ids.Ids(title="Title") + spec = ids.Specification(name="Name") + spec.applicability.append(ids.Entity(name="IFCWALL")) + spec.requirements.append(ids.Attribute(name="Name", value="Waldo")) + specs.specifications.append(spec) + + spec2 = ids.Specification(name="Name") + spec2.applicability.append(ids.Entity(name="IFCWALL")) + spec2.requirements.append(ids.Attribute(name="Name", value="Waldo")) + specs.specifications.append(spec2) + + model = ifcopenshell.file() + wall = model.createIfcWall() + waldo = model.createIfcWall(Name="Waldo") + specs.validate(model) + + assert spec.status == False + assert set(spec.applicable_entities) == {wall, waldo} + assert spec.requirements[0].failed_entities == [wall] + assert spec2.requirements[0].failed_entities == [wall] + + def test_validate_simple(self): + return # TODO + # Same test as in reporting... + ids_file = ids.open(IDS_URL) + report = ids.SimpleHandler() + logger.addHandler(report) + ids_file.validate(ifc_file) + self.assertEqual(len(report.statements), 5) + logger.handlers.pop() + + def test_validate_all_facets(self): + # Those are true: + e = ids.Entity(name="IfcWall") + p1 = ids.Property(propertySet="MySet", name="Param1", value="banan") + p2 = ids.Property(propertySet="MySet", name="Param2", value=120.0) + p3 = ids.Property(propertySet="Pset_WallCommon", name="LoadBearing", value=False) + # Those are false: + p4 = ids.Property(propertySet="MySet", name="Param1", value="orange") + p5 = ids.Property(propertySet="MySet", name="Param2", value=123.4) + p6 = ids.Property(propertySet="Pset_WallCommon", name="LoadBearing", value=True) + + i = ids.Ids(title="My IDS") + i.specifications.append(ids.Specification(name="Test_Specification")) + i.specifications[0].applicability.append(e) + i.specifications[0].requirements.append(p1) + i.specifications[0].requirements.append(p2) + i.specifications[0].requirements.append(p3) + i.specifications[0].requirements.append(p4) + i.specifications[0].requirements.append(p5) + i.specifications[0].requirements.append(p6) + + report = ids.SimpleHandler(report_valid=True) + logger.addHandler(report) + + i.validate(ifc_file) + # TODO self.assertEqual(len(report.statements), 27) #there are 5 walls in the IFC, one passed 3 criteria, est should fail (27 failures) + logger.handlers.pop() + + """ Validating IDS files with restrictions """ + + def test_validate_restrictions_enumeration(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), + "Sample-BIM-Files/IDS/", + "IDS_Wall_needs_property_with_restriction_enumeration.xml", + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + [ + x["@value"] + for x in ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["enumeration"] + ], + ["testA", "testB"], + ) + # TODO actual test of validation result + # self.assertTrue( ) + + def test_validate_restrictions_boundsInclusive(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_property_with_restriction_bounds.xml" + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["minInclusive"]["@value"], + "0", + ) + # TODO actual test of validation result + # self.assertTrue( ) + + def test_validate_restrictions_boundsExclusive(self): + # TODO + pass + + def test_validate_restrictions_pattern_simple(self): + return # TODO + IDS_URL = os.path.join( + os.path.dirname(__file__), "Sample-BIM-Files/IDS/", "IDS_Wall_needs_property_with_restriction_pattern.xml" + ) + ids_file = ids.open(IDS_URL) + self.assertEqual(ids_file.specifications[0].requirements[0].node["name"]["simpleValue"], "Test_Parameter") + self.assertEqual( + ids_file.specifications[0].requirements[0].node["value"]["restriction"][0]["pattern"]["@value"], + "[A-Z]{2,4}", + ) + # TODO actual test of validation result + # self.assertTrue( ) + + +class TestIdsReporting(unittest.TestCase): + def test_simple_report(self): + return # TODO + # Same test as in validation... + ids_file = ids.open(IDS_URL) + report = ids.SimpleHandler() + logger.addHandler(report) + ids_file.validate(ifc_file) + self.assertEqual(len(report.statements), 5) + logger.handlers.pop() + + def test_bcf_report(self): + return # TODO + ids_file = ids.open(IDS_URL) + fn = os.path.join(tempfile.gettempdir(), "test.bcf") + bcf_handler = ids.BcfHandler(project_name="Default IDS Project", author="your@email.com", filepath=fn) + logger.addHandler(bcf_handler) + ids_file.validate(ifc_file) + my_bcfxml = bcfxml.load(fn) + topics = my_bcfxml.get_topics() + self.assertEqual(len(topics), 5) + logger.handlers.pop()