diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 504560bff6..208232f882 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -37,9 +37,7 @@ from typing import Any from . import ifcopenshell_wrapper from .entity_instance import entity_instance -from lark import Lark, Transformer -from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken - +from ifcopenshell.util.mvd_info import MvdInfo, LARK_AVAILABLE if TYPE_CHECKING: import ifcopenshell.util.schema @@ -211,106 +209,6 @@ NO_HEADER = ifcopenshell_wrapper.file_open_status.NO_HEADER UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX -mvd_grammar = r''' - start: entry+ - - entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition - | "Comment" "[" comment_text "]" -> comment - | GENERIC_KEYWORD "[" value_list_set "]" -> dynamic_option - - %declare COMMENT_TEXT // Ensure Lark treats it with higher priority - - GENERIC_KEYWORD: /[A-Za-z0-9_]+/ - - simple_value_list: value ("," value)* - - value_list_set: value_set (";" value_set)* - - value_set: set_name ":" simple_value_list - - set_name: /[A-Za-z0-9_]+/ - - value: /[A-Za-z0-9 _\.-]+/ - - comment_text: /[^\[\]]+/ // Free text inside COMMENT brackets - - %import common.WS - %ignore WS -''' - -parser = Lark(mvd_grammar, parser='lalr') -class DescriptionTransform(Transformer): - def __init__(self): - self.mvd = [] - self.keywords = set() - self.comments = "" - self.exchangerequirement = "" - self.option = '' - - - def view_definition(self, args): - self.keywords.add('mvd') - self.mvd.extend(args[0]) - - - def dynamic_option(self, args): - """ - e.g. in case of 'Remark' as optional keyword in the description - The value can be retrieved through DescriptionTransform.remark - """ - key = str(args[0]).lower() - attr_name = f"{key}" - if attr_name not in self.keywords: - setattr(self, attr_name, {}) - self.keywords.add(attr_name) - dynamic_dict = getattr(self, attr_name) - for value_set in args[1]: - set_name, *values = value_set - dynamic_dict[set_name] = values if len(values) > 1 else values[0] - - - def comment(self, args): - self.keywords.add('comment') - self.comments = " ".join(str(child) for child in args[0].children).strip() - - def simple_value_list(self, args): - return [str(arg) for arg in args] - - def value_list_set(self, args): - return args - - def value_set(self, args): - return [str(args[0])] + args[1] - - def value(self, args): - return str(args[0]) - - def set_name(self, args): - return str(args[0]) - - @property - def other_keywords(self): - """" - The predefined keywords are 'ViewDefinition', 'Option', 'Comment', 'ExchangeRequirement' and 'Option' - Keywords in the description not from this lists are returned - """ - return {k for k in self.keywords if k not in {'mvd', 'comment', 'exchangerequirement', 'option'}} - - -def parse_mvd(description): - text = ' '.join(description) - parser = Lark(mvd_grammar, parser='lalr') - parsed_description = DescriptionTransform() - try: - if not text: - parsed_description.mvd = None - return parsed_description - parse_tree = parser.parse(text) - parsed_description.transform(parse_tree) - except (UnexpectedCharacters, UnexpectedEOF, UnexpectedToken) as e: - parsed_description.mvd = None - return parsed_description - class file: """Base class for containing IFC files. @@ -409,7 +307,6 @@ class file: import weakref file_dict[self.file_pointer()] = weakref.ref(self) - self.parsed_description = parse_mvd(self.wrapped_data.header.file_description.description) def __del__(self) -> None: # Avoid infinite recursion if file is failed to initialize @@ -569,68 +466,16 @@ class file: number = re.search(prefix + r"(\d)", schema) version.append(int(number.group(1)) if number else 0) return tuple(version) - - @property - def mvd(self) -> str: - """ - View Definition supported by the exporting application that is reflected in the IFC file - For example: - “CoordinationView” - | “PresentationView” - | “StructuralAnalysisView” - | “FMHandOverView” - | “QuantityTakeOffAddOnView” - | “SpaceBoundary1stLevelAddOnView” - | “SpaceBoundary2ndLevelAddOnView” - """ - return ','.join(self.parsed_description.mvd) - - @property - def mvd_comments(self) -> str: - """ - For example; 'Comment [This - export contains Boolean Operation geometry that may not be fully - supported by other application]' - f.mvd_comments - 'This export contains Boolean Operation geometry that may not be fully supported by other application' - """ - return str(self.parsed_description.comments) - - @property - def mvd_exchange_requirements(self) -> str: - return str(self.parsed_description.exchangerequirement) - - @property - def mvd_options(self) -> str: - """ - Option being used by the exporting application for e.g. quality control and debugging - a. this is an optional field providing informal information, the values for the Option keyword - reflect the export settings of the exporting software application, those settings may be - specific to the exporting software - For example: - > "{'ExcludedObjects': [' Stair', ' Ramp', ' Space'], 'SplitLevel': ' On'}" - """ - return str(self.parsed_description.option) - @property - def mvd_keywords(self) -> str: - """ - Returns all keywords from description, e.g. - f.mvd_keywords - "{'exchangerequirement', 'mvd', 'option', 'comment', 'remark'}"_ - """ - return str(self.parsed_description.keywords) - - @property - def mvd_optional_keyword_fields(self) -> str: - """" - Returns fields from keywords other than 'ViewDefinition', 'Option', 'Comment' and 'ExchangeRequirement'. - For example, in case of - 'REMARK [SomeKey: SomeValue; AnotherKey: AnotherValue]' - file.mvd_optional_keywords_fields == "{'remark': {'SomeKey': ' SomeValue', 'AnotherKey': ' AnotherValue'}}" - """ - return str({kw: getattr(self.parsed_description, kw) for kw in self.parsed_description.other_keywords}) + def mvd(self): + if not LARK_AVAILABLE: + return None + file_description = self.wrapped_data.header.file_description + return MvdInfo( + get_description=lambda: file_description.description, + set_description=lambda d: setattr(file_description, "description", tuple(d)) + ) def __getattr__(self, attr) -> Union[Any, Callable[..., ifcopenshell.entity_instance]]: if attr[0:6] == "create": diff --git a/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py b/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py new file mode 100644 index 0000000000..573f4945fa --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/util/mvd_info.py @@ -0,0 +1,310 @@ +try: + from lark import Lark, Transformer + from lark.exceptions import UnexpectedCharacters, UnexpectedEOF, UnexpectedToken + LARK_AVAILABLE = True +except ImportError: + LARK_AVAILABLE = False + +from typing import Callable +import re + +if LARK_AVAILABLE: + mvd_grammar = r''' + start: entry+ + + entry: "ViewDefinition" "[" simple_value_list "]" -> view_definition + | "Comment" "[" simple_value_list "]" -> comment + | "ExchangeRequirement" "[" other_keyword "]" -> exchangerequirement + | "Option" "[" other_keyword "]" -> option + | GENERIC_KEYWORD "[" dynamic_option_word "]" -> dynamic_option + + GENERIC_KEYWORD: /[A-Za-z0-9_]+/ + + simple_value_list: value ("," value)* + + value_list_set: value_set (";" value_set)* + + value_set: set_name ":" simple_value_list + + set_name: /[A-Za-z0-9_]+/ + + value: /[A-Za-z0-9 _\.-]+/ + + other_keyword: /[^\[\]]+/ + + dynamic_option_word: /[^\[\]]+/ + + %import common.WS + %ignore WS + ''' + + parser = Lark(mvd_grammar, parser='lalr') + + class DescriptionTransform(Transformer): + def __init__(self): + self.view_definitions = [] + self.keywords = set() + self.comments = "" + self.exchange_requirements = "" + self.options = "" + self._dynamic = {} + + def view_definition(self, args): + self.keywords.add('view_definitions') + self.view_definitions.extend(args[0]) + + def store_text_attribute(self, args, keyword): + self.keywords.add(keyword) + setattr(self, keyword, " ".join(" ".join(str(child) for child in args[0].children).split())) + + def comment(self, args): + self.keywords.add("comments") + self.comments = args[0] if len(args[0]) > 1 else args[0][0] + + def exchangerequirement(self, args): + self.store_text_attribute(args, "exchange_requirements") + + def option(self, args): + if v := parse_semicolon_separated_kv(" ".join(" ".join(str(child) for child in args[0].children).split())): + setattr(self, 'options', v) + else: + self.store_text_attribute(args, "options") + + def dynamic_option(self, args): + try: + original_keyword = str(args[0]) + key = original_keyword.lower() + raw_text = args[1].children[0].value + parsed_value = parse_semicolon_separated_kv(raw_text) + self._dynamic[key] = (parsed_value, original_keyword) + self.keywords.add(key) + setattr(self, key, parsed_value) + except Exception: + setattr(self, key, None) + + def simple_value_list(self, args): + return [str(arg) for arg in args] + + def value_list_set(self, args): + return args + + def value_set(self, args): + return [str(args[0])] + args[1] + + def value(self, args): + return str(args[0]) + + def set_name(self, args): + return str(args[0]) + + def parse_mvd(description): + text = ' '.join(description) + parsed_description = DescriptionTransform() + try: + if not text: + parsed_description.view_definitions = None + return parsed_description + parse_tree = parser.parse(text) + parsed_description.transform(parse_tree) + except (UnexpectedCharacters, UnexpectedEOF, UnexpectedToken): + parsed_description.view_definitions = None + return parsed_description + + def parse_semicolon_separated_kv(text: str) -> dict[str, str | list[str]] | None: + if not re.search(r'\w+\s*:\s*[^:]+', text): + return None + result = {} + try: + pairs = text.split(';') + for pair in pairs: + if ':' in pair: + key, value = pair.split(':', 1) + key = key.strip() + values = [v.strip() for v in value.split(',')] + result[key] = values[0] if len(values) == 1 else values + return result + except Exception: + return None +else: + def parse_mvd(description): + return None + + +class MvdInfo: + def __init__(self, get_description: Callable[[], list[str]], set_description: Callable[[list[str]], None]): + self._get_description = get_description + self._set_description = set_description + self._parsed = None + + def _ensure_parsed(self): + if not LARK_AVAILABLE: + return + if self._parsed is None: + self._parsed = parse_mvd(self.description) + + @property + def description(self) -> list[str]: + return self._get_description() + + @description.setter + def description(self, new_description: list[str]): + self._set_description(new_description) + self._parsed = None + + @property + def view_definitions(self): + self._ensure_parsed() + return ', '.join(' '.join(item.split()) for item in self._parsed.view_definitions) if self._parsed else None + + @view_definitions.setter + def view_definitions(self, new_value: str): + self._update_keyword("ViewDefinition", new_value) + + @property + def comments(self): + self._ensure_parsed() + comments = self._parsed.comments + comment_list = comments if isinstance(comments, list) else [comments] if comments else [] + return AutoCommitList( + comment_list, + callback=lambda val: self._update_keyword("Comment", val), + formatter=lambda lst: ", ".join(str(i) for i in lst) + ) + + @comments.setter + def comments(self, new_value: str | list[str]): + if isinstance(new_value, list): + value = ", ".join(new_value) + else: + value = str(new_value) + self._update_keyword("Comment", value) + + @property + def exchange_requirements(self): + self._ensure_parsed() + return self._parsed.exchange_requirements if self._parsed else None + + @exchange_requirements.setter + def exchange_requirements(self, new_value: str): + self._update_keyword("ExchangeRequirement", new_value) + + @property + def options(self): + self._ensure_parsed() + if isinstance(self._parsed.options, dict): + return DictionaryHandler(self._parsed.options, self, "Option") + return self._parsed.options if self._parsed else None + + @options.setter + def options(self, new_value: str): + self._update_keyword("Option", new_value) + + @property + def keywords(self): + self._ensure_parsed() + return self._parsed.keywords if self._parsed else set() + + def _update_keyword(self, keyword: str, new_value: str): + updated = False + new_line = f"{keyword} [{new_value}]" + lines = [] + for line in self.description: + if line.strip().startswith(f"{keyword} ["): + lines.append(new_line) + updated = True + else: + lines.append(line) + if not updated: + lines.append(new_line) + self.description = lines + + def __getattr__(self, name): + self._ensure_parsed() + if hasattr(self._parsed, '_dynamic'): + name_lc = name.lower() + if name_lc in self._parsed._dynamic: + value, original_keyword = self._parsed._dynamic[name_lc] + return DictionaryHandler(value, self, original_keyword) + raise AttributeError(f"'MvdInfo' object has no attribute '{name}'") + + def __dir__(self): + base = super().__dir__() + if self._parsed and hasattr(self._parsed, '_dynamic'): + return base + [kw for _, kw in self._parsed._dynamic.values()] + return base + + +class DictionaryHandler(dict): + def __init__(self, initial_data, mvdinfo, keyword): + super().__init__() + self._mvdinfo = mvdinfo + self._keyword = keyword + for k, v in initial_data.items(): + if isinstance(v, list): + super().__setitem__(k, AutoCommitList(v, self._commit)) + else: + super().__setitem__(k, v) + + def _commit(self): + new_value = "; ".join( + f"{k}: {', '.join(v) if isinstance(v, list) else v}" + for k, v in self.items() + ) + self._mvdinfo._update_keyword(self._keyword, new_value) + + def __setitem__(self, key, value): + if isinstance(value, list): + value = AutoCommitList(value, self._commit) + super().__setitem__(key, value) + self._commit() + + def __delitem__(self, key): + super().__delitem__(key) + self._commit() + + +class AutoCommitList(list): + "ensures keyword attributes are written back to ifcopenshell.file.header" + def __init__(self, iterable, callback, formatter=None): + super().__init__(iterable) + self._callback = callback + self._formatter = formatter + + def _commit(self): + if self._formatter: + self._callback(self._formatter(self)) + else: + self._callback() + + def append(self, item): + super().append(item) + self._commit() + + def extend(self, iterable): + super().extend(iterable) + self._commit() + + def insert(self, index, item): + super().insert(index, item) + self._commit() + + def remove(self, item): + super().remove(item) + self._commit() + + def pop(self, index=-1): + item = super().pop(index) + self._commit() + return item + + def clear(self): + super().clear() + self._commit() + + def __setitem__(self, index, value): + super().__setitem__(index, value) + self._commit() + + def __delitem__(self, index): + super().__delitem__(index) + self._commit() \ No newline at end of file diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_comment.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_comment.ifc new file mode 100644 index 0000000000..23ac19889a --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_comment.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]', 'Comment [Any]'),'2;1'); +FILE_NAME('Header.ifc','2025-02-13T15:58:45',('tricott'),('Trimble Inc.'),'TrimBimToIFC rel. 4.0.2','Trimble Inc. - SketchUp - 2025.0','IFC4 model'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_exchange_requirement.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_exchange_requirement.ifc new file mode 100644 index 0000000000..eb41ee1bea --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_exchange_requirement.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]', 'ExchangeRequirement [Any]'),'2;1'); +FILE_NAME('Header.ifc','2025-02-13T15:58:45',('tricott'),('Trimble Inc.'),'TrimBimToIFC rel. 4.0.2','Trimble Inc. - SketchUp - 2025.0','IFC4 model'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_options.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_options.ifc new file mode 100644 index 0000000000..78eb286f3e --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/contains_options.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]', 'Option [Any]'),'2;1'); +FILE_NAME('Header.ifc','2025-02-13T15:58:45',('tricott'),('Trimble Inc.'),'TrimBimToIFC rel. 4.0.2','Trimble Inc. - SketchUp - 2025.0','IFC4 model'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/dynamic_fields.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/dynamic_fields.ifc new file mode 100644 index 0000000000..045091c432 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/dynamic_fields.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]', 'ViewDefinition [QuantityTakeOffAddOnView]', 'Option [ExcludedObjects: Stair, Ramp, Space; SplitLevel: On]', 'ExchangeRequirement [CustomRequirement: Value1, Value2]', 'Remark [SomeKey: SomeValue; AnotherKey: AnotherValue]', 'Comment [This is a free text comment, or a comma-separated list of items]'),'2;1'); +FILE_NAME('Header example2.ifc', '2022-09-16T10:35:07', ('Evandro Alfieri'), ('buildingSMART Int.'), 'IFC Motor 1.0', 'Company - Application - 26.0.0.0', 'none'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/passing_header.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/passing_header.ifc new file mode 100644 index 0000000000..4f13ee88fe --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/passing_header.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [Alignment-basedView]'),'2;1'); +FILE_NAME('Header example2.ifc', '2022-09-16T10:35:07', ('Evandro Alfieri'), ('buildingSMART Int.'), 'IFC Motor 1.0', 'Company - Application - 26.0.0.0', 'none'); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/fixtures/mvd_parsing/two_views.ifc b/src/ifcopenshell-python/test/fixtures/mvd_parsing/two_views.ifc new file mode 100644 index 0000000000..540ffd5d79 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/mvd_parsing/two_views.ifc @@ -0,0 +1,30 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]', 'ViewDefinition[SpaceBoundaryAddonView]'),'2;1'); +FILE_NAME('Header example2.ifc', '2022-09-16T10:35:07', ('Evandro Alfieri'), ('buildingSMART Int.'), 'IFC Motor 1.0', 'Company - Application - 26.0.0.0', 'none'); +FILE_SCHEMA(('IFC2X3')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +#2=IFCORGANIZATION($,'',$,$,$); +#3=IFCPERSONANDORGANIZATION(#1,#2,$); +#4=IFCAPPLICATION(#2,'v0.7.0-6c9e130ca','IfcOpenShell-v0.7.0-6c9e130ca',''); +#5=IFCOWNERHISTORY(#3,#4,$,.NOTDEFINED.,$,#3,#4,1700419055); +#6=IFCDIRECTION((1.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCCARTESIANPOINT((0.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#8,#7,#6); +#10=IFCDIRECTION((0.,1.)); +#11=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,#10); +#12=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#13=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#14=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#15=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#16=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#17=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.017453292519943295),#16); +#18=IFCCONVERSIONBASEDUNIT(#12,.PLANEANGLEUNIT.,'DEGREE',#17); +#19=IFCUNITASSIGNMENT((#13,#14,#15,#18)); +#20=IFCPROJECT('0iDmeiiLP3AOllitM2Favn',#5,'',$,$,$,$,(#11),#19); +#21=IFCSITE('3rg2jGkIH10RFhrQsGZKRk',#5,$,$,$,$,$,$,$,$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcopenshell-python/test/test_mvd_info.py b/src/ifcopenshell-python/test/test_mvd_info.py new file mode 100644 index 0000000000..3335d9772e --- /dev/null +++ b/src/ifcopenshell-python/test/test_mvd_info.py @@ -0,0 +1,134 @@ +import os +import pytest +import ifcopenshell +from ifcopenshell.util import mvd_info +from ifcopenshell.util.mvd_info import parse_mvd + + +@pytest.fixture +def load_fixture(): + base_dir = os.path.join(os.path.dirname(__file__), "fixtures", "mvd_parsing") + def _load(filename): + return ifcopenshell.open(os.path.join(base_dir, filename)) + return _load + + +class TestViewDefinition: + def test_single_view(self, load_fixture): + f = load_fixture("passing_header.ifc") + assert f.mvd.view_definitions == "Alignment-basedView" + + def test_multiple_views(self, load_fixture): + f = load_fixture("two_views.ifc") + assert f.mvd.view_definitions == 'CoordinationView_V2.0, SpaceBoundaryAddonView' + + +class TestExchangeRequirements: + def test_parsing(self, load_fixture): + f = load_fixture("contains_exchange_requirement.ifc") + parsed = parse_mvd(f.mvd.description) + assert parsed.exchange_requirements == 'Any' + + def test_access_and_modification(self, load_fixture): + f = load_fixture("contains_exchange_requirement.ifc") + f.header.file_description.description = ( + 'ViewDefinition [Alignment-basedView]', + 'ExchangeRequirement [SomethingElse]' + ) + assert f.mvd.exchange_requirements == 'SomethingElse' + f.mvd.view_definitions = 'CoordinationView_V2.0' + assert f.mvd.view_definitions == 'CoordinationView_V2.0' + + +class TestComments: + def test_read_and_append(self, load_fixture): + f = load_fixture("contains_comment.ifc") + assert f.mvd.comments == ['Any'] + f.mvd.comments = ['SomethingElse'] + assert f.mvd.comments == ['SomethingElse'] + f.mvd.comments.append('AnotherComment') + assert f.mvd.comments == ['SomethingElse', ' AnotherComment'] + assert f.mvd.description[1] == 'Comment [SomethingElse, AnotherComment]' + + def test_comment_list_modifications(self, load_fixture): + f = load_fixture("contains_comment.ifc") + f.mvd.comments = '' + f.mvd.comments.append('OnlyOne') + assert 'OnlyOne' in f.mvd.comments + + f.mvd.comments.insert(0, 'FirstOne') + f.mvd.comments[0] == 'FirstOne' + + f.mvd.comments.pop() + assert f.mvd.comments[0] == 'FirstOne' + + del f.mvd.comments[0] + assert not f.mvd.comments + + +class TestOptions: + def test_string_options(self, load_fixture): + f = load_fixture("contains_options.ifc") + assert f.mvd.options == 'Any' + assert 'options' in f.mvd.keywords + + +class TestDynamicFields: + def test_options_modifications(self, load_fixture): + f = load_fixture("dynamic_fields.ifc") + + f.mvd.options['ExcludedObjects'].append('Chair') + f.mvd.options['SplitLevel'] = 'Off' + f.mvd.options['OtherAttr'] = 'SomeValue' + + assert f.mvd.description[2].startswith('Option [') + assert 'OtherAttr: SomeValue' in f.mvd.description[2] + assert f.mvd.description == f.header.file_description.description + + def test_remark_editing(self, load_fixture): + f = load_fixture("dynamic_fields.ifc") + assert f.mvd.remark == {'SomeKey': 'SomeValue', 'AnotherKey': 'AnotherValue'} + f.mvd.remark['AnotherKey'] = 'SometingElse' + f.mvd.remark['IncludedObjects'] = ['Floor', 'Roof'] + assert f.mvd.remark['AnotherKey'] == 'SometingElse' + assert f.mvd.remark['IncludedObjects'] == ['Floor', 'Roof'] + assert 'remark' in f.mvd.keywords + + def test_custom_dict_behavior(self, load_fixture): + f = load_fixture("dynamic_fields.ifc") + + # delete + del f.mvd.options['SplitLevel'] + assert not f.mvd.options.get('SplitLevel') + + # keys, values, items + assert set(f.mvd.remark.keys()) == {'SomeKey', 'AnotherKey'} + assert 'SomeValue' in f.mvd.remark.values() + assert ('SomeKey', 'SomeValue') in f.mvd.remark.items() + + # containment + assert 'SomeKey' in f.mvd.remark + assert 'MissingKey' not in f.mvd.remark + + +class TestKeywords: + @pytest.mark.parametrize("filename, expected_keywords", [ + ("contains_comment.ifc", {"view_definitions", "comments"}), + ("contains_exchange_requirement.ifc", {"view_definitions", "exchange_requirements"}), + ("contains_options.ifc", {"view_definitions", "options"}), + ("dynamic_fields.ifc", {"view_definitions", "exchange_requirements", "comments", "remark"}) + ]) + def test_keywords_present(self, load_fixture, filename, expected_keywords): + f = load_fixture(filename) + assert f.mvd.keywords == expected_keywords + + +class TestFallbackBehavior: + def test_parse_mvd_fallback(self, monkeypatch): + monkeypatch.setattr(mvd_info, "LARK_AVAILABLE", False) + mvd = mvd_info.MvdInfo( + get_description=lambda: ["ViewDefinition [ShouldNotParse]"], + set_description=lambda d: None + ) + assert mvd.view_definitions is None + assert mvd.keywords == set()