diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 714c304f5a..8ba9fe7703 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,14 +7,24 @@ assignees: '' --- -Replace this text describing what problem occurred and what you expected to happen instead. +**Error Description and Steps to Reproduce** + + **Attachments** + + **Debug information** + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 42394b3486..18044de82c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,4 +7,9 @@ assignees: '' --- -Replace this text and describe a feature you'd like us to add. If it's not obvious, explain why this feature is awesome. Note that feature requests must be specific and measurable. + +**Feature Description** + + diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml new file mode 100644 index 0000000000..57a4d8f707 --- /dev/null +++ b/.github/workflows/build_all.yml @@ -0,0 +1,21 @@ +name: Dispatch Build IfcOpenShell + +on: + workflow_dispatch: + +jobs: + trigger-workflows: + runs-on: ubuntu-latest + strategy: + matrix: + workflow: + - 'Build IfcOpenShell Linux' + - 'Build IfcOpenShell Linux ARM' + - 'Build IfcOpenShell OSX' + - 'Build IfcOpenShell WASM / Pyodide' + - 'Build IfcOpenShell Windows' + steps: + - name: Trigger binary build workflows + uses: benc-uk/workflow-dispatch@v1 + with: + workflow: ${{ matrix.workflow }} diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index d70cf0930c..4b968c8516 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -128,10 +128,8 @@ jobs: # # To force the link and overwrite all conflicting files: # brew link --overwrite python@3.13 - brew link --overwrite python@3.12 - brew link --overwrite python@3.13 # https://github.com/rust-lang/rustup/pull/3989/files - brew install --overwrite awscli + brew install --overwrite awscli | true - name: Upload .zip archives to S3 run: | diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 52f88ea629..d7f59ec18d 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -104,7 +104,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://ftp.nluug.nl/pub/graphics/blender/release/Blender4.3/blender-4.3.0-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender4.3/blender-4.3.2-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f92bc1c126..5845b9d8d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,6 @@ jobs: run: | python -m pip install --upgrade pip pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely - pip install mathutils pip install src/bcf --no-deps pip install https://github.com/Andrej730/aud/archive/refs/heads/master-reduced-size.zip diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml index 59fcf5c9f5..0c453528a6 100644 --- a/.github/workflows/publish-pyodide-demo-app.yml +++ b/.github/workflows/publish-pyodide-demo-app.yml @@ -9,6 +9,8 @@ on: paths: - 'src/pyodide/**' - '.github/workflows/publish-pyodide-demo-app.yml' + branches: + - v0.8.0 jobs: activate: diff --git a/nix/build-all.py b/nix/build-all.py index cb821a5d6d..5f2f2f0a79 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -350,7 +350,7 @@ def git_clone_or_pull_repository(clone_url, target_dir, revision=None): run([git, "clone", "--recursive", clone_url, target_dir]) else: logger.info(f"directory '{target_dir}' already cloned. Pulling latest changes.") - run([git, "-C", target_dir, "fetch", "--all", "--tags"]) + run([git, "-C", target_dir, "fetch", "--all", "--tags", "--force"]) # detect whether we are on a branch and pull if run([git, "rev-parse", "--abbrev-ref", "HEAD"], cwd=target_dir) != "HEAD": diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 90867ff0ec..8933f14290 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -46,6 +46,7 @@ VERSION_MINOR:=$(shell cat '../../VERSION' | cut -d '.' -f 2) VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3) VERSION_DATE:=$(shell date '+%y%m%d') LAST_COMMIT_HASH:=$(shell git rev-parse HEAD) +LAST_COMMIT_DATE:=$(shell git show -s --format=%cI) PYVERSION:=py310 PYPI_IMP:=cp @@ -254,6 +255,7 @@ ifeq ($(IS_STABLE), TRUE) else $(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml $(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py + $(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml endif diff --git a/src/bonsai/bonsai/__init__.py b/src/bonsai/bonsai/__init__.py index be5b497145..6ea48885b3 100644 --- a/src/bonsai/bonsai/__init__.py +++ b/src/bonsai/bonsai/__init__.py @@ -40,6 +40,7 @@ from typing import Union, Any, Generator last_commit_hash = "8888888" +last_commit_date = "9999999" def get_last_commit_hash() -> Union[str, None]: @@ -51,6 +52,12 @@ def get_last_commit_hash() -> Union[str, None]: return last_commit_hash[:7] +def get_last_commit_date() -> Union[str, None]: + if last_commit_date == str(9_999999): + return None + return last_commit_date + + # Accessed from bonsai extension: bbim_semver: dict[str, Any] = {} @@ -96,6 +103,7 @@ def get_debug_info(): "blender_version": bpy.app.version_string, "bonsai_version": bbim_version, "bonsai_commit_hash": get_last_commit_hash(), + "bonsai_commit_date": get_last_commit_date(), "last_actions": last_actions, "last_error": last_error, } @@ -218,6 +226,7 @@ if IN_BLENDER: path = Path(__file__).resolve().parent repo = git.Repo(str(path), search_parent_directories=True) last_commit_hash = repo.head.object.hexsha + last_commit_date = repo.head.object.committed_datetime.isoformat() except: pass diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 3755a8d12a..930e067c24 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -83,6 +83,7 @@ modules = { "covering": None, "web": None, "light": None, + "alignment": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, @@ -115,10 +116,8 @@ classes = [ operator.ReloadIfcFile, operator.RemoveIfcFile, operator.RevertClippingPlaneCut, - operator.SelectDataDir, - operator.SelectCacheDir, + operator.SelectDir, operator.SelectIfcFile, - operator.SelectSchemaDir, operator.SelectURIAttribute, operator.SetTab, operator.SwitchTab, diff --git a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc index 37278587a9..dd4851f9c5 100644 --- a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc +++ b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc @@ -28,11 +28,13 @@ DATA; #21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); -#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcTypeProduct',(#25,#26,#27,#28)); +#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30)); #25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #28=IFCSIMPLEPROPERTYTEMPLATE('0bnzttUb9BPuN597uNTXOE',$,'TextSuffix','Text to add after annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #29=IFCSIMPLEPROPERTYTEMPLATE('2pJmUDpB50VBdCOib1zcJJ',$,'Newline_At','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.); +#30=IFCSIMPLEPROPERTYTEMPLATE('2TJn72t_v2cvBUG916Dpev',$,'CustomUnit','Dimension''s custom unit',.P_ENUMERATEDVALUE.,'IfcText',$,#31,$,$,$,.READWRITE.); +#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index deafc5b3ff..8b010fd141 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -132,11 +132,12 @@ class IfcExporter: bpy.ops.bim.update_representation(obj=obj.name) def has_changed_materials(self, obj: bpy.types.Object) -> bool: - checksum = obj.data.BIMMeshProperties.material_checksum + mprops = tool.Geometry.get_mesh_props(obj.data) + checksum = mprops.material_checksum return checksum != tool.Geometry.get_material_checksum(obj) def sync_object_placement(self, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: - element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = self.file.by_id(tool.Blender.get_object_bim_props(obj).ifc_definition_id) if tool.Geometry.is_scaled(obj): bpy.ops.bim.update_representation(obj=obj.name) # update_representation might not apply scale if the object has openings diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index d65017243f..88e785194f 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -24,17 +24,15 @@ import ifcopenshell.util.unit import ifcopenshell.api.owner.settings import bonsai.bim import bonsai.tool as tool -import bonsai.core.owner as core_owner from bpy.app.handlers import persistent from bonsai.bim.ifc import IfcStore -from bonsai.bim.module.owner.prop import get_user_person, get_user_organisation from bonsai.bim.module.model.data import AuthoringData from bonsai.bim.module.aggregate.decorator import AggregateDecorator from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator from bonsai.bim.module.model.decorator import WallAxisDecorator, SlabDirectionDecorator from bonsai.bim.module.nest.decorator import NestDecorator from mathutils import Vector -from math import cos, degrees +from math import cos from typing import Union, Callable @@ -55,23 +53,24 @@ def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) - return if isinstance(obj, bpy.types.Material): - props = obj.BIMStyleProperties + props = tool.Style.get_material_style_props(obj) if ifc_definition_id := props.ifc_definition_id: if props.is_renaming: - props.is_renmaing = False + props.is_renaming = False return - IfcStore.get_file().by_id(ifc_definition_id).Name = obj.name + tool.Ifc.get().by_id(ifc_definition_id).Name = obj.name refresh_ui_data() return - if not obj.BIMObjectProperties.ifc_definition_id: + props = tool.Blender.get_object_bim_props(obj) + if not props.ifc_definition_id: return - if obj.BIMObjectProperties.is_renaming: - obj.BIMObjectProperties.is_renaming = False + if props.is_renaming: + props.is_renaming = False return - element = IfcStore.get_file().by_id(obj.BIMObjectProperties.ifc_definition_id) + element = tool.Ifc.get().by_id(props.ifc_definition_id) if "/" in obj.name: object_name = obj.name element_name = obj.name.split("/", 1)[1] @@ -87,8 +86,8 @@ def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) - if not element.is_a("IfcRoot"): return element.Name = element_name - if obj.BIMObjectProperties.collection: - obj.BIMObjectProperties.collection.name = object_name + if props.collection: + props.collection.name = object_name refresh_ui_data() @@ -221,35 +220,38 @@ def refresh_ui_data(): if isinstance(tool.Ifc.get(), ifcopenshell.sqlite): tool.Ifc.get().clear_cache() - bpy.context.scene.DocProperties.should_draw_decorations = bpy.context.scene.DocProperties.should_draw_decorations + props = tool.Drawing.get_document_props() + props.should_draw_decorations = props.should_draw_decorations if bpy.context.scene.WebProperties.is_connected: tool.Web.send_webui_data() @persistent -def loadIfcStore(scene): +def loadIfcStore(scene: bpy.types.Scene) -> None: IfcStore.purge() refresh_ui_data() - if not IfcStore.get_file(): + if not tool.Ifc.get(): return - IfcStore.get_schema() + tool.Ifc.schema() IfcStore.relink_all_objects() @persistent -def undo_post(scene): - if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: - IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction - IfcStore.undo(until_key=bpy.context.scene.BIMProperties.last_transaction) +def undo_post(scene: bpy.types.Scene) -> None: + props = tool.Blender.get_bim_props() + if IfcStore.last_transaction != props.last_transaction: + IfcStore.last_transaction = props.last_transaction + IfcStore.undo(until_key=props.last_transaction) refresh_ui_data() tool.Ifc.rebuild_element_maps() @persistent -def redo_post(scene): - if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: - IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction - IfcStore.redo(until_key=bpy.context.scene.BIMProperties.last_transaction) +def redo_post(scene: bpy.types.Scene) -> None: + props = tool.Blender.get_bim_props() + if IfcStore.last_transaction != props.last_transaction: + IfcStore.last_transaction = props.last_transaction + IfcStore.redo(until_key=props.last_transaction) refresh_ui_data() tool.Ifc.rebuild_element_maps() @@ -282,10 +284,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None return pao -def viewport_shading_changed_callback(area): +def viewport_shading_changed_callback(area: bpy.types.Area) -> None: shading = area.spaces.active.shading.type if shading == "RENDERED": - bpy.context.scene.BIMStylesProperties.active_style_type = "External" + tool.Style.get_style_props().active_style_type = "External" def subscribe_to_viewport_shading_changes(): @@ -340,10 +342,11 @@ def load_post(scene): tool.Blender.setup_tabs() if tool.Ifc.get() and bpy.data.is_saved: - bpy.context.scene.BIMProperties.has_blend_warning = True + props = tool.Blender.get_bim_props() + props.has_blend_warning = True # Bonsai overlays - georeference_props = bpy.context.scene.BIMGeoreferenceProperties + georeference_props = tool.Georeference.get_georeference_props() aggregate_props = bpy.context.scene.BIMAggregateProperties nest_props = bpy.context.scene.BIMNestProperties model_props = tool.Model.get_model_props() diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index a8b25bca1a..147eb424b2 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -27,12 +27,12 @@ import ifcopenshell.util.element import ifcopenshell.util.unit from ifcopenshell.util.doc import get_attribute_doc, get_predefined_type_doc, get_property_doc import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from typing import Optional, Callable, Any, Union, Iterable, TYPE_CHECKING if TYPE_CHECKING: import bonsai.bim.prop from bonsai.bim.prop import Attribute + from bonsai.bim.module.search.prop import BIMFilterGroup # ImportCallback return values: # - None - property should be imported by default workflow @@ -119,7 +119,8 @@ def import_attributes( data: dict[str, Any], callback: Optional[ImportCallback] = None, ) -> None: - for attribute in IfcStore.get_schema().declaration_by_name(ifc_class).all_attributes(): + schema = tool.Ifc.schema() + for attribute in schema.declaration_by_name(ifc_class).all_attributes(): import_attribute(attribute, props, data, callback=callback) @@ -292,7 +293,10 @@ def prop_with_search( should_click_ok: bool = False, original_operator_path: Optional[str] = None, **kwargs: Any, -): +) -> bpy.types.UILayout: + """ + :return: Added row. + """ # kwargs are layout.prop arguments (text, icon, etc.) row = layout.row(align=True) row.prop(data, prop_name, **kwargs) @@ -306,6 +310,7 @@ def prop_with_search( op.original_operator_path = original_operator_path or "" except TypeError: # Prop is not iterable pass + return row def get_enum_items( @@ -372,11 +377,16 @@ def convert_property_group_from_si(property_group: bpy.types.PropertyGroup, skip setattr(property_group, prop_name, prop_value) -def draw_filter(layout: bpy.types.UILayout, filter_groups, data, module: str) -> None: +def draw_filter( + layout: bpy.types.UILayout, + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup], + data, + module: str, +) -> None: if not data.is_loaded: data.load() - sprops = bpy.context.scene.BIMSearchProperties + sprops = tool.Search.get_search_props() if tool.Ifc.get(): row = layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index 9576c60459..1cac1d5cb1 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -33,7 +33,7 @@ import bonsai.bim.handler import bonsai.tool as tool from pathlib import Path from bonsai.tool.brick import BrickStore -from typing import Set, Union, Optional, TypedDict, Callable, NotRequired, cast +from typing import Set, Union, Optional, TypedDict, Callable, NotRequired IFC_CONNECTED_TYPE = Union[bpy.types.Material, bpy.types.Object] @@ -105,10 +105,8 @@ class IfcStore: @staticmethod def get_file(): if IfcStore.file is None: - IfcStore.path = cast(str, bpy.context.scene.BIMProperties.ifc_file) - # Interpret relative paths as relative to .blend file. - if IfcStore.path and not os.path.isabs(IfcStore.path): - IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path)) + props = tool.Blender.get_bim_props() + IfcStore.set_path(props.ifc_file) if IfcStore.path: try: IfcStore.load_file(IfcStore.path) @@ -116,13 +114,21 @@ class IfcStore: print(f"Failed to load file {IfcStore.path}. Error details: {e}") return IfcStore.file + @staticmethod + def set_path(value): + IfcStore.path = value + # Interpret relative paths as relative to .blend file. + if IfcStore.path and not os.path.isabs(IfcStore.path): + IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path)) + @staticmethod def get_cache(): if IfcStore.cache is None and IfcStore.path: + props = tool.Blender.get_bim_props() ifc_key = IfcStore.path + IfcStore.file.wrapped_data.header.file_name.time_stamp ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest() - os.makedirs(bpy.context.scene.BIMProperties.cache_dir, exist_ok=True) - IfcStore.cache_path = os.path.join(bpy.context.scene.BIMProperties.cache_dir, f"{ifc_hash}.h5") + os.makedirs(props.cache_dir, exist_ok=True) + IfcStore.cache_path = os.path.join(props.cache_dir, f"{ifc_hash}.h5") cache_path = Path(IfcStore.cache_path) cache_settings = ifcopenshell.geom.settings() serializer_settings = ifcopenshell.geom.serializer_settings() @@ -162,7 +168,8 @@ class IfcStore: assert IfcStore.file ifc_key = IfcStore.path + IfcStore.file.wrapped_data.header.file_name.time_stamp ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest() - new_cache_path = os.path.join(bpy.context.scene.BIMProperties.cache_dir, f"{ifc_hash}.h5") + props = tool.Blender.get_bim_props() + new_cache_path = os.path.join(props.cache_dir, f"{ifc_hash}.h5") IfcStore.cache = None try: shutil.move(IfcStore.cache_path, new_cache_path) @@ -178,6 +185,7 @@ class IfcStore: if not os.path.isfile(path): return extension = path.split(".")[-1] + props = tool.Project.get_project_props() if extension.lower() == "ifczip": with tempfile.TemporaryDirectory() as unzipped_path: with zipfile.ZipFile(path, "r") as zip_ref: @@ -187,7 +195,7 @@ class IfcStore: return elif extension.lower() == "ifcxml": IfcStore.file = ifcopenshell.file(ifcopenshell.ifcopenshell_wrapper.parse_ifcxml(path)) - elif bpy.context.scene.BIMProjectProperties.should_stream: + elif props.should_stream: IfcStore.file = ifcopenshell.open(path, should_stream=True) else: IfcStore.file = ifcopenshell.open(path) @@ -195,9 +203,8 @@ class IfcStore: @staticmethod def get_schema() -> ifcopenshell.ifcopenshell_wrapper.schema_definition: if IfcStore.file is None: - IfcStore.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name( - bpy.context.scene.BIMProjectProperties.export_schema - ) + props = tool.Project.get_project_props() + IfcStore.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(props.export_schema) elif IfcStore.schema is None: IfcStore.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(IfcStore.file.schema_identifier) return IfcStore.schema @@ -247,7 +254,7 @@ class IfcStore: # refactor this class and deprecate usage of IfcStore in favour of # tools. if not isinstance(obj, (bpy.types.Object, bpy.types.Material)): - obj.BIMMeshProperties.ifc_definition_id = element.id() + tool.Geometry.get_mesh_props(obj).ifc_definition_id = element.id() return existing_obj = IfcStore.id_map.get(element.id(), None) @@ -262,9 +269,11 @@ class IfcStore: IfcStore.guid_map[global_id] = obj if element.is_a("IfcSurfaceStyle"): - obj.BIMStyleProperties.ifc_definition_id = element.id() + props = tool.Style.get_material_style_props(obj) + props.ifc_definition_id = element.id() else: - obj.BIMObjectProperties.ifc_definition_id = element.id() + props = tool.Blender.get_object_bim_props(obj) + props.ifc_definition_id = element.id() tool.Ifc.setup_listeners(obj) @@ -402,15 +411,24 @@ class IfcStore: @staticmethod def purge_blender_ifc_data(obj: IFC_CONNECTED_TYPE) -> None: if isinstance(obj, bpy.types.Material): - obj.BIMStyleProperties.ifc_definition_id = 0 + props = tool.Style.get_material_style_props(obj) + props.ifc_definition_id = 0 else: # bpy.types.Object - obj.BIMObjectProperties.ifc_definition_id = 0 + props = tool.Blender.get_object_bim_props(obj) + props.ifc_definition_id = 0 @staticmethod - def execute_ifc_operator(operator: tool.Ifc.Operator, context: bpy.types.Context, is_invoke=False) -> set[str]: + def execute_ifc_operator( + operator: tool.Ifc.Operator, + context: bpy.types.Context, + event=None, + method: Literal["EXECUTE", "INVOKE", "MODAL"] = "EXECUTE", + ) -> set[str]: bonsai.last_actions.append({"type": "operator", "name": operator.bl_idname}) - bpy.context.scene.BIMProperties.is_dirty = True - is_top_level_operator = not bool(IfcStore.current_transaction) + props = tool.Blender.get_bim_props() + props.is_dirty = True + # Modals don't nest, and Blender handles the loop that continuously calls modal() + is_top_level_operator = not bool(IfcStore.current_transaction) or (method == "MODAL") if is_top_level_operator: IfcStore.begin_transaction(operator) @@ -436,10 +454,12 @@ class IfcStore: bonsai.bim.handler.refresh_ui_data() try: - if is_invoke: - result = getattr(operator, "_invoke")(context, None) - else: + if method == "EXECUTE": result = getattr(operator, "_execute")(context) + elif method == "INVOKE": + result = getattr(operator, "_invoke")(context, event) + elif method == "MODAL": + result = getattr(operator, "_modal")(context, event) except: bonsai.last_error = traceback.format_exc() # Try to ensure undo will work since Blender undo does work in case of errors. @@ -451,12 +471,27 @@ class IfcStore: end_top_level_operator() raise - end_top_level_operator() + if method == "MODAL": + if result == {"FINISHED"}: + end_top_level_operator() + elif result == {"CANCELLED"}: + # Please read the docs: https://docs.blender.org/api/current/bpy.types.Operator.html + # > "when an operator returns {'CANCELLED'}, no undo step will be created". + # This means that if your modal edits IFC data, then the user + # cancels it, Blender's undo history will not be in sync with + # Bonsai / IfcOpenShell's undo history. Instead of hoping for + # Bonsai devs to remember to handle the "cancel" state (i.e. + # detect escape keypress) and return {"FINISHED"}, we instead + # always enforce an undo step. + bpy.ops.ed.undo_push(message=f"Cancel {operator.bl_idname}") + end_top_level_operator() + else: + end_top_level_operator() return result @staticmethod def begin_transaction(operator: tool.Ifc.Operator) -> None: - IfcStore.current_transaction = str(uuid.uuid4()) + IfcStore.current_transaction = str(uuid.uuid4()) + operator.__class__.__name__ operator.transaction_key = IfcStore.current_transaction @staticmethod @@ -470,7 +505,8 @@ class IfcStore: ) -> None: key = getattr(operator, "transaction_key", None) data = getattr(operator, "transaction_data", None) - bpy.context.scene.BIMProperties.last_transaction = key + props = tool.Blender.get_bim_props() + props.last_transaction = key IfcStore.last_transaction = key rollback = rollback or getattr(operator, "rollback", lambda data: True) commit = commit or getattr(operator, "commit", lambda data: True) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 6eb0a99a9c..5c36163207 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -20,6 +20,7 @@ from __future__ import annotations import bpy import time import json +import ifcpatch import logging import traceback import mathutils @@ -38,6 +39,7 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore, IFC_CONNECTED_TYPE from bonsai.tool.loader import OBJECT_DATA_TYPE from typing import Dict, Union, Optional, Any, Literal +from ifcopenshell.util.shape import MatrixType class MaterialCreator: @@ -69,6 +71,9 @@ class MaterialCreator: if isinstance(mesh, bpy.types.Curve): return + if mesh.get("has_layer_styles", None) == True: + return + self.mesh = mesh self.obj = obj if element.is_a("IfcTypeProduct"): @@ -82,7 +87,7 @@ class MaterialCreator: def load_existing_materials(self) -> None: for material in bpy.data.materials: - if ifc_definition_id := material.BIMStyleProperties.ifc_definition_id: + if ifc_definition_id := tool.Blender.get_ifc_definition_id(material): self.styles[ifc_definition_id] = material def parse_element_type_material_styles(self, element: ifcopenshell.entity_instance) -> None: @@ -90,7 +95,8 @@ class MaterialCreator: return # Already has materials assign to the representation itself # Otherwise, we need to check for material styles on the element, since # create_shape on types only works on representations. - context = tool.Ifc.get().by_id(self.mesh.BIMMeshProperties.ifc_definition_id).ContextOfItems + mprops = tool.Geometry.get_mesh_props(self.mesh) + context = tool.Ifc.get().by_id(mprops.ifc_definition_id).ContextOfItems for material in ifcopenshell.util.element.get_materials(element): if style := ifcopenshell.util.representation.get_material_style(material, context): self.mesh["ios_materials"] = (style.id(),) @@ -218,7 +224,7 @@ class IfcImporter: self.progress = 0 self.material_creator = MaterialCreator(ifc_import_settings, self) - classes_to_wireframe_str = bpy.context.scene.DocProperties.classes_to_wireframe + classes_to_wireframe_str = tool.Drawing.get_document_props().classes_to_wireframe self.classes_to_wireframe_list = [word.strip() for word in classes_to_wireframe_str.split(",")] def profile_code(self, message: str) -> None: @@ -294,6 +300,20 @@ class IfcImporter: bpy.context.window_manager.progress_end() def process_context_filter(self) -> None: + contexts = self.file.by_type("IfcGeometricRepresentationContext") + if len(contexts) > 100: # Probably something strange happening. Encountered from Revizto. + print("Warning! Excessive contexts were found and merged where applicable.") + uniques = {} + i = 0 + for element in contexts: + data = "-".join([str(a) for a in element]) + if unique := uniques.get(data, None): + ifcopenshell.util.element.replace_element(element, unique) + self.file.remove(element) + i += 1 + else: + uniques[data] = element + print(f"Replaced {i} IfcGeometricRepresentationContext") tool.Loader.settings.contexts = ifcopenshell.util.representation.get_prioritised_contexts(self.file) tool.Loader.settings.context_settings = tool.Loader.create_settings() tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True) @@ -434,7 +454,7 @@ class IfcImporter: return False def calculate_model_offset(self) -> None: - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if self.ifc_import_settings.false_origin_mode == "MANUAL": tool.Loader.set_manual_blender_offset(self.file) elif self.ifc_import_settings.false_origin_mode == "AUTOMATIC": @@ -475,14 +495,16 @@ class IfcImporter: if grid.WAxes: self.create_grid_axes(grid.WAxes, grid_obj, grid_placement) - def create_grid_axes(self, axes, grid_obj, grid_placement): + def create_grid_axes( + self, axes: list[ifcopenshell.entity_instance], grid_obj: bpy.types.Object, grid_placement: MatrixType + ) -> None: for axis in axes: shape = tool.Loader.create_generic_shape(axis.AxisCurve) mesh = self.create_mesh(axis, shape) obj = bpy.data.objects.new(tool.Loader.get_name(axis), mesh) obj.show_in_front = True self.link_element(axis, obj) - self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, grid_placement.copy())) + self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, grid_placement)) def create_element_types(self): for element_type in self.element_types: @@ -507,8 +529,9 @@ class IfcImporter: pass elif shape: mesh = self.create_mesh(element, shape) - tool.Loader.link_mesh(shape, mesh) - self.meshes[mesh_name] = mesh + if mesh is not None: + tool.Loader.link_mesh(shape, mesh) + self.meshes[mesh_name] = mesh else: self.ifc_import_settings.logger.error("Failed to generate shape for %s", element) break @@ -738,12 +761,9 @@ class IfcImporter: def create_pointclouds(self, products: set[ifcopenshell.entity_instance]) -> set[ifcopenshell.entity_instance]: result = set() for product in products: - representation = self.get_pointcloud_representation(product) - if representation is not None: - pointcloud = self.create_pointcloud(product, representation) - if pointcloud is not None: + if representation := self.get_pointcloud_representation(product): + if pointcloud := self.create_pointcloud(product, representation): result.add(pointcloud) - return result def create_pointcloud( @@ -791,8 +811,9 @@ class IfcImporter: materials_updated = bool(mesh) if mesh is None: mesh = self.create_mesh(element, shape) - tool.Loader.link_mesh(shape, mesh) - self.meshes[mesh_name] = mesh + if mesh is not None: + tool.Loader.link_mesh(shape, mesh) + self.meshes[mesh_name] = mesh else: mesh = None @@ -803,11 +824,10 @@ class IfcImporter: if element.is_a(ifcclass): obj.display_type = "WIRE" - if shape: + if shape and mesh: # We use numpy here because Blender mathutils.Matrix is not accurate enough - mat = np.array(shape.transformation.matrix).reshape((4, 4), order="F") + mat = ifcopenshell.util.shape.get_shape_matrix(shape) self.set_matrix_world(obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, mat)) - assert mesh # Type checker. if not materials_updated: self.material_creator.create(element, obj, mesh, tool.Geometry.does_shape_has_openings(shape)) elif mesh: # When does this occur? @@ -878,17 +898,19 @@ class IfcImporter: def load_file(self): self.ifc_import_settings.logger.info("loading file %s", self.ifc_import_settings.input_file) - if not bpy.context.scene.BIMProperties.ifc_file: - bpy.context.scene.BIMProperties.ifc_file = self.ifc_import_settings.input_file - self.file = IfcStore.get_file() + props = tool.Blender.get_bim_props() + if not props.ifc_file: + props.ifc_file = self.ifc_import_settings.input_file + self.file = tool.Ifc.get() def calculate_unit_scale(self): self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) tool.Loader.set_unit_scale(self.unit_scale) - def set_units(self): + def set_units(self) -> None: if not (assignment := self.file.by_type("IfcProject")[0].UnitsInContext): return # Geometry is optional in IFC + props = tool.Blender.get_bim_props() for unit in assignment.Units: if unit.is_a("IfcNamedUnit") and unit.UnitType == "LENGTHUNIT": if unit.is_a("IfcSIUnit"): @@ -908,26 +930,27 @@ class IfcImporter: elif unit.is_a("IfcNamedUnit") and unit.UnitType == "AREAUNIT": name = unit.Name if unit.is_a("IfcSIUnit") else unit.Name.lower() try: - bpy.context.scene.BIMProperties.area_unit = "{}{}".format( + props.area_unit = "{}{}".format( unit.Prefix + "/" if hasattr(unit, "Prefix") and unit.Prefix else "", name ) except: # Probably an invalid unit. - bpy.context.scene.BIMProperties.area_unit = "SQUARE_METRE" + props.area_unit = "SQUARE_METRE" elif unit.is_a("IfcNamedUnit") and unit.UnitType == "VOLUMEUNIT": name = unit.Name if unit.is_a("IfcSIUnit") else unit.Name.lower() try: - bpy.context.scene.BIMProperties.volume_unit = "{}{}".format( + props.volume_unit = "{}{}".format( unit.Prefix + "/" if hasattr(unit, "Prefix") and unit.Prefix else "", name ) except: # Probably an invalid unit. - bpy.context.scene.BIMProperties.volume_unit = "CUBIC_METRE" + props.volume_unit = "CUBIC_METRE" def create_project(self): project = self.file.by_type("IfcProject")[0] self.project = {"ifc": project} obj = tool.Ifc.get_object(project) if obj: - self.project["blender"] = obj.BIMObjectProperties.collection + props = tool.Blender.get_object_bim_props(obj) + self.project["blender"] = props.collection self.has_existing_project = True return self.project["blender"] = bpy.data.collections.new( @@ -937,10 +960,16 @@ class IfcImporter: obj.hide_select = True self.project["blender"].objects.link(obj) self.project["blender"].BIMCollectionProperties.obj = obj - obj.BIMObjectProperties.collection = self.collections[project.GlobalId] = self.project["blender"] + props = tool.Blender.get_object_bim_props(obj) + props.collection = self.collections[project.GlobalId] = self.project["blender"] def create_styles(self) -> None: - for style in self.file.by_type("IfcSurfaceStyle"): + styles = self.file.by_type("IfcSurfaceStyle") + if len(styles) > self.ifc_import_settings.style_limit: # Probably something strange happening + print("Warning! Excessive styles were found and merged where applicable.") + ifcpatch.execute({"file": self.file, "recipe": "MergeStyles", "arguments": []}) + styles = self.file.by_type("IfcSurfaceStyle") + for style in styles: self.create_style(style) def create_style(self, style: ifcopenshell.entity_instance) -> None: @@ -953,10 +982,11 @@ class IfcImporter: self.material_creator.styles[style.id()] = blender_material style_elements = tool.Style.get_style_elements(blender_material) + props = tool.Style.get_material_style_props(blender_material) if tool.Style.has_blender_external_style(style_elements): - blender_material.BIMStyleProperties.active_style_type = "External" + props.active_style_type = "External" else: - blender_material.BIMStyleProperties.active_style_type = "Shading" + props.active_style_type = "Shading" def place_objects_in_collections(self) -> None: for ifc_definition_id, obj in self.added_data.items(): @@ -964,10 +994,10 @@ class IfcImporter: tool.Collector.assign(obj, should_clean_users_collection=False) def is_curve_annotation(self, element: ifcopenshell.entity_instance) -> bool: - object_type = element.ObjectType + object_type = ifcopenshell.util.element.get_predefined_type(element) return ( object_type in tool.Drawing.ANNOTATION_TYPES_DATA - and tool.Drawing.ANNOTATION_TYPES_DATA[object_type][3] == "curve" + and tool.Drawing.ANNOTATION_TYPES_DATA[object_type].data_type == "curve" ) def get_drawing_group(self, element): @@ -1021,7 +1051,7 @@ class IfcImporter: element: ifcopenshell.entity_instance, shape: Union[ifcopenshell.geom.ShapeElementType, ifcopenshell.geom.ShapeType], cartesian_point_offset: Union[npt.NDArray[np.float64], Literal[False]] = None, - ) -> bpy.types.Mesh: + ) -> Union[bpy.types.Mesh, None]: try: if hasattr(shape, "geometry"): # shape is ShapeElementType @@ -1040,7 +1070,7 @@ class IfcImporter: if cartesian_point_offset is False: mesh["has_cartesian_point_offset"] = False elif cartesian_point_offset is not None: - verts -= cartesian_point_offset + verts = verts - cartesian_point_offset mesh["has_cartesian_point_offset"] = True mesh["cartesian_point_offset"] = ( @@ -1048,21 +1078,21 @@ class IfcImporter: ) elif verts.size and tool.Loader.is_point_far_away(verts[0], is_meters=True): # Shift geometry close to the origin based off that first vert it found - verts_array = np.array(geometry.verts) offset = verts[0] - verts -= offset + verts = verts - offset mesh["has_cartesian_point_offset"] = True mesh["cartesian_point_offset"] = f"{offset[0]},{offset[1]},{offset[2]}" else: mesh["has_cartesian_point_offset"] = False - return tool.Loader.convert_geometry_to_mesh( + mesh = tool.Loader.convert_geometry_to_mesh( geometry, mesh, verts=verts, load_indexed_maps=self.ifc_import_settings.load_indexed_maps, ) + return tool.Loader.slice_layerset_mesh(element, mesh) except: self.ifc_import_settings.logger.error("Could not create mesh for %s", element) import traceback @@ -1070,9 +1100,10 @@ class IfcImporter: print(traceback.format_exc()) def set_default_context(self): + rprops = tool.Root.get_root_props() for subcontext in self.file.by_type("IfcGeometricRepresentationSubContext"): if subcontext.ContextIdentifier == "Body": - bpy.context.scene.BIMRootProperties.contexts = str(subcontext.id()) + rprops.contexts = str(subcontext.id()) break def link_element(self, element: ifcopenshell.entity_instance, obj: IFC_CONNECTED_TYPE) -> None: @@ -1118,6 +1149,7 @@ class IfcImportSettings: self.deflection_tolerance = 0.001 self.angular_tolerance = 0.5 self.void_limit = 30 + self.style_limit = 300 # Locations greater than 1km are not considered "small sites" according to the georeferencing guide # Users can configure this if they have to handle larger sites but beware of surveying precision self.distance_limit = 1000 @@ -1138,7 +1170,7 @@ class IfcImportSettings: @staticmethod def factory(context=None, input_file=None, logger=None): - scene_diff = bpy.context.scene.DiffProperties + scene_diff = tool.Blender.get_diff_props() props = tool.Project.get_project_props() settings = IfcImportSettings() settings.input_file = input_file @@ -1155,6 +1187,7 @@ class IfcImportSettings: settings.deflection_tolerance = props.deflection_tolerance settings.angular_tolerance = props.angular_tolerance settings.void_limit = props.void_limit + settings.style_limit = props.style_limit settings.distance_limit = props.distance_limit settings.false_origin_mode = props.false_origin_mode try: diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index c2e0cb16c2..c324037b89 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -23,7 +23,6 @@ import ifcopenshell.util.element import bonsai.tool as tool import bonsai.core.aggregate as core import bonsai.core.spatial -from bonsai.bim.ifc import IfcStore class BIM_OT_aggregate_assign_object(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/aggregate/prop.py b/src/bonsai/bonsai/bim/module/aggregate/prop.py index f49c0d3dc5..1d524b3645 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/prop.py +++ b/src/bonsai/bonsai/bim/module/aggregate/prop.py @@ -102,6 +102,7 @@ class BIMObjectAggregateProperties(PropertyGroup): class Objects(bpy.types.PropertyGroup): obj: PointerProperty(type=bpy.types.Object) previous_display_type: bpy.props.StringProperty(default="TEXTURED") + previous_hide_select: bpy.props.BoolProperty(default=False) class BIMAggregateProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/aggregate/ui.py b/src/bonsai/bonsai/bim/module/aggregate/ui.py index 7b9503f530..0755d3a54f 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/ui.py +++ b/src/bonsai/bonsai/bim/module/aggregate/ui.py @@ -34,12 +34,12 @@ class BIM_PT_aggregate(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) if not props.ifc_definition_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(props.ifc_definition_id): return False if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcObjectDefinition"): return False @@ -66,9 +66,9 @@ class BIM_PT_aggregate(Panel): col.enabled = False op = col.operator("bim.aggregate_assign_object", icon="CHECKMARK") if props.relating_object: - op.relating_object = props.relating_object.BIMObjectProperties.ifc_definition_id + op.relating_object = tool.Blender.get_object_bim_props(props.relating_object).ifc_definition_id elif props.related_object: - op.related_object = props.related_object.BIMObjectProperties.ifc_definition_id + op.related_object = tool.Blender.get_object_bim_props(props.related_object).ifc_definition_id row.operator("bim.disable_editing_aggregate", icon="CANCEL", text="") return else: @@ -115,14 +115,14 @@ class BIM_PT_linked_aggregate(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) if not props.ifc_definition_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(props.ifc_definition_id): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcObjectDefinition"): + if not tool.Ifc.get().by_id(props.ifc_definition_id).is_a("IfcObjectDefinition"): return False return True diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py new file mode 100644 index 0000000000..3c49e0b4fe --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -0,0 +1,36 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy + +# from . import ui, prop, operator +from . import operator + +classes = (operator.ImportAlignmentCSV,) + + +def menu_func_import(self, context): + self.layout.operator(operator.ImportAlignmentCSV.bl_idname, text="Alignment (.csv)") + + +def register(): + bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + + +def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py new file mode 100644 index 0000000000..fe38ceea40 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -0,0 +1,113 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult , 2022 Yassine Oualid +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +# pyright: reportUnnecessaryTypeIgnoreComment=error + +import os + +import ifcopenshell.api.alignment +import ifcopenshell.api.alignment.add_stationing_to_alignment + +import bpy +import json +import time +import calendar +import isodate +import bonsai.core.sequence as core +import bonsai.tool as tool +import bonsai.bim.module.sequence.helper as helper +import ifcopenshell.api.spatial +import ifcopenshell.geom +import ifcopenshell.util.sequence +import ifcopenshell.util.selector +from datetime import datetime +from dateutil import parser, relativedelta +from bpy_extras.io_utils import ImportHelper +from typing import get_args, TYPE_CHECKING +from typing_extensions import assert_never + + +class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): + bl_idname = "bim.import_alignment_csv" + bl_label = "Import Alignment CSV" + bl_options = {"REGISTER", "UNDO"} + filename_ext = ".csv" + filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) + + @classmethod + def poll(cls, context): + ifc_file = tool.Ifc.get() + if ifc_file is None: + cls.poll_message_set("No IFC file is loaded.") + return False + elif ifc_file.schema != "IFC4X3": + cls.poll_message_set("Schema must be IFC4x3.") + return False + return True + + def _execute(self, context): + self.file = tool.Ifc.get() + start = time.time() + alignment = ifcopenshell.api.alignment.create_alignment_from_csv(self.file, self.filepath) + ifcopenshell.api.alignment.create_geometric_representation(self.file, alignment) + ifcopenshell.api.alignment.add_stationing_to_alignment(self.file, alignment=alignment, start_station=0.0) + + # IFC 4.1.5.1 alignments cannot be contained in spatial structures, but can be referenced into them + sites = self.file.by_type("IfcSite") + for site in sites: + ifcopenshell.api.spatial.reference_structure(self.file, products=[alignment], relating_structure=site) + + # process the generated IfcReferent for the alignment + for rel in alignment.IsNestedBy: + for referent in rel.RelatedObjects: + if referent.is_a("IfcReferent"): + referent_obj = bpy.data.objects.new(tool.Loader.get_name(referent), None) + tool.Geometry.link(referent, referent_obj) + tool.Collector.assign(referent_obj, should_clean_users_collection=False) + + # an alignment can be an aggregation of multiple child alignments (ie. multiple verticals for a single horizontal) + # get all the alignment curves + curves = [] + for rel in alignment.IsDecomposedBy: + for agg in rel.RelatedObjects: + if agg.is_a("IfcAlignment"): + curves.append(ifcopenshell.api.alignment.get_curve(agg)) # 3D curve + + # if there aren't any curves from aggregation, then there is only a single vertical or no vertical + if len(curves) == 0: + curves.append(ifcopenshell.api.alignment.get_curve(alignment)) + + settings = ifcopenshell.geom.settings() + for curve in curves: + shape = ifcopenshell.geom.create_shape(settings, curve) + + # create a new Blender mesh + mesh_name = tool.Loader.get_mesh_name_from_shape(shape) + mesh = bpy.data.meshes.new(mesh_name) + m = tool.Loader.convert_geometry_to_mesh(shape, mesh) + + # create a new Blender object + alignment_obj = bpy.data.objects.new(tool.Loader.get_name(alignment), m) + + # link the blender object to with the alignment element + tool.Geometry.link(alignment, alignment_obj) + + # assign the object to the blender collections + tool.Collector.assign(alignment_obj, should_clean_users_collection=False) + + self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start)) diff --git a/src/bonsai/bonsai/bim/module/attribute/operator.py b/src/bonsai/bonsai/bim/module/attribute/operator.py index 3282c66ec5..2b279895e3 100644 --- a/src/bonsai/bonsai/bim/module/attribute/operator.py +++ b/src/bonsai/bonsai/bim/module/attribute/operator.py @@ -26,7 +26,6 @@ import bonsai.bim.helper import bonsai.tool as tool import bonsai.core.attribute as core import bonsai.core.spatial -from bonsai.bim.ifc import IfcStore def get_objs_for_operation(operator_properties, context): @@ -117,7 +116,7 @@ class EditAttributes(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() obj = tool.Blender.get_active_object(is_selected=False) if not (element := tool.Ifc.get_entity(obj)): return diff --git a/src/bonsai/bonsai/bim/module/attribute/prop.py b/src/bonsai/bonsai/bim/module/attribute/prop.py index 207fd927fd..fd2dab7cd2 100644 --- a/src/bonsai/bonsai/bim/module/attribute/prop.py +++ b/src/bonsai/bonsai/bim/module/attribute/prop.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import bpy -from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup from bpy.props import ( diff --git a/src/bonsai/bonsai/bim/module/attribute/ui.py b/src/bonsai/bonsai/bim/module/attribute/ui.py index 6f0f5a5599..0fce2b07d4 100644 --- a/src/bonsai/bonsai/bim/module/attribute/ui.py +++ b/src/bonsai/bonsai/bim/module/attribute/ui.py @@ -18,7 +18,6 @@ import bonsai.bim.helper from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.attribute.data import AttributesData import bonsai.tool as tool diff --git a/src/bonsai/bonsai/bim/module/augin/operator.py b/src/bonsai/bonsai/bim/module/augin/operator.py index 965279e9b7..b578beb554 100644 --- a/src/bonsai/bonsai/bim/module/augin/operator.py +++ b/src/bonsai/bonsai/bim/module/augin/operator.py @@ -113,7 +113,7 @@ class AuginCreateNewModel(bpy.types.Operator): context.scene.collection.objects.link(cam_obj) context.scene.camera = cam_obj - tmpdir = tempfile.mkdtemp() + tmpdir = tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None) thumb_path = os.path.join(tmpdir, "thumb.png") context.scene.render.image_settings.file_format = "PNG" context.scene.render.filepath = thumb_path @@ -122,15 +122,16 @@ class AuginCreateNewModel(bpy.types.Operator): context.scene.render.image_settings.file_format = old_file_format context.scene.render.filepath = old_filepath - client.upload_file(context.scene.BIMProperties.ifc_file, result["s3_bucket"], result["model_path"]) + bim_props = tool.Blender.get_bim_props() + client.upload_file(bim_props.ifc_file, result["s3_bucket"], result["model_path"]) client.upload_file(thumb_path, result["s3_bucket"], result["thumb_path"]) # Notify done url = "https://server.auge.pro.br/API/v3/augin_rest.php/files_uploaded" payload = { "user_token": props.token, - "ifc_filesize": os.path.getsize(context.scene.BIMProperties.ifc_file), - "model_filesize": os.path.getsize(context.scene.BIMProperties.ifc_file), + "ifc_filesize": os.path.getsize(bim_props.ifc_file), + "model_filesize": os.path.getsize(bim_props.ifc_file), "thumb_filesize": os.path.getsize(thumb_path), "model_upload_path": result["model_path"], "thumb_upload_path": result["thumb_path"], diff --git a/src/bonsai/bonsai/bim/module/augin/ui.py b/src/bonsai/bonsai/bim/module/augin/ui.py index 0da722f7a6..65ca3ac7f9 100644 --- a/src/bonsai/bonsai/bim/module/augin/ui.py +++ b/src/bonsai/bonsai/bim/module/augin/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy.types +import bonsai.tool as tool class BIM_PT_augin(bpy.types.Panel): @@ -47,7 +48,8 @@ class BIM_PT_augin(bpy.types.Panel): row = layout.row() row.label(text="Logged in as " + props.username) - if not context.scene.BIMProperties.ifc_file: + bim_props = tool.Blender.get_bim_props() + if not bim_props.ifc_file: row = layout.row() row.label(text="No IFC Found") return diff --git a/src/bonsai/bonsai/bim/module/bcf/bcfstore.py b/src/bonsai/bonsai/bim/module/bcf/bcfstore.py index b80e724be3..13be3faf1d 100644 --- a/src/bonsai/bonsai/bim/module/bcf/bcfstore.py +++ b/src/bonsai/bonsai/bim/module/bcf/bcfstore.py @@ -21,6 +21,7 @@ import bpy import bcf import bcf.bcfxml import bcf.v2.bcfxml +import bonsai.tool as tool from typing import Union @@ -30,7 +31,8 @@ class BcfStore: @classmethod def get_bcfxml(cls) -> Union[bcf.bcfxml.BcfXml, None]: if not cls.bcfxml: - bcf_filepath = bpy.context.scene.BCFProperties.bcf_file + props = tool.Bcf.get_bcf_props() + bcf_filepath = props.bcf_file if not os.path.isabs(bcf_filepath): bcf_filepath = os.path.abspath(os.path.join(bpy.path.abspath("//"), bcf_filepath)) if bcf_filepath: @@ -46,7 +48,7 @@ class BcfStore: @classmethod def set(cls, bcfxml: Union[bcf.bcfxml.BcfXml, None], filepath: str) -> None: cls.bcfxml = bcfxml - props = bpy.context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() props.bcf_file = filepath # Set bcf_version prop on load. diff --git a/src/bonsai/bonsai/bim/module/bcf/operator.py b/src/bonsai/bonsai/bim/module/bcf/operator.py index 0295d2cdde..b8daa24e00 100644 --- a/src/bonsai/bonsai/bim/module/bcf/operator.py +++ b/src/bonsai/bonsai/bim/module/bcf/operator.py @@ -42,8 +42,8 @@ import ifcopenshell.util.unit import bonsai.tool as tool import bonsai.bim.module.bcf.prop as bcf_prop import bonsai.bim.module.bcf.bcfstore as bcfstore +from bpy_extras.io_utils import ImportHelper, ExportHelper from pathlib import Path -from bonsai.bim.ifc import IfcStore from math import radians, degrees, atan, tan, cos, sin from mathutils import Vector, Matrix, Euler, geometry from xsdata.models.datatype import XmlDateTime @@ -55,7 +55,7 @@ class NewBcfProject(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() bcf_v2 = props.bcf_version == "2" bcf_class = bcf.v2.bcfxml.BcfXml if bcf_v2 else bcf.v3.bcfxml.BcfXml bcfxml = bcf_class.create_new("New Project") @@ -64,12 +64,14 @@ class NewBcfProject(bpy.types.Operator): return {"FINISHED"} -class LoadBcfProject(bpy.types.Operator): +class LoadBcfProject(bpy.types.Operator, ImportHelper): bl_idname = "bim.load_bcf_project" bl_label = "Load BCF Project" + bl_description = "Load the BCF file." bl_options = {"REGISTER", "UNDO"} filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) filter_glob: bpy.props.StringProperty(default="*.bcf;*.bcfzip", options={"HIDDEN"}) + filename_ext = ".bcf" def execute(self, context): # Operator is also used when new project is created by not yet saved. @@ -103,15 +105,12 @@ class LoadBcfProject(bpy.types.Operator): assert bcfxml.project if bcfxml.project.name is None: bcfxml.project.name = nameless - context.scene.BCFProperties.name = bcfxml.project.name + props = tool.Bcf.get_bcf_props() + props.name = bcfxml.project.name bpy.ops.bim.load_bcf_topics() self.report({"INFO"}, f"BCF Project '{Path(self.filepath).name}' is loaded.") return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class UnloadBcfProject(bpy.types.Operator): bl_idname = "bim.unload_bcf_project" @@ -131,7 +130,7 @@ class LoadBcfTopics(bpy.types.Operator): def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() props.topics.clear() # workaround, one non standard topic would break reading entire bcf # ignored these topics ATM @@ -163,7 +162,8 @@ class LoadBcfTopic(bpy.types.Operator): assert bcfxml topic = bcfxml.topics[self.topic_guid] bcfxml.get_header(self.topic_guid) - new = context.scene.BCFProperties.topics[self.topic_index] + props = tool.Bcf.get_bcf_props() + new = props.topics[self.topic_index] data_map = { "name": topic.guid, "title": topic.topic.title, @@ -244,7 +244,8 @@ class LoadBcfComments(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - blender_topic = context.scene.BCFProperties.topics.get(self.topic_guid) + props = tool.Bcf.get_bcf_props() + blender_topic = props.topics.get(self.topic_guid) blender_topic.comments.clear() for comment in bcfxml.topics[self.topic_guid].comments: new = blender_topic.comments.add() @@ -274,7 +275,9 @@ class EditBcfProjectName(bpy.types.Operator): # Bonsai creates default project on load. assert bcfxml.project - bcfxml.project.name = context.scene.BCFProperties.name + + props = tool.Bcf.get_bcf_props() + bcfxml.project.name = props.name return {"FINISHED"} @@ -284,7 +287,7 @@ class EditBcfTopicName(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml @@ -300,7 +303,7 @@ class EditBcfTopic(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml @@ -337,13 +340,15 @@ class EditBcfTopic(bpy.types.Operator): return {"FINISHED"} -class SaveBcfProject(bpy.types.Operator): +class SaveBcfProject(bpy.types.Operator, ExportHelper): bl_idname = "bim.save_bcf_project" bl_label = "Save BCF Project" + bl_description = "Save active BCF project by the provided filepath." bl_options = {"REGISTER", "UNDO"} filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.bcf;*.bcfzip", options={"HIDDEN"}) save_current_bcf: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) + filename_ext = ".bcf" def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() @@ -360,8 +365,7 @@ class SaveBcfProject(bpy.types.Operator): self.filepath = str(path) return self.execute(context) - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) class AddBcfTopic(bpy.types.Operator): @@ -371,13 +375,15 @@ class AddBcfTopic(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.author + props = tool.Bcf.get_bcf_props() + return props.author def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - bcfxml.add_topic("New Topic", "", context.scene.BCFProperties.author) + props = tool.Bcf.get_bcf_props() + bcfxml.add_topic("New Topic", "", props.author) bpy.ops.bim.load_bcf_topics() return {"FINISHED"} @@ -389,7 +395,7 @@ class AddBcfBimSnippet(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() props_are_filled = all( (getattr(props, attr) for attr in ("bim_snippet_reference", "bim_snippet_schema", "bim_snippet_type")) ) @@ -403,7 +409,7 @@ class AddBcfBimSnippet(bpy.types.Operator): assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] is_external = "://" in props.bim_snippet_reference @@ -435,7 +441,7 @@ class AddBcfRelatedTopic(bpy.types.Operator): assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] related_topics = tool.Bcf.get_topic_related_topics(topic) @@ -462,14 +468,15 @@ class AddBcfHeaderFile(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.file_reference + props = tool.Bcf.get_bcf_props() + return props.file_reference def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] @@ -519,9 +526,10 @@ class ViewBcfTopic(bpy.types.Operator): topic_guid: bpy.props.StringProperty() def execute(self, context): - for index, topic in enumerate(context.scene.BCFProperties.topics): + props = tool.Bcf.get_bcf_props() + for index, topic in enumerate(props.topics): if topic.name.lower() == self.topic_guid.lower(): - context.scene.BCFProperties.active_topic_index = index + props.active_topic_index = index break return {"FINISHED"} @@ -546,7 +554,7 @@ class AddBcfViewpoint(bpy.types.Operator): blender_camera = context.scene.camera assert blender_camera - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] @@ -675,7 +683,7 @@ class RemoveBcfViewpoint(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() if not bcfxml: return False - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() topic = props.active_topic if not topic: return False @@ -690,7 +698,7 @@ class RemoveBcfViewpoint(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] del topic.viewpoints[blender_topic.viewpoints] @@ -715,7 +723,7 @@ class RemoveBcfFile(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] header_files = tool.Bcf.get_topic_header_files(topic) @@ -733,13 +741,14 @@ class RemoveBcfTopic(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.topics + props = tool.Bcf.get_bcf_props() + return props.topics def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() del bcfxml.topics[props.active_topic.name] bpy.ops.bim.load_bcf_topics() return {"FINISHED"} @@ -752,13 +761,14 @@ class AddBcfReferenceLink(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.reference_link + props = tool.Bcf.get_bcf_props() + return bool(props.reference_link) def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] reference_links = tool.Bcf.get_topic_reference_links(topic) @@ -776,14 +786,15 @@ class AddBcfDocumentReference(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.document_reference + props = tool.Bcf.get_bcf_props() + return bool(props.document_reference) def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] @@ -855,13 +866,14 @@ class AddBcfLabel(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BCFProperties.label + props = tool.Bcf.get_bcf_props() + return bool(props.label) def execute(self, context): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] new = blender_topic.labels.add() @@ -883,7 +895,7 @@ class EditBcfReferenceLinks(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] reference_links = [r.name for r in blender_topic.reference_links] @@ -900,7 +912,7 @@ class EditBcfLabels(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] labels = [l.name for l in blender_topic.labels] @@ -918,7 +930,7 @@ class RemoveBcfReferenceLink(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] reference_links = tool.Bcf.get_topic_reference_links(topic) @@ -938,7 +950,7 @@ class RemoveBcfLabel(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] labels = tool.Bcf.get_topic_labels(topic) @@ -957,7 +969,7 @@ class RemoveBcfBimSnippet(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] tool.Bcf.set_topic_bim_snippet(topic, None) @@ -978,7 +990,7 @@ class RemoveBcfDocumentReference(bpy.types.Operator): assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] @@ -1043,7 +1055,7 @@ class RemoveBcfRelatedTopic(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] related_topics = tool.Bcf.get_topic_related_topics(topic) @@ -1063,7 +1075,7 @@ class RemoveBcfComment(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] comments = topic.comments @@ -1084,7 +1096,7 @@ class EditBcfComment(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic blender_comment = blender_topic.comments.get(self.comment_guid) topic = bcfxml.topics[blender_topic.name] @@ -1092,7 +1104,7 @@ class EditBcfComment(bpy.types.Operator): if comment.guid == self.comment_guid: comment.comment = blender_comment.comment comment.modified_date = XmlDateTime.now() - comment.modified_author = context.scene.BCFProperties.author + comment.modified_author = props.author bpy.ops.bim.load_bcf_comments(topic_guid=topic.guid) return {"FINISHED"} @@ -1105,7 +1117,7 @@ class AddBcfComment(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() if not props.comment: cls.poll_message_set("No comment to add.") return False @@ -1128,7 +1140,7 @@ class AddBcfComment(bpy.types.Operator): assert bcfxml bcf_v2 = (bcfxml.version.version_id or "").startswith("2") - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] comments = topic.comments @@ -1136,7 +1148,7 @@ class AddBcfComment(bpy.types.Operator): if bcf_v2: comment = bcf.v2.model.Comment( date=XmlDateTime.now(), - author=context.scene.BCFProperties.author, + author=props.author, comment=props.comment, guid=str(uuid.uuid4()), ) @@ -1149,7 +1161,7 @@ class AddBcfComment(bpy.types.Operator): else: comment = bcf.v3.model.Comment( date=XmlDateTime.now(), - author=context.scene.BCFProperties.author, + author=props.author, comment=props.comment, guid=str(uuid.uuid4()), ) @@ -1179,7 +1191,7 @@ class ActivateBcfViewpoint(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic if blender_topic is None: cls.poll_message_set("No topic is active.") @@ -1193,11 +1205,11 @@ class ActivateBcfViewpoint(bpy.types.Operator): return True def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() blender_topic = props.active_topic topic = bcfxml.topics[blender_topic.name] if self.viewpoint_guid: @@ -1303,7 +1315,7 @@ class ActivateBcfViewpoint(bpy.types.Operator): [0, 0, 0, 1], ) ) - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) matrix = ifcopenshell.util.geolocation.global2local( @@ -1342,7 +1354,7 @@ class ActivateBcfViewpoint(bpy.types.Operator): objs: list[bpy.types.Object] = [] for global_id in exception_global_ids: - obj = IfcStore.get_element(global_id) + obj = tool.Ifc.get_object_by_identifier(global_id) if obj and context.view_layer.objects.get(obj.name): assert isinstance(obj, bpy.types.Object) objs.append(obj) @@ -1401,7 +1413,7 @@ class ActivateBcfViewpoint(bpy.types.Operator): return bpy.ops.object.select_all(action="DESELECT") for global_id in selected_global_ids: - obj = IfcStore.get_element(global_id) + obj = tool.Ifc.get_object_by_identifier(global_id) if obj: obj.select_set(True) obj.hide_set(False) @@ -1414,7 +1426,7 @@ class ActivateBcfViewpoint(bpy.types.Operator): for acomponent in acoloring.component: global_id_colours.setdefault(acomponent.ifc_guid, acoloring.color) for global_id, color in global_id_colours.items(): - obj = IfcStore.get_element(global_id) + obj = tool.Ifc.get_object_by_identifier(global_id) if obj: obj.color = self.hex_to_rgb(color) @@ -1517,58 +1529,51 @@ class OpenBcfReferenceLink(bpy.types.Operator): index: bpy.props.IntProperty() def execute(self, context): - webbrowser.open(context.scene.BCFProperties.topic_links[self.index].name) + props = tool.Bcf.get_bcf_props() + webbrowser.open(props.topic_links[self.index].name) return {"FINISHED"} -class SelectBcfHeaderFile(bpy.types.Operator): +class SelectBcfHeaderFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_bcf_header_file" bl_label = "Select BCF Header File" + bl_description = "Select filepath for BCF header reference." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcjson", options={"HIDDEN"}) + filename_ext = ".ifc" def execute(self, context): if self.filepath: - context.scene.BCFProperties.file_reference = self.filepath + props = tool.Bcf.get_bcf_props() + props.file_reference = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectBcfBimSnippetReference(bpy.types.Operator): +class SelectBcfBimSnippetReference(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_bcf_bim_snippet_reference" bl_label = "Select BCF BIM Snippet Reference" + bl_description = "Select filepath for BCF snippet reference." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): if self.filepath: - context.scene.BCFProperties.bim_snippet_reference = self.filepath + props = tool.Bcf.get_bcf_props() + props.bim_snippet_reference = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectBcfDocumentReference(bpy.types.Operator): +class SelectBcfDocumentReference(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_bcf_document_reference" bl_label = "Select BCF Document Reference" + bl_description = "Select filepath for BCF document reference." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): if self.filepath: - context.scene.BCFProperties.document_reference = self.filepath + props = tool.Bcf.get_bcf_props() + props.document_reference = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class LoadBcfHeaderIfcFile(bpy.types.Operator): bl_idname = "bim.load_bcf_header_ifc_file" @@ -1585,7 +1590,8 @@ class LoadBcfHeaderIfcFile(bpy.types.Operator): assert bcfxml bcf_path = tool.Bcf.get_path() - topic = bcfxml.topics[context.scene.BCFProperties.active_topic.name] + props = tool.Bcf.get_bcf_props() + topic = bcfxml.topics[props.active_topic.name] entity = tool.Bcf.get_topic_header_files(topic)[self.index] ifc_path = bcf.agnostic.topic.extract_file(topic, entity) bpy.ops.bim.load_project(filepath=ifc_path) @@ -1605,7 +1611,8 @@ class ExtractBcfFile(bpy.types.Operator): bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml - topic = bcfxml.topics[context.scene.BCFProperties.active_topic.name] + props = tool.Bcf.get_bcf_props() + topic = bcfxml.topics[props.active_topic.name] if self.entity_type == "HEADER_FILE": entity = tool.Bcf.get_topic_header_files(topic)[self.index] diff --git a/src/bonsai/bonsai/bim/module/bcf/prop.py b/src/bonsai/bonsai/bim/module/bcf/prop.py index ef681ace10..17a15597a7 100644 --- a/src/bonsai/bonsai/bim/module/bcf/prop.py +++ b/src/bonsai/bonsai/bim/module/bcf/prop.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from . import bcfstore from bonsai.bim.prop import StrProperty from bpy.types import PropertyGroup @@ -34,6 +35,7 @@ from functools import partial from typing import Literal from typing_extensions import assert_never from bcf.agnostic.extensions import get_extensions_attributes +from typing import TYPE_CHECKING, Union bcfviewpoints_enum = None @@ -95,7 +97,7 @@ def getBcfViewpoints(self, context, force_update=False): global bcfviewpoints_enum if bcfviewpoints_enum is None or force_update: # Retrieving Viewpoints is slow. Make sure we only do when needed bcfviewpoints_enum = [] - props = context.scene.BCFProperties + props = tool.Bcf.get_bcf_props() bcfxml = bcfstore.BcfStore.get_bcfxml() assert bcfxml topic = props.active_topic @@ -110,6 +112,12 @@ class BcfBimSnippet(PropertyGroup): type: StringProperty(name="Type") is_external: BoolProperty(name="Is External") + if TYPE_CHECKING: + schema: str + reference: str + type: str + is_external: bool + class BcfDocumentReference(PropertyGroup): reference: StringProperty(name="Reference") @@ -117,6 +125,12 @@ class BcfDocumentReference(PropertyGroup): guid: StringProperty(name="GUID") is_external: BoolProperty(name="Is External") + if TYPE_CHECKING: + reference: str + description: str + guid: str + is_external: bool + class BcfComment(PropertyGroup): name: StringProperty(name="GUID") @@ -128,6 +142,16 @@ class BcfComment(PropertyGroup): modified_author: StringProperty(name="Modified Author") is_editable: BoolProperty(name="Is Editable", default=False, update=updateBcfCommentIsEditable) + if TYPE_CHECKING: + name: str + date: str + author: str + comment: str + viewpoint: str + modified_date: str + modified_author: str + is_editable: bool + def get_extensions_items( self: "BCFProperties", context: bpy.types.Context, edit_text: str, extensions_attr: str @@ -183,6 +207,30 @@ class BcfTopic(PropertyGroup): comments: CollectionProperty(name="Comments", type=BcfComment) is_editable: BoolProperty(name="Edit Topic Attributes", default=False, update=updateBcfTopicIsEditable) + if TYPE_CHECKING: + name: str + title: str + type: str + status: str + priority: str + stage: str + creation_date: str + creation_author: str + modified_date: str + modified_author: str + assigned_to: str + due_date: str + description: str + viewpoints: str + files: bpy.types.bpy_prop_collection_idprop[StrProperty] + reference_links: bpy.types.bpy_prop_collection_idprop[BcfReferenceLink] + labels: bpy.types.bpy_prop_collection_idprop[BcfLabel] + bim_snippet: BcfBimSnippet + document_references: bpy.types.bpy_prop_collection_idprop[BcfDocumentReference] + related_topics: bpy.types.bpy_prop_collection_idprop[StrProperty] + comments: bpy.types.bpy_prop_collection_idprop[BcfComment] + is_editable: bool + def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: global RELATED_TOPICS_ENUM_ITEMS @@ -235,7 +283,30 @@ class BCFProperties(PropertyGroup): comment: StringProperty(default="", name="Comment") has_related_viewpoint: BoolProperty(name="Has Related Viewpoint", default=False) - def clear_input_fields(self): + if TYPE_CHECKING: + bcf_file: str + bcf_version: str + comment_text_width: int + name: str + author: str + topics: bpy.types.bpy_prop_collection_idprop[BcfTopic] + active_topic_index: int + file_reference: str + file_ifc_project: str + file_ifc_spatial_structure_element: str + reference_link: str + label: str + bim_snippet_reference: str + bim_snippet_type: str + bim_snippet_schema: str + document_reference: str + document_reference_description: str + document_description: str + related_topic: str + comment: str + has_related_viewpoint: bool + + def clear_input_fields(self) -> None: self.file_reference = "" self.file_ifc_project = "" self.file_ifc_spatial_structure_element = "" @@ -250,7 +321,7 @@ class BCFProperties(PropertyGroup): self.has_related_viewpoint = False @property - def active_topic(self): + def active_topic(self) -> Union[BcfTopic, None]: if len(self.topics) == 0: return None if self.active_topic_index < 0: @@ -259,5 +330,5 @@ class BCFProperties(PropertyGroup): self.active_topic_index = len(self.topics) - 1 return self.topics[self.active_topic_index] - def refresh_topic(self, context): + def refresh_topic(self, context: bpy.types.Context) -> None: refreshBcfTopic(self, context) diff --git a/src/bonsai/bonsai/bim/module/bcf/ui.py b/src/bonsai/bonsai/bim/module/bcf/ui.py index 5efa118a8c..83c80557f8 100644 --- a/src/bonsai/bonsai/bim/module/bcf/ui.py +++ b/src/bonsai/bonsai/bim/module/bcf/ui.py @@ -37,8 +37,7 @@ class BIM_PT_bcf(Panel): layout.use_property_split = True layout.use_property_decorate = False - scene = context.scene - props = scene.BCFProperties + props = tool.Bcf.get_bcf_props() if not bcfstore.BcfStore.get_bcfxml(): row = layout.row(align=True) @@ -63,18 +62,17 @@ class BIM_PT_bcf(Panel): row = layout.row() row.prop(props, "author") - props = context.scene.BCFProperties row = layout.row() row.template_list("BIM_UL_topics", "", props, "topics", props, "active_topic_index") col = row.column(align=True) col.operator("bim.add_bcf_topic", icon="ADD", text="") col.operator("bim.remove_bcf_topic", icon="REMOVE", text="") - if props.active_topic_index < len(props.topics): - topic = props.active_topic + + topic = props.active_topic + if topic is not None: is_editable = topic.is_editable col.prop(topic, "is_editable", icon="CHECKMARK" if topic.is_editable else "GREASEPENCIL", icon_only=True) - topic = props.active_topic row = layout.row() row.enabled = is_editable row.prop(topic, "description", text="") @@ -131,8 +129,7 @@ class BIM_PT_bcf_metadata(Panel): layout.use_property_split = True layout.use_property_decorate = False - scene = context.scene - props = scene.BCFProperties + props = tool.Bcf.get_bcf_props() bcfxml = bcfstore.BcfStore.get_bcfxml() if not bcfxml or props.active_topic_index >= len(props.topics): @@ -296,8 +293,7 @@ class BIM_PT_bcf_comments(Panel): layout.use_property_split = True layout.use_property_decorate = False - scene = context.scene - props = scene.BCFProperties + props = tool.Bcf.get_bcf_props() if props.active_topic_index >= len(props.topics): layout.label(text="No BCF project is loaded") diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index e3bc51683c..ece7277275 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -25,6 +25,7 @@ import mathutils import numpy as np import multiprocessing import ifcopenshell.api +import ifcopenshell.api.boundary import ifcopenshell.geom import ifcopenshell.util.unit import ifcopenshell.util.shape @@ -40,6 +41,7 @@ from bonsai.bim.module.model.decorator import ProfileDecorator from bonsai.bim.module.boundary.decorator import BoundaryDecorator import bonsai.core import bonsai.core.geometry +from typing import Union, Optional def disable_editing_boundary_geometry(context): @@ -57,7 +59,7 @@ def disable_editing_boundary_geometry(context): class Loader: - def __init__(self, operator=None): + def __init__(self, operator: Optional[bpy.types.Operator] = None): self.operator = operator self.ifc_file = None self.logger = None @@ -66,7 +68,7 @@ class Loader: self.fallback_settings = self.load_fallback_settings() self.load_importer() - def create_mesh(self, boundary): + def create_mesh(self, boundary: ifcopenshell.entity_instance) -> Union[bpy.types.Mesh, None]: # ConnectionGeometry is optional in IFC schema for some reasons. if not boundary.ConnectionGeometry: return None @@ -113,7 +115,7 @@ class Loader: bm.edges.new((verts[-1], verts[0])) bm.to_mesh(mesh) bm.free() - mesh.BIMMeshProperties.ifc_definition_id = surface.id() + tool.Ifc.link(surface, mesh) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) matrix = mathutils.Matrix( ifcopenshell.util.placement.get_axis2placement(surface.BasisSurface.Position).tolist() @@ -130,7 +132,7 @@ class Loader: settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) return settings - def load_importer(self): + def load_importer(self) -> None: self.ifc_file = tool.Ifc.get() self.logger = logging.getLogger("ImportIFC") ifc_import_settings = import_ifc.IfcImportSettings.factory(bpy.context, IfcStore.path, self.logger) @@ -279,7 +281,7 @@ class SelectProjectBoundaries(bpy.types.Operator): return {"FINISHED"} -def get_colour(ifc_boundary): +def get_colour(ifc_boundary: ifcopenshell.entity_instance) -> tuple[float, float, float, float]: """Return a color depending on IfcClass given""" product_colors = { "IfcWall": (0.7, 0.3, 0, 1), @@ -315,7 +317,8 @@ class ColourByRelatedBuildingElement(bpy.types.Operator): def _execute(self, context): for obj in context.visible_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + props = tool.Blender.get_object_bim_props(obj) + if not props.ifc_definition_id: continue element = tool.Ifc.get_entity(obj) if not element.is_a("IfcRelSpaceBoundary"): @@ -416,7 +419,7 @@ class UpdateBoundaryGeometry(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): tool.Boundary.move_origin_to_space_origin(context.active_object) settings = tool.Boundary.get_assign_connection_geometry_settings(context.active_object) - ifcopenshell.api.run("boundary.assign_connection_geometry", tool.Ifc.get(), **settings) + ifcopenshell.api.boundary.assign_connection_geometry(tool.Ifc.get(), **settings) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py index e3c7d8936f..ba7592b34c 100644 --- a/src/bonsai/bonsai/bim/module/boundary/ui.py +++ b/src/bonsai/bonsai/bim/module/boundary/ui.py @@ -18,7 +18,6 @@ import bpy from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore import bonsai.tool as tool from bonsai.bim.module.boundary.data import SpaceBoundariesData @@ -34,7 +33,7 @@ class BIM_PT_SceneBoundaries(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): row = self.layout.row(align=True) @@ -53,20 +52,20 @@ class BIM_PT_Boundary(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) if not props.ifc_definition_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(props.ifc_definition_id): return False - entity = IfcStore.get_file().by_id(props.ifc_definition_id) + entity = tool.Ifc.get().by_id(props.ifc_definition_id) return entity.is_a("IfcRelSpaceBoundary") def draw(self, context): obj = context.active_object assert obj - props = obj.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) ifc_file = tool.Ifc.get() boundary = ifc_file.by_id(props.ifc_definition_id) self.bprops = tool.Boundary.get_object_boundary_props(obj) @@ -129,12 +128,12 @@ class BIM_PT_SpaceBoundaries(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) if not props.ifc_definition_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(props.ifc_definition_id): return False element = tool.Ifc.get_entity(context.active_object) for ifc_class in ("IfcSpace", "IfcExternalSpatialElement"): @@ -146,7 +145,9 @@ class BIM_PT_SpaceBoundaries(Panel): if not SpaceBoundariesData.is_loaded: SpaceBoundariesData.load() - self.props = context.active_object.BIMObjectProperties + obj = context.active_object + assert obj + self.props = tool.Blender.get_object_bim_props(obj) self.ifc_file = tool.Ifc.get() row = self.layout.row() row.operator("bim.load_space_boundaries") diff --git a/src/bonsai/bonsai/bim/module/brick/data.py b/src/bonsai/bonsai/bim/module/brick/data.py index 737896ad3c..b7ac6f61e1 100644 --- a/src/bonsai/bonsai/bim/module/brick/data.py +++ b/src/bonsai/bonsai/bim/module/brick/data.py @@ -52,7 +52,7 @@ class BrickschemaData: def active_relations(cls): if BrickStore.graph is None: return [] - props = bpy.context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() try: brick = props.bricks[props.active_brick_index] except: diff --git a/src/bonsai/bonsai/bim/module/brick/operator.py b/src/bonsai/bonsai/bim/module/brick/operator.py index e76da467fa..bb4f5f3277 100644 --- a/src/bonsai/bonsai/bim/module/brick/operator.py +++ b/src/bonsai/bonsai/bim/module/brick/operator.py @@ -22,29 +22,26 @@ import ifcopenshell.api import bonsai.tool as tool import bonsai.core.brick as core import bonsai.bim.handler +from bpy_extras.io_utils import ImportHelper, ExportHelper from bonsai.bim.ifc import IfcStore from bonsai.tool.brick import BrickStore -class LoadBrickProject(bpy.types.Operator, tool.Ifc.Operator): +class LoadBrickProject(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.load_brick_project" bl_label = "Load Brickschema Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Load in a Brick project from a file" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ttl", options={"HIDDEN"}) + filename_ext = ".ttl" def _execute(self, context): if os.path.exists(self.filepath) and "ttl" in os.path.splitext(self.filepath)[1].lower(): - root = context.scene.BIMBrickProperties.brick_list_root + root = tool.Brick.get_brick_props().brick_list_root core.load_brick_project(tool.Brick, filepath=self.filepath, brick_root=root) else: self.report({"ERROR"}, f"Failed to load {self.filepath}") - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class ViewBrickClass(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.view_brick_class" @@ -111,10 +108,10 @@ class AssignBrickReference(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Assign the selected Ifc entity to the selected Brick entity" def _execute(self, context): - if not context.active_object: + if not (obj := context.active_object) or not (element := tool.Ifc.get_entity(obj)): self.report({"ERROR"}, f"No Ifc selected") return - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() try: props.bricks[props.active_brick_index] except: @@ -123,7 +120,7 @@ class AssignBrickReference(bpy.types.Operator, tool.Ifc.Operator): core.assign_brick_reference( tool.Ifc, tool.Brick, - element=tool.Ifc.get_entity(context.active_object), + element=element, library=tool.Ifc.get().by_id(int(props.libraries)), brick_uri=props.bricks[props.active_brick_index].uri, ) @@ -136,7 +133,7 @@ class AddBrick(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Create the Brick entity" def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() core.add_brick( tool.Ifc, tool.Brick, @@ -155,7 +152,7 @@ class AddBrickRelation(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Create the Brick relationship" def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() brick = props.bricks[props.active_brick_index] if props.new_brick_relation_type == "http://www.w3.org/2000/01/rdf-schema#label": object = props.new_brick_relation_object @@ -173,7 +170,7 @@ class ConvertIfcToBrick(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Convert Ifc entities and relations to Brick entities and relations" def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() library = None if props.libraries: library = tool.Ifc.get().by_id(int(props.libraries)) @@ -201,7 +198,8 @@ class NewBrickFile(bpy.types.Operator): return {"FINISHED"} def _execute(self, context): - root = context.scene.BIMBrickProperties.brick_list_root + props = tool.Brick.get_brick_props() + root = props.brick_list_root core.new_brick_file(tool.Brick, brick_root=root) def rollback(self, data): @@ -230,7 +228,7 @@ class RemoveBrick(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Delete this entity" def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() core.remove_brick( tool.Ifc, tool.Brick, @@ -239,18 +237,18 @@ class RemoveBrick(bpy.types.Operator, tool.Ifc.Operator): ) -class SerializeBrick(bpy.types.Operator): +class SerializeBrick(bpy.types.Operator, ExportHelper): bl_idname = "bim.serialize_brick" bl_label = "Serialize Brick" + # Prevents crash on Blender 4.4.0. + bl_description = "Save active Brick project by the provided filepath." filter_glob: bpy.props.StringProperty(default="*.ttl", options={"HIDDEN"}) filepath: bpy.props.StringProperty(subtype="FILE_PATH") should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) def invoke(self, context, event): if self.should_save_as or not BrickStore.path: - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) else: return self.execute(context) @@ -273,7 +271,7 @@ class AddBrickNamespace(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Bind a new namespace to the Brick project" def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() alias = props.new_brick_namespace_alias uri = props.new_brick_namespace_uri core.add_brick_namespace(tool.Brick, alias=alias, uri=uri) @@ -288,6 +286,6 @@ class RemoveBrickRelation(bpy.types.Operator, tool.Ifc.Operator): object: bpy.props.StringProperty(name="Object") def _execute(self, context): - props = context.scene.BIMBrickProperties + props = tool.Brick.get_brick_props() brick = props.bricks[props.active_brick_index] core.remove_brick_relation(tool.Brick, brick_uri=brick.uri, predicate=self.predicate, object=self.object) diff --git a/src/bonsai/bonsai/bim/module/brick/prop.py b/src/bonsai/bonsai/bim/module/brick/prop.py index 1a226fd1a0..4b738a0c77 100644 --- a/src/bonsai/bonsai/bim/module/brick/prop.py +++ b/src/bonsai/bonsai/bim/module/brick/prop.py @@ -33,6 +33,7 @@ from bpy.props import ( import bonsai.core.brick as core import bonsai.tool.brick as tool from bonsai.tool.brick import BrickStore +from typing import TYPE_CHECKING def update_active_brick_index(self, context): @@ -74,13 +75,13 @@ def get_brick_relations(self, context): return BRICK_RELATIONS_ENUM_ITEMS -def update_view(self, context): - root = context.scene.BIMBrickProperties.brick_list_root +def update_view(self: "BIMBrickProperties", context: bpy.types.Context) -> None: + root = self.brick_list_root core.set_brick_list_root(tool.Brick, brick_root=root, split_screen=False) -def split_screen_update_view(self, context): - root = context.scene.BIMBrickProperties.split_screen_brick_list_root +def split_screen_update_view(self: "BIMBrickProperties", context: bpy.types.Context) -> None: + root = self.split_screen_brick_list_root core.set_brick_list_root(tool.Brick, brick_root=root, split_screen=True) @@ -90,6 +91,11 @@ class Brick(PropertyGroup): uri: StringProperty(name="URI") total_items: IntProperty(name="Total Items") + if TYPE_CHECKING: + label: str + uri: str + total_items: int + class BIMBrickProperties(PropertyGroup): active_brick_class: StringProperty(name="Active Brick Class") @@ -124,3 +130,28 @@ class BIMBrickProperties(PropertyGroup): split_screen_brick_list_root: EnumProperty( name="Split Screen Brick List Root", items=get_brick_roots, update=split_screen_update_view ) + + if TYPE_CHECKING: + active_brick_class: str + brick_breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty] + bricks: bpy.types.bpy_prop_collection_idprop[Brick] + active_brick_index: int + libraries: str + set_list_root_toggled: bool + brick_list_root: str + namespace: str + new_brick_namespace_alias: str + new_brick_namespace_uri: str + new_brick_label: str + brick_entity_create_type: str + brick_entity_class: str + brick_create_relations_toggled: bool + brick_edit_relations_toggled: bool + new_brick_relation_type: str + new_brick_relation_object: str + split_screen_toggled: bool + split_screen_bricks: bpy.types.bpy_prop_collection_idprop[Brick] + split_screen_active_brick_index: int + split_screen_active_brick_class: str + split_screen_brick_breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty] + split_screen_brick_list_root: str diff --git a/src/bonsai/bonsai/bim/module/brick/ui.py b/src/bonsai/bonsai/bim/module/brick/ui.py index 141dba5559..e7d0a1be69 100644 --- a/src/bonsai/bonsai/bim/module/brick/ui.py +++ b/src/bonsai/bonsai/bim/module/brick/ui.py @@ -45,7 +45,7 @@ class BIM_PT_brickschema_project_info(Panel): bl_parent_id = "BIM_PT_brickschema" def draw(self, context): - self.props = context.scene.BIMBrickProperties + self.props = tool.Brick.get_brick_props() if not BrickschemaData.data["is_loaded"]: row = self.layout.row(align=True) @@ -87,7 +87,7 @@ class BIM_PT_brickschema_namespaces(Panel): return BrickStore.graph != None def draw(self, context): - self.props = context.scene.BIMBrickProperties + self.props = tool.Brick.get_brick_props() row = self.layout.row(align=True) row.label(text="Active Namespace:") @@ -122,7 +122,7 @@ class BIM_PT_brickschema_create_entity(Panel): return BrickStore.graph != None def draw(self, context): - self.props = context.scene.BIMBrickProperties + self.props = tool.Brick.get_brick_props() # TO DO: hide this if selected entity already has a reference, or something similar row = self.layout.row(align=True) @@ -156,7 +156,7 @@ class BIM_PT_brickschema_viewport(Panel): return BrickStore.graph != None def draw(self, context): - self.props = context.scene.BIMBrickProperties + self.props = tool.Brick.get_brick_props() row = self.layout.row(align=True) row.column().alignment = "RIGHT" @@ -292,7 +292,7 @@ class BIM_PT_ifc_brickschema_references(Panel): def draw(self, context): if not BrickschemaReferencesData.is_loaded: BrickschemaReferencesData.load() - self.props = context.scene.BIMBrickProperties + self.props = tool.Brick.get_brick_props() if not BrickschemaReferencesData.data["is_loaded"]: row = self.layout.row() diff --git a/src/bonsai/bonsai/bim/module/bsdd/ui.py b/src/bonsai/bonsai/bim/module/bsdd/ui.py index ac3e633d8a..046bc46fec 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/ui.py +++ b/src/bonsai/bonsai/bim/module/bsdd/ui.py @@ -18,7 +18,6 @@ import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore class BIM_PT_bsdd(Panel): diff --git a/src/bonsai/bonsai/bim/module/cad/operator.py b/src/bonsai/bonsai/bim/module/cad/operator.py index 710c38d5db..3d8bd5234e 100644 --- a/src/bonsai/bonsai/bim/module/cad/operator.py +++ b/src/bonsai/bonsai/bim/module/cad/operator.py @@ -128,9 +128,10 @@ class CadMitre(bpy.types.Operator): class CadFillet(bpy.types.Operator): bl_idname = "bim.cad_fillet" bl_label = "CAD Fillet" + bl_description = "Add fillet to the 2 selected edges." bl_options = {"REGISTER", "UNDO"} resolution: bpy.props.IntProperty(name="Arc Resolution", min=0, default=1) - radius: bpy.props.FloatProperty(name="Radius", default=0.1) + radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE") @classmethod def poll(cls, context): @@ -143,18 +144,15 @@ class CadFillet(bpy.types.Operator): layout.prop(self, prop) def execute(self, context): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.radius = self.radius * si_conversion - bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.mode_set(mode="EDIT") obj = bpy.context.active_object - cursor = obj.matrix_world.inverted() @ bpy.context.scene.cursor.location - mesh = obj.data + assert obj and isinstance((mesh := obj.data), bpy.types.Mesh) bm = bmesh.from_edit_mesh(mesh) selected_edges = [e for e in bm.edges if e.select] if len(selected_edges) != 2: + self.report({"ERROR"}, "Exactly 2 edges should be selected.") return {"CANCELLED"} # Assume the user has selected two edges sharing a vert, but merge verts @@ -219,6 +217,7 @@ class CadFillet(bpy.types.Operator): class CadArcFrom2Points(bpy.types.Operator): bl_idname = "bim.cad_arc_from_2_points" bl_label = "CAD Arc from 2 Points" + bl_description = "Add an arc to the active mesh based on 2 selected vertices." bl_options = {"REGISTER", "UNDO"} resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1) should_flip: bpy.props.BoolProperty(name="Flip", description="Flip arc", default=False) @@ -235,6 +234,7 @@ class CadArcFrom2Points(bpy.types.Operator): if bpy.context.mode != "EDIT_MESH": return {"CANCELLED"} obj = bpy.context.active_object + assert obj region = bpy.context.region region_3d = bpy.context.area.spaces.active.region_3d cursor = bpy.context.scene.cursor.location @@ -242,10 +242,12 @@ class CadArcFrom2Points(bpy.types.Operator): if not center: return {"CANCELLED"} mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) mw = obj.matrix_world bm = bmesh.from_edit_mesh(mesh) selected_verts = [v for v in bm.verts if v.select] if len(selected_verts) != 2: + self.report({"ERROR"}, "Exactly 2 vertices should be selected.") return {"CANCELLED"} v1 = bpy_extras.view3d_utils.location_3d_to_region_2d(region, region_3d, mw @ selected_verts[0].co) v2 = bpy_extras.view3d_utils.location_3d_to_region_2d(region, region_3d, mw @ selected_verts[1].co) @@ -342,11 +344,12 @@ class CadArcFrom3Points(bpy.types.Operator): class CadOffset(bpy.types.Operator): bl_idname = "bim.cad_offset" bl_label = "CAD Offset" + bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle." bl_options = {"REGISTER", "UNDO"} - distance: bpy.props.FloatProperty(name="Distance", default=0.1) + distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE") @classmethod - def poll(self, context): + def poll(cls, context): return context.mode == "EDIT_MESH" def draw(self, context): @@ -369,9 +372,6 @@ class CadOffset(bpy.types.Operator): # effect of converting an unclosed loop into a closed loop which is also # not what users expect. - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.distance = self.distance * si_conversion - bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.mode_set(mode="EDIT") @@ -543,7 +543,8 @@ class CadOffset(bpy.types.Operator): class AddIfcCircle(bpy.types.Operator): bl_idname = "bim.add_ifccircle" bl_label = "Add IfcCircle" - radius: bpy.props.FloatProperty(name="Radius", default=0.5) + bl_description = "Add IfcCircle to the currently active mesh." + radius: bpy.props.FloatProperty(name="Radius", default=0.5, subtype="DISTANCE") @classmethod def poll(cls, context): @@ -551,18 +552,19 @@ class AddIfcCircle(bpy.types.Operator): return bool(obj) and obj.type == "MESH" def execute(self, context): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.radius = self.radius * si_conversion - + obj = context.active_object + assert obj and isinstance((mesh := obj.data), bpy.types.Mesh) + self.obj = obj + self.mesh = mesh if self.has_selected_existing_circle(context): self.change_radius(context) else: self.create_circle(context) return {"FINISHED"} - def has_selected_existing_circle(self, context): - obj = context.active_object - bm = bmesh.from_edit_mesh(obj.data) + def has_selected_existing_circle(self, context: bpy.types.Context) -> bool: + obj = self.obj + bm = bmesh.from_edit_mesh(self.mesh) verts = [v for v in bm.verts if v.select and not v.hide] if len(verts) != 2: return False @@ -575,32 +577,32 @@ class AddIfcCircle(bpy.types.Operator): for group in groups: if group in verts[0][deform_layer] and group in verts[1][deform_layer]: return True + return False - def change_radius(self, context): - obj = context.active_object - bm = bmesh.from_edit_mesh(obj.data) + def change_radius(self, context: bpy.types.Object) -> None: + bm = bmesh.from_edit_mesh(self.mesh) verts = [v for v in bm.verts if v.select and not v.hide] center = verts[0].co.lerp(verts[1].co, 0.5) verts[0].co = center + ((verts[0].co - center).normalized() * self.radius) verts[1].co = center + ((verts[1].co - center).normalized() * self.radius) bm.verts.index_update() bm.edges.index_update() - bmesh.update_edit_mesh(obj.data) + bmesh.update_edit_mesh(self.mesh) - def create_circle(self, context): - obj = context.active_object + def create_circle(self, context: bpy.types.Context) -> None: + obj = self.obj bpy.ops.object.mode_set(mode="OBJECT") # The last group may be the result of a prior run of this operator. # I tried looping through all groups but Blender group indices seem to behave unpredictably. if len(obj.vertex_groups): last_group = obj.vertex_groups[-1] - verts_in_group = [v for v in obj.data.vertices if last_group.index in [vg.group for vg in v.groups]] + verts_in_group = [v for v in self.mesh.vertices if last_group.index in [vg.group for vg in v.groups]] if "IFCCIRCLE" in last_group.name and len(verts_in_group) != 2: obj.vertex_groups.remove(last_group) group = obj.vertex_groups.new(name="IFCCIRCLE") bpy.ops.object.mode_set(mode="EDIT") - bm = bmesh.from_edit_mesh(obj.data) + bm = bmesh.from_edit_mesh(self.mesh) bm.verts.layers.deform.verify() deform_layer = bm.verts.layers.deform.active @@ -617,7 +619,7 @@ class AddIfcCircle(bpy.types.Operator): bm.verts.index_update() bm.edges.index_update() - bmesh.update_edit_mesh(obj.data) + bmesh.update_edit_mesh(self.mesh) class AddIfcArcIndexFillet(bpy.types.Operator): @@ -638,18 +640,19 @@ class AddIfcArcIndexFillet(bpy.types.Operator): layout.prop(self, prop) def execute(self, context): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.radius = self.radius * si_conversion - + obj = context.active_object + assert obj and isinstance((mesh := obj.data), bpy.types.Mesh) + self.obj = obj + self.mesh = mesh if self.has_selected_existing_arc(context): self.change_radius(context) else: - self.create_arc(context) + return self.create_arc(context) return {"FINISHED"} def has_selected_existing_arc(self, context: bpy.types.Context) -> bool: - obj = context.active_object - bm = bmesh.from_edit_mesh(obj.data) + obj = self.obj + bm = bmesh.from_edit_mesh(self.mesh) verts = [v for v in bm.verts if v.select and not v.hide] if len(verts) != 3: return False @@ -668,13 +671,14 @@ class AddIfcArcIndexFillet(bpy.types.Operator): return False def change_radius(self, context: bpy.types.Context) -> None: - obj = context.active_object - bm = bmesh.from_edit_mesh(obj.data) + obj = self.obj + bm = bmesh.from_edit_mesh(self.mesh) edges = [e for e in bm.edges if e.select and not e.hide] mid = list(set(edges[0].verts) & set(edges[1].verts))[0] v1 = edges[0].other_vert(mid) v2 = edges[1].other_vert(mid) + assert v1 and v2 center = tool.Cad.get_center_of_arc([v1.co, mid.co, v2.co]) if len(v1.link_edges) != 2 or len(v2.link_edges) != 2: @@ -682,6 +686,7 @@ class AddIfcArcIndexFillet(bpy.types.Operator): v3 = v1.link_edges[1].other_vert(v1) if mid in v1.link_edges[0].verts else v1.link_edges[0].other_vert(v1) v4 = v2.link_edges[1].other_vert(v2) if mid in v2.link_edges[0].verts else v2.link_edges[0].other_vert(v2) + assert v3 and v4 dir1 = (v3.co - v1.co).normalized() dir2 = (v4.co - v2.co).normalized() @@ -702,11 +707,10 @@ class AddIfcArcIndexFillet(bpy.types.Operator): bm.verts.index_update() bm.edges.index_update() - bmesh.update_edit_mesh(obj.data) - - def create_arc(self, context): - obj = bpy.context.active_object + bmesh.update_edit_mesh(self.mesh) + def create_arc(self, context: bpy.types.Context) -> set[str]: + obj = self.obj bpy.ops.object.mode_set(mode="OBJECT") # The last group may be the result of a prior run of this operator. # I tried looping through all groups but Blender group indices seem to behave unpredictably. @@ -718,7 +722,7 @@ class AddIfcArcIndexFillet(bpy.types.Operator): group = obj.vertex_groups.new(name="IFCARCINDEX") bpy.ops.object.mode_set(mode="EDIT") - bm = bmesh.from_edit_mesh(obj.data) + bm = bmesh.from_edit_mesh(self.mesh) bm.verts.layers.deform.verify() deform_layer = bm.verts.layers.deform.active @@ -737,6 +741,7 @@ class AddIfcArcIndexFillet(bpy.types.Operator): shared_vert = list(set(selected_edges[0].verts) & set(selected_edges[1].verts))[0] v1 = selected_edges[0].other_vert(shared_vert) v2 = selected_edges[1].other_vert(shared_vert) + assert v1 and v2 dir1 = (v1.co - shared_vert.co).normalized() dir2 = (v2.co - shared_vert.co).normalized() edge_angle = dir1.angle(dir2) @@ -770,7 +775,8 @@ class AddIfcArcIndexFillet(bpy.types.Operator): bm.verts.index_update() bm.edges.index_update() - bmesh.update_edit_mesh(obj.data) + bmesh.update_edit_mesh(self.mesh) + return {"FINISHED"} class AlignViewToProfile(bpy.types.Operator): @@ -806,8 +812,9 @@ class AlignViewToProfile(bpy.types.Operator): class AddRectangle(bpy.types.Operator): bl_idname = "bim.add_rectangle" bl_label = "Add Rectangle" - x: bpy.props.FloatProperty(name="X", default=1) - y: bpy.props.FloatProperty(name="Y", default=1) + bl_description = "Add rectangle shape to the active mesh." + x: bpy.props.FloatProperty(name="X", default=1, subtype="DISTANCE") + y: bpy.props.FloatProperty(name="Y", default=1, subtype="DISTANCE") @classmethod def poll(cls, context): @@ -815,11 +822,8 @@ class AddRectangle(bpy.types.Operator): return bool(obj) and obj.type == "MESH" def execute(self, context): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.x = self.x * si_conversion - self.y = self.y * si_conversion - obj = context.active_object + assert obj and isinstance(obj.data, bpy.types.Mesh) bm = bmesh.from_edit_mesh(obj.data) cursor = obj.matrix_world.inverted() @ context.scene.cursor.location new_verts = [ diff --git a/src/bonsai/bonsai/bim/module/cad/prop.py b/src/bonsai/bonsai/bim/module/cad/prop.py index 9fd2dd4d3b..7247408f00 100644 --- a/src/bonsai/bonsai/bim/module/cad/prop.py +++ b/src/bonsai/bonsai/bim/module/cad/prop.py @@ -20,6 +20,7 @@ import bpy from bonsai.bim.module.model.data import AuthoringData from bpy.types import PropertyGroup from math import pi +from typing import TYPE_CHECKING class BIMCadProperties(PropertyGroup): @@ -31,3 +32,11 @@ class BIMCadProperties(PropertyGroup): gable_roof_edge_angle: bpy.props.FloatProperty( name="Gable Roof Edge Angle", default=pi / 2, soft_min=0, soft_max=pi / 2, subtype="ANGLE" ) + + if TYPE_CHECKING: + resolution: int + radius: float + distance: float + x: float + y: float + gable_roof_edge_angle: float diff --git a/src/bonsai/bonsai/bim/module/cad/workspace.py b/src/bonsai/bonsai/bim/module/cad/workspace.py index 854b34bb7d..017f493682 100644 --- a/src/bonsai/bonsai/bim/module/cad/workspace.py +++ b/src/bonsai/bonsai/bim/module/cad/workspace.py @@ -23,7 +23,7 @@ import bonsai.bim.module.type.prop as type_prop import ifcopenshell.util.unit from bpy.types import WorkSpaceTool from bonsai.bim.module.model.data import AuthoringData, RailingData, RoofData -from typing import Union +from functools import partial def load_custom_icons(): @@ -69,10 +69,12 @@ class CadTool(WorkSpaceTool): ("bim.cad_hotkey", {"type": "X", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_X")]}), ) - def draw_settings(context, layout, workspace_tool): + def draw_settings( + context: bpy.types.Context, layout: bpy.types.UILayout, workspace_tool: bpy.types.WorkSpaceTool + ) -> None: ui_context = str(context.region.type) obj = context.active_object - if not obj or not obj.data: + if not obj or not (data := obj.data): return is_profile = tool.Geometry.is_profile_object_active() if is_profile: @@ -112,17 +114,20 @@ class CadTool(WorkSpaceTool): row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Offset", "S_O", "Offset", ui_context) + add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Rectangle", "S_R", "Rectangle", ui_context) + add_layout_hotkey_operator(row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Circle", "S_C", "Circle", ui_context) + add_layout_hotkey_operator(row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) add_layout_hotkey_operator(row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__, ui_context) - elif hasattr(obj.data, "BIMMeshProperties") and obj.data.BIMMeshProperties.subshape_type == "AXIS": + elif ( + isinstance(data, tool.Geometry.TYPES_WITH_MESH_PROPERTIES) + and tool.Geometry.get_mesh_props(data).subshape_type == "AXIS" + ): add_header_apply_button( layout, "Edit Axis", "bim.edit_extrusion_axis", "bim.disable_editing_extrusion_axis", ui_context ) @@ -133,15 +138,15 @@ class CadTool(WorkSpaceTool): row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context ) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Fillet", "S_F", "Fillet", ui_context) + add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Offset", "S_O", "Offset", ui_context) + add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context) else: if ( (RailingData.is_loaded or not RailingData.load()) and RailingData.data["pset_data"] - and context.active_object.BIMRailingProperties.is_editing_path + and tool.Model.get_railing_props(obj).is_editing_path ): add_header_apply_button( layout, @@ -154,7 +159,7 @@ class CadTool(WorkSpaceTool): elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"] - and context.active_object.BIMRoofProperties.is_editing_path + and tool.Model.get_roof_props(obj).is_editing_path ): add_header_apply_button( layout, "Edit Roof Path", "bim.finish_editing_roof_path", "bim.cancel_editing_roof_path", ui_context @@ -171,17 +176,17 @@ class CadTool(WorkSpaceTool): row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "Offset", "S_O", "Offset", ui_context) + add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) - add_layout_hotkey_operator(row, "2-Point Arc", "S_C", "2-Point Arc", ui_context) + add_layout_hotkey_operator(row, "2-Point Arc", "S_C", bpy.ops.bim.cad_arc_from_2_points.__doc__, ui_context) row = row if ui_context == "TOOL_HEADER" else layout.row(align=True) add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.cad_arc_from_3_points.__doc__, ui_context) class CadHotkey(bpy.types.Operator): bl_idname = "bim.cad_hotkey" - bl_label = "CAD Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -190,12 +195,14 @@ class CadHotkey(bpy.types.Operator): return operator.description or "" def execute(self, context): - self.props = context.scene.BIMCadProperties + self.props = tool.Cad.get_cad_props() getattr(self, f"hotkey_{self.hotkey}")() return {"FINISHED"} def draw(self, context): - props = context.scene.BIMCadProperties + props = tool.Cad.get_cad_props() + obj = context.active_object + if self.hotkey == "S_C": if tool.Geometry.is_profile_object_active(): row = self.layout.row() @@ -221,7 +228,7 @@ class CadHotkey(bpy.types.Operator): elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"] - and bpy.context.active_object.BIMRoofProperties.is_editing_path + and tool.Model.get_roof_props(obj).is_editing_path ): self.layout.row().prop(props, "gable_roof_edge_angle") @@ -231,9 +238,8 @@ class CadHotkey(bpy.types.Operator): row.prop(props, "resolution") def hotkey_S_C(self): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if tool.Geometry.is_profile_object_active(): - bpy.ops.bim.add_ifccircle(radius=self.props.radius / si_conversion) + bpy.ops.bim.add_ifccircle(radius=self.props.radius) else: bpy.ops.bim.cad_arc_from_2_points() @@ -241,36 +247,49 @@ class CadHotkey(bpy.types.Operator): bpy.ops.bim.cad_trim_extend() def hotkey_S_F(self): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if tool.Geometry.is_profile_object_active(): - bpy.ops.bim.add_ifcarcindex_fillet(radius=self.props.radius / si_conversion) + bpy.ops.bim.add_ifcarcindex_fillet(radius=self.props.radius) else: - bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius / si_conversion) + bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius) def hotkey_S_O(self): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - bpy.ops.bim.cad_offset(distance=self.props.distance / si_conversion) + bpy.ops.bim.cad_offset(distance=self.props.distance) def hotkey_S_Q(self): - element = tool.Ifc.get_entity(bpy.context.active_object) - if bpy.context.active_object.data.BIMMeshProperties.subshape_type == "PROFILE": + obj = bpy.context.active_object + + if not obj: + return + + if not tool.Geometry.has_mesh_properties(data := obj.data): + return + + element = tool.Ifc.get_entity(obj) + if not element: + return + + mprops = tool.Geometry.get_mesh_props(data) + if mprops.subshape_type == "PROFILE": if element.is_a("IfcProfileDef"): bpy.ops.bim.edit_arbitrary_profile() elif element.is_a("IfcRelSpaceBoundary"): bpy.ops.bim.edit_boundary_geometry() else: bpy.ops.bim.edit_extrusion_profile() - elif bpy.context.active_object.data.BIMMeshProperties.subshape_type == "AXIS": + elif mprops.subshape_type == "AXIS": bpy.ops.bim.edit_extrusion_axis() def hotkey_S_R(self): + obj = bpy.context.active_object + if not obj: + return + if tool.Geometry.is_profile_object_active(): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - bpy.ops.bim.add_rectangle(x=self.props.x / si_conversion, y=self.props.y / si_conversion) + bpy.ops.bim.add_rectangle(x=self.props.x, y=self.props.y) elif ( (RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"] - and bpy.context.active_object.BIMRoofProperties.is_editing_path + and tool.Model.get_roof_props(obj).is_editing_path ): bpy.ops.bim.set_gable_roof_edge_angle(angle=self.props.gable_roof_edge_angle) @@ -288,7 +307,9 @@ class CadHotkey(bpy.types.Operator): bpy.ops.bim.reset_vertex() -def add_header_apply_button(layout, text, apply_operator, cancel_operator, ui_context=""): +def add_header_apply_button( + layout: bpy.types.UILayout, text: str, apply_operator: str, cancel_operator: str, ui_context: str = "" +) -> None: custom_icon = custom_icon_previews.get(text.upper().replace(" ", "_"), custom_icon_previews["IFC"]).icon_id row = layout.row(align=True) row.label(text=f"{text} Mode", icon_value=custom_icon) @@ -307,34 +328,8 @@ def add_header_apply_button(layout, text, apply_operator, cancel_operator, ui_co row.label(text="Tools") -def add_layout_hotkey_operator( - layout: bpy.types.UILayout, text: str, hotkey: str, description: Union[str, None], ui_context: str = "" -) -> bpy.types.OperatorProperties: - parts = hotkey.split("_") - modifier, key = parts - op_text = "" if ui_context == "TOOL_HEADER" else text - custom_icon = custom_icon_previews.get(text.upper().replace(" ", "_"), custom_icon_previews["IFC"]).icon_id - modifier_icon, modifier_str = MODIFIERS.get(modifier, ("NONE", "")) - - row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True) - op = row.operator("bim.cad_hotkey", text=op_text, icon_value=custom_icon) - - if ui_context != "TOOL_HEADER" and len(parts) == 2: - layout = layout.row(align=True) # Create a new line for hotkey display - layout.label(text="", icon=modifier_icon) - layout.label(text="", icon=f"EVENT_{key}") - - hotkey_description = f"Hotkey: {modifier_str} {key}" - description = "\n\n".join(filter(None, [hotkey_description if description else ""])) - op.hotkey = hotkey - op.description = description or hotkey_description - - return op +add_layout_hotkey_operator = partial(tool.Blender.add_layout_hotkey_operator, tool_name="cad", module_name=__name__) custom_icon_previews = None display_mode = None - -MODIFIERS = { - "S": ("EVENT_SHIFT", "⇧"), -} diff --git a/src/bonsai/bonsai/bim/module/clash/decorator.py b/src/bonsai/bonsai/bim/module/clash/decorator.py index f23b8962c6..764c6e5232 100644 --- a/src/bonsai/bonsai/bim/module/clash/decorator.py +++ b/src/bonsai/bonsai/bim/module/clash/decorator.py @@ -60,8 +60,9 @@ class ClashDecorator: unselected_elements_color = self.addon_prefs.decorator_color_unselected special_elements_color = self.addon_prefs.decorator_color_special - text = context.scene.BIMClashProperties.active_clash_text - p = context.scene.BIMClashProperties.p1.lerp(context.scene.BIMClashProperties.p2, 0.5) + props = tool.Clash.get_clash_props() + text = props.active_clash_text + p = props.p1.lerp(props.p2, 0.5) font_id = 0 blf.size(font_id, 12) @@ -92,7 +93,8 @@ class ClashDecorator: # general shader self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - selected_vertices = [context.scene.BIMClashProperties.p1, context.scene.BIMClashProperties.p2] + props = tool.Clash.get_clash_props() + selected_vertices = [props.p1, props.p2] selected_edges = [] if selected_vertices[0] != selected_vertices[1]: selected_edges = [[0, 1]] diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py index 020aeba55e..83fed588d2 100644 --- a/src/bonsai/bonsai/bim/module/clash/operator.py +++ b/src/bonsai/bonsai/bim/module/clash/operator.py @@ -24,26 +24,20 @@ import logging import numpy as np import ifcopenshell import bonsai.tool as tool +from bpy_extras.io_utils import ExportHelper, ImportHelper from math import radians from mathutils import Matrix, Vector from bonsai.bim.ifc import IfcStore from bonsai.bim.module.clash.decorator import ClashDecorator -class ExportClashSets(bpy.types.Operator): +class ExportClashSets(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_clash_sets" bl_label = "Export Clash Sets" bl_description = "Export clash sets to a selected file" filename_ext = ".json" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) - def invoke(self, context, event): - self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".json") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} - def execute(self, context): self.filepath = bpy.path.ensure_ext(self.filepath, ".json") clash_sets = tool.Clash.export_clash_sets() @@ -52,26 +46,24 @@ class ExportClashSets(bpy.types.Operator): return {"FINISHED"} -class ImportClashSets(bpy.types.Operator): +class ImportClashSets(bpy.types.Operator, ImportHelper): bl_idname = "bim.import_clash_sets" bl_label = "Import Clash Sets" bl_options = {"REGISTER", "UNDO"} bl_description = "Import clash sets from a selected file" filename_ext = ".json" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) def invoke(self, context, event): self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".json") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def execute(self, context): tool.Clash.load_clash_sets(self.filepath) - context.scene.BIMClashProperties.clash_sets.clear() + props = tool.Clash.get_clash_props() + props.clash_sets.clear() for clash_set in tool.Clash.get_clash_sets(): - new = context.scene.BIMClashProperties.clash_sets.add() + new = props.clash_sets.add() new.name = clash_set["name"] new.mode = clash_set["mode"] if new.mode == "intersection": @@ -106,7 +98,8 @@ class AddClashSet(bpy.types.Operator): bl_description = "Add a clash set" def execute(self, context): - new = context.scene.BIMClashProperties.clash_sets.add() + props = tool.Clash.get_clash_props() + new = props.clash_sets.add() new.name = "New Clash Set" return {"FINISHED"} @@ -119,7 +112,8 @@ class RemoveClashSet(bpy.types.Operator): index: bpy.props.IntProperty() def execute(self, context): - context.scene.BIMClashProperties.clash_sets.remove(self.index) + props = tool.Clash.get_clash_props() + props.clash_sets.remove(self.index) return {"FINISHED"} @@ -131,7 +125,8 @@ class AddClashSource(bpy.types.Operator): group: bpy.props.StringProperty() def execute(self, context): - clash_set = context.scene.BIMClashProperties.active_clash_set + props = tool.Clash.get_clash_props() + clash_set = props.active_clash_set source = getattr(clash_set, self.group).add() return {"FINISHED"} @@ -145,62 +140,54 @@ class RemoveClashSource(bpy.types.Operator): group: bpy.props.StringProperty() def execute(self, context): - clash_set = context.scene.BIMClashProperties.active_clash_set + props = tool.Clash.get_clash_props() + clash_set = props.active_clash_set getattr(clash_set, self.group).remove(self.index) return {"FINISHED"} -class SelectClashSource(bpy.types.Operator): +class SelectClashSource(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_clash_source" bl_label = "Select Clash Source" bl_options = {"REGISTER", "UNDO"} bl_description = "Select an IFC file to add as a clash source" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) index: bpy.props.IntProperty() group: bpy.props.StringProperty() + filename_ext = ".ifc" def execute(self, context): - clash_set = context.scene.BIMClashProperties.active_clash_set + props = tool.Clash.get_clash_props() + clash_set = props.active_clash_set getattr(clash_set, self.group)[self.index].name = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectClashResults(bpy.types.Operator): +class SelectClashResults(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_clash_results" bl_label = "Select Clash Results" + bl_description = "Select filepath for clash results." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): - context.scene.BIMClashProperties.clash_results_path = self.filepath + props = tool.Clash.get_clash_props() + props.clash_results_path = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectSmartGroupedClashesPath(bpy.types.Operator): +class SelectSmartGroupedClashesPath(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_smart_grouped_clashes_path" bl_label = "Select Smart-Grouped Clashes Path" + bl_description = "Select filepath for smart-grouped clashes." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): - context.scene.BIMClashProperties.smart_grouped_clashes_path = self.filepath + props = tool.Clash.get_clash_props() + props.smart_grouped_clashes_path = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class ExecuteIfcClash(bpy.types.Operator): +class ExecuteIfcClash(bpy.types.Operator, ExportHelper): bl_idname = "bim.execute_ifc_clash" bl_label = "Execute IFC Clash" bl_description = "Execute clash detection and save the information to a .bcf or .json file" @@ -210,13 +197,12 @@ class ExecuteIfcClash(bpy.types.Operator): def invoke(self, context, event): if self.filepath: return self.execute(context) - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def execute(self, context): from ifcclash import ifcclash - self.props = context.scene.BIMClashProperties + self.props = tool.Clash.get_clash_props() _, extension = os.path.splitext(self.filepath) if extension != ".bcf": @@ -284,27 +270,27 @@ class ExecuteIfcClash(bpy.types.Operator): return {"FINISHED"} -class SelectIfcClashResults(bpy.types.Operator): +class SelectIfcClashResults(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_ifc_clash_results" bl_label = "Select IFC Clash Results" bl_options = {"REGISTER", "UNDO"} bl_description = "Select the clashing IFC geometry stored in a file" + filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) filename_ext = ".json" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def invoke(self, context, event): self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".json") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def execute(self, context): # TODO refactor into new clash results system - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.filepath = bpy.path.ensure_ext(self.filepath, ".json") with open(self.filepath) as f: clash_sets = json.load(f) - clash_set_name = context.scene.BIMClashProperties.active_clash_set.name + clash_props = tool.Clash.get_clash_props() + assert clash_props.active_clash_set + clash_set_name = clash_props.active_clash_set.name global_ids = [] for clash_set in clash_sets: if clash_set["name"] != clash_set_name: @@ -316,13 +302,15 @@ class SelectIfcClashResults(bpy.types.Operator): global_ids.extend([clash["a_global_id"], clash["b_global_id"]]) for obj in context.visible_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + props = tool.Blender.get_object_bim_props(obj) + if not props.ifc_definition_id: continue ifc_file = "" for scene in obj.users_scene: - if scene.BIMProperties.ifc_file: - ifc_file = scene.BIMProperties.ifc_file + bim_props = tool.Blender.get_bim_props(scene) + if bim_props.ifc_file: + ifc_file = bim_props.ifc_file if scene.library: break @@ -334,7 +322,7 @@ class SelectIfcClashResults(bpy.types.Operator): element_file = self.file try: - element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = element_file.by_id(props.ifc_definition_id) except: continue @@ -354,7 +342,7 @@ class SelectClash(bpy.types.Operator): index: bpy.props.IntProperty() def execute(self, context): - self.props = context.scene.BIMClashProperties + self.props = tool.Clash.get_clash_props() clash_set = tool.Clash.get_clash_set(self.props.active_clash_set.name) active_clash = self.props.active_clash clash = tool.Clash.get_clash(clash_set, active_clash.a_global_id, active_clash.b_global_id) @@ -388,13 +376,15 @@ class SmartClashGroup(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BIMClashProperties.clash_results_path + props = tool.Clash.get_clash_props() + return bool(props.clash_results_path) def execute(self, context): from ifcclash import ifcclash settings = ifcclash.ClashSettings() - self.filepath = bpy.path.ensure_ext(context.scene.BIMClashProperties.clash_results_path, ".json") + props = tool.Clash.get_clash_props() + self.filepath = bpy.path.ensure_ext(props.clash_results_path, ".json") settings.output = self.filepath settings.logger = logging.getLogger("Clash") settings.logger.setLevel(logging.DEBUG) @@ -404,19 +394,18 @@ class SmartClashGroup(bpy.types.Operator): clash_sets = json.load(f) # execute the smart grouping - save_path = bpy.path.ensure_ext(context.scene.BIMClashProperties.smart_grouped_clashes_path, ".json") - smart_grouped_clashes = ifc_clasher.smart_group_clashes( - clash_sets, context.scene.BIMClashProperties.smart_clash_grouping_max_distance - ) + save_path = bpy.path.ensure_ext(props.smart_grouped_clashes_path, ".json") + smart_grouped_clashes = ifc_clasher.smart_group_clashes(clash_sets, props.smart_clash_grouping_max_distance) # save smart_groups to json with open(save_path, "w") as f: f.write(json.dumps(smart_grouped_clashes)) - clash_set_name = context.scene.BIMClashProperties.active_clash_set.name + assert props.active_clash_set + clash_set_name = props.active_clash_set.name # Reset the list of smart_clash_groups for the UI - context.scene.BIMClashProperties.smart_clash_groups.clear() + props.smart_clash_groups.clear() for clash_set, smart_groups in smart_grouped_clashes.items(): # Only select the clashes that correspond to the actively selected IFC Clash Set @@ -424,7 +413,7 @@ class SmartClashGroup(bpy.types.Operator): continue else: for smart_group, global_id_pairs in smart_groups[0].items(): - new_group = context.scene.BIMClashProperties.smart_clash_groups.add() + new_group = props.smart_clash_groups.add() new_group.number = f"{smart_group}" for pair in global_id_pairs: @@ -442,18 +431,21 @@ class LoadSmartGroupsForActiveClashSet(bpy.types.Operator): @classmethod def poll(cls, context): - return context.scene.BIMClashProperties.active_clash_set + props = tool.Clash.get_clash_props() + return bool(props.active_clash_set) def execute(self, context): - smart_groups_path = bpy.path.ensure_ext(context.scene.BIMClashProperties.smart_grouped_clashes_path, ".json") + props = tool.Clash.get_clash_props() + smart_groups_path = bpy.path.ensure_ext(props.smart_grouped_clashes_path, ".json") - clash_set_name = context.scene.BIMClashProperties.active_clash_set.name + assert props.active_clash_set + clash_set_name = props.active_clash_set.name with open(smart_groups_path) as f: smart_grouped_clashes = json.load(f) # Reset the list of smart_clash_groups for the UI - context.scene.BIMClashProperties.smart_clash_groups.clear() + props.smart_clash_groups.clear() for clash_set, smart_groups in smart_grouped_clashes.items(): # Only select the clashes that correspond to the actively selected IFC Clash Set @@ -461,12 +453,12 @@ class LoadSmartGroupsForActiveClashSet(bpy.types.Operator): continue else: for smart_group, global_id_pairs in smart_groups[0].items(): - new_group = context.scene.BIMClashProperties.smart_clash_groups.add() + new_group = props.smart_clash_groups.add() new_group.number = f"{smart_group}" for pair in global_id_pairs: for guid in pair: new_global_id = new_group.global_ids.add() - new_global_id.guid = guid + new_global_id.name = guid return {"FINISHED"} @@ -478,15 +470,19 @@ class SelectSmartGroup(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() and context.visible_objects and context.scene.BIMClashProperties.active_smart_group + props = tool.Clash.get_clash_props() + return tool.Ifc.get() and context.visible_objects and props.active_smart_group def execute(self, context): - selected_smart_group = context.scene.BIMClashProperties.active_smart_group - products = [] + ifc_file = tool.Ifc.get() + props = tool.Clash.get_clash_props() + selected_smart_group = props.active_smart_group + assert selected_smart_group + products: list[ifcopenshell.entity_instance] = [] for global_id in selected_smart_group.global_ids: try: - products.append(tool.Ifc.get().by_guid(global_id.guid)) - except: + products.append(ifc_file.by_guid(global_id.name)) + except RuntimeError: continue tool.Spatial.select_products(products, unhide=True) context_override = tool.Blender.get_viewport_context() diff --git a/src/bonsai/bonsai/bim/module/clash/prop.py b/src/bonsai/bonsai/bim/module/clash/prop.py index b112964fe4..88b13a0e25 100644 --- a/src/bonsai/bonsai/bim/module/clash/prop.py +++ b/src/bonsai/bonsai/bim/module/clash/prop.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bonsai.bim.prop import StrProperty, Attribute, BIMFilterGroup from bpy.types import PropertyGroup from bpy.props import ( @@ -29,6 +30,8 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from mathutils import Vector +from typing import TYPE_CHECKING, Literal, Union class ClashSource(PropertyGroup): @@ -43,6 +46,10 @@ class ClashSource(PropertyGroup): name="Mode", ) + if TYPE_CHECKING: + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + mode: Literal["a", "i", "e"] + class Clash(PropertyGroup): a_global_id: StringProperty(name="A") @@ -51,9 +58,15 @@ class Clash(PropertyGroup): b_name: StringProperty(name="B Name") status: BoolProperty(name="Status", default=False) + if TYPE_CHECKING: + a_global_id: str + b_global_id: str + a_name: str + b_name: str + status: bool + class ClashSet(PropertyGroup): - name: StringProperty(name="Name") mode: EnumProperty( items=[ ( @@ -76,11 +89,25 @@ class ClashSet(PropertyGroup): b: CollectionProperty(name="Group B", type=ClashSource) clashes: CollectionProperty(name="Clashes", type=Clash) + if TYPE_CHECKING: + mode: Literal["intersection", "collision", "clearance"] + tolerance: float + clearance: float + allow_touching: bool + check_all: bool + a: bpy.types.bpy_prop_collection_idprop[ClashSource] + b: bpy.types.bpy_prop_collection_idprop[ClashSource] + clashes: bpy.types.bpy_prop_collection_idprop[Clash] + class SmartClashGroup(PropertyGroup): number: StringProperty(name="Number") global_ids: CollectionProperty(name="GlobalIDs", type=StrProperty) + if TYPE_CHECKING: + number: str + global_ids: bpy.types.bpy_prop_collection_idprop[StrProperty] + class BIMClashProperties(PropertyGroup): blender_clash_set_a: CollectionProperty(name="Blender Clash Set A", type=StrProperty) @@ -107,17 +134,33 @@ class BIMClashProperties(PropertyGroup): subtype="FILE_PATH", ) - @property - def active_clash_set(self): - if self.active_clash_set_index < len(self.clash_sets): - return self.clash_sets[self.active_clash_set_index] + if TYPE_CHECKING: + blender_clash_set_a: bpy.types.bpy_prop_collection_idprop[StrProperty] + blender_clash_set_b: bpy.types.bpy_prop_collection_idprop[StrProperty] + clash_sets: bpy.types.bpy_prop_collection_idprop[ClashSet] + should_create_clash_snapshots: bool + clash_results_path: str + smart_grouped_clashes_path: str + active_clash_set_index: int + active_clash_index: int + smart_clash_groups: bpy.types.bpy_prop_collection_idprop[SmartClashGroup] + active_smart_group_index: int + smart_clash_grouping_max_distance: int + p1: Vector + p2: Vector + active_clash_text: str + export_path: str @property - def active_smart_group(self): - if self.active_smart_group_index < len(self.smart_clash_groups): - return self.smart_clash_groups[self.active_smart_group_index] + def active_clash_set(self) -> Union[ClashSet, None]: + return tool.Blender.get_active_uilist_element(self.clash_sets, self.active_clash_set_index) @property - def active_clash(self): - if self.active_clash_index < len(self.active_clash_set.clashes): - return self.active_clash_set.clashes[self.active_clash_index] + def active_smart_group(self) -> Union[SmartClashGroup, None]: + return tool.Blender.get_active_uilist_element(self.smart_clash_groups, self.active_smart_group_index) + + @property + def active_clash(self) -> Union[Clash, None]: + if not (clash_set := self.active_clash_set): + return None + return tool.Blender.get_active_uilist_element(clash_set.clashes, self.active_clash_index) diff --git a/src/bonsai/bonsai/bim/module/clash/ui.py b/src/bonsai/bonsai/bim/module/clash/ui.py index 2a95b54ab3..3943f21f04 100644 --- a/src/bonsai/bonsai/bim/module/clash/ui.py +++ b/src/bonsai/bonsai/bim/module/clash/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel from bonsai.bim.module.clash.data import ClashData @@ -36,9 +37,7 @@ class BIM_PT_ifcclash(Panel): ClashData.load() layout = self.layout - - scene = context.scene - props = scene.BIMClashProperties + props = tool.Clash.get_clash_props() row = layout.row(align=True) row.operator("bim.add_clash_set") @@ -157,7 +156,7 @@ class BIM_PT_smart_clash_manager(Panel): def draw(self, context): layout = self.layout - props = context.scene.BIMClashProperties + props = tool.Clash.get_clash_props() row = layout.row() layout.label(text="Select clash results to group:") diff --git a/src/bonsai/bonsai/bim/module/classification/data.py b/src/bonsai/bonsai/bim/module/classification/data.py index 1dff8c3875..e4c5e30ca8 100644 --- a/src/bonsai/bonsai/bim/module/classification/data.py +++ b/src/bonsai/bonsai/bim/module/classification/data.py @@ -118,7 +118,7 @@ class MaterialClassificationsData(ReferencesData): def references(cls): results = [] - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.materials and props.active_material_index < len(props.materials): material = props.materials[props.active_material_index] if material.ifc_definition_id: diff --git a/src/bonsai/bonsai/bim/module/classification/operator.py b/src/bonsai/bonsai/bim/module/classification/operator.py index 199fa2d61b..ba9f9d1928 100644 --- a/src/bonsai/bonsai/bim/module/classification/operator.py +++ b/src/bonsai/bonsai/bim/module/classification/operator.py @@ -25,19 +25,16 @@ import ifcopenshell.util.classification import ifcopenshell.util.element import bonsai.tool as tool import bonsai.bim.helper +from bpy_extras.io_utils import ImportHelper from bonsai.bim.ifc import IfcStore -class LoadClassificationLibrary(bpy.types.Operator, tool.Ifc.Operator): +class LoadClassificationLibrary(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.load_classification_library" bl_label = "Load Classification Library" + bl_description = "Load classification library from the provided filepath." filename_ext = ".ifc" filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} def _execute(self, context): IfcStore.classification_file = ifcopenshell.open(self.filepath) @@ -201,7 +198,8 @@ class EnableEditingClassification(bpy.types.Operator): def execute(self, context): def callback(name, prop, data): if name == "ReferenceTokens": - new = bpy.context.scene.BIMGeoreferenceProperties.projected_crs.add() + geo_props = tool.Georeference.get_georeference_props() + new = geo_props.projected_crs.add() new.name = name new.data_type = "string" new.is_null = data[name] is None @@ -239,7 +237,7 @@ class RemoveClassification(bpy.types.Operator, tool.Ifc.Operator): classification: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "classification.remove_classification", tool.Ifc.get(), @@ -459,6 +457,7 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): for prop in blender_properties: properties[prop.name] = prop.get_value() + # TODO: is this still the correct approach? if classification_pset.name == "undefined_set": if "ObjectType" in properties: if hasattr(element, "ObjectType"): diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index fcef0ad4bd..c171ff3cd6 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -21,7 +21,6 @@ import bonsai.bim.helper import bonsai.tool as tool import bonsai.bim.module.classification.prop as classification_prop from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.classification.data import ( ClassificationsData, ClassificationReferencesData, @@ -124,7 +123,7 @@ class ReferenceUI: self.sprops = context.scene.BIMClassificationProperties self.bprops = context.scene.BIMBSDDProperties self.props = context.scene.BIMClassificationReferenceProperties - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.draw_add_ui(context) @@ -312,7 +311,7 @@ class BIM_PT_material_classifications(Panel, ReferenceUI): def poll(cls, context): if not tool.Ifc.get(): return False - props = context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.is_editing and (material := props.active_material) and material.ifc_definition_id: return True return False diff --git a/src/bonsai/bonsai/bim/module/constraint/operator.py b/src/bonsai/bonsai/bim/module/constraint/operator.py index deed4d6335..4ae0dde395 100644 --- a/src/bonsai/bonsai/bim/module/constraint/operator.py +++ b/src/bonsai/bonsai/bim/module/constraint/operator.py @@ -23,7 +23,6 @@ import ifcopenshell.api.constraint import ifcopenshell.util.attribute import bonsai.bim.helper import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore class LoadObjectives(bpy.types.Operator): @@ -84,7 +83,7 @@ class AddObjective(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - result = ifcopenshell.api.run("constraint.add_objective", IfcStore.get_file()) + result = ifcopenshell.api.run("constraint.add_objective", tool.Ifc.get()) bpy.ops.bim.load_objectives() bpy.ops.bim.enable_editing_constraint(constraint=result.id()) return {"FINISHED"} @@ -116,7 +115,7 @@ class RemoveConstraint(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = context.scene.BIMConstraintProperties - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "constraint.remove_constraint", self.file, **{"constraint": self.file.by_id(self.constraint)} ) @@ -162,8 +161,12 @@ class AssignConstraint(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): self.file = tool.Ifc.get() - objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects - products = [self.file.by_id(obj_id) for obj in objs if (obj_id := obj.BIMObjectProperties.ifc_definition_id)] + objs = [bpy.data.objects[self.obj]] if self.obj else context.selected_objects + products = [ + self.file.by_id(obj_id) + for obj in objs + if (obj_id := tool.Blender.get_object_bim_props(obj).ifc_definition_id) + ] if products: ifcopenshell.api.run( "constraint.assign_constraint", @@ -185,8 +188,12 @@ class UnassignConstraint(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): self.file = tool.Ifc.get() - objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects - products = [self.file.by_id(obj_id) for obj in objs if (obj_id := obj.BIMObjectProperties.ifc_definition_id)] + objs = [bpy.data.objects[self.obj]] if self.obj else context.selected_objects + products = [ + self.file.by_id(obj_id) + for obj in objs + if (obj_id := tool.Blender.get_object_bim_props(obj).ifc_definition_id) + ] if products: ifcopenshell.api.run( "constraint.unassign_constraint", diff --git a/src/bonsai/bonsai/bim/module/constraint/ui.py b/src/bonsai/bonsai/bim/module/constraint/ui.py index f7d530a757..3693e9565a 100644 --- a/src/bonsai/bonsai/bim/module/constraint/ui.py +++ b/src/bonsai/bonsai/bim/module/constraint/ui.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.module.constraint.data import ConstraintsData, ObjectConstraintsData @@ -33,7 +33,7 @@ class BIM_PT_constraints(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not ConstraintsData.is_loaded: @@ -79,21 +79,21 @@ class BIM_PT_object_constraints(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - if not IfcStore.get_element(context.active_object.BIMObjectProperties.ifc_definition_id): - return False - return bool(context.active_object.BIMObjectProperties.ifc_definition_id) + props = tool.Blender.get_object_bim_props(obj) + return bool(tool.Ifc.get_object_by_identifier(props.ifc_definition_id)) def draw(self, context): if not ObjectConstraintsData.is_loaded: ObjectConstraintsData.load() obj = context.active_object - self.oprops = obj.BIMObjectProperties + assert obj + self.oprops = tool.Blender.get_object_bim_props(obj) self.sprops = context.scene.BIMConstraintProperties self.props = obj.BIMObjectConstraintProperties - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.draw_add_ui() diff --git a/src/bonsai/bonsai/bim/module/cost/operator.py b/src/bonsai/bonsai/bim/module/cost/operator.py index 00d5f5fec0..3cbe9b4d68 100644 --- a/src/bonsai/bonsai/bim/module/cost/operator.py +++ b/src/bonsai/bonsai/bim/module/cost/operator.py @@ -21,7 +21,7 @@ import bpy import ifcopenshell.api import bonsai.tool as tool -from bpy_extras.io_utils import ImportHelper +from bpy_extras.io_utils import ImportHelper, ExportHelper import bonsai.tool as tool import bonsai.core.cost as core from typing import get_args, TYPE_CHECKING @@ -678,7 +678,7 @@ class CalculateCostItemResourceValue(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class ExportCostSchedules(bpy.types.Operator): +class ExportCostSchedules(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_cost_schedules" bl_label = "Export Cost Schedule" bl_options = {"REGISTER", "UNDO"} @@ -701,9 +701,7 @@ class ExportCostSchedules(bpy.types.Operator): return {"FINISHED"} def invoke(self, context, event): - wm = context.window_manager - wm.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def draw(self, context): self.layout.label(text="Choose a format") @@ -740,15 +738,14 @@ class LoadProductCostItems(bpy.types.Operator): @classmethod def poll(cls, context): - if not tool.Ifc.get() or not (obj := context.active_object) or not (obj.BIMObjectProperties.ifc_definition_id): + if not tool.Ifc.get() or not (obj := context.active_object) or not (tool.Blender.get_ifc_definition_id(obj)): cls.poll_message_set("No IFC object is active.") return False return True def execute(self, context): - core.load_product_cost_items( - tool.Cost, product=tool.Ifc.get().by_id(context.active_object.BIMObjectProperties.ifc_definition_id) - ) + obj = context.active_object + core.load_product_cost_items(tool.Cost, product=tool.Ifc.get_entity(obj)) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/cost/prop.py b/src/bonsai/bonsai/bim/module/cost/prop.py index 092f429121..abe035fdb6 100644 --- a/src/bonsai/bonsai/bim/module/cost/prop.py +++ b/src/bonsai/bonsai/bim/module/cost/prop.py @@ -19,7 +19,6 @@ import bpy import ifcopenshell.api import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.classification.data import CostClassificationsData from bonsai.bim.module.cost.data import CostSchedulesData, CostItemRatesData, CostItemQuantitiesData from bonsai.bim.prop import StrProperty, Attribute @@ -83,7 +82,7 @@ def update_cost_item_identification(self, context): props = context.scene.BIMCostProperties if not props.is_cost_update_enabled or self.identification == "XXX": return - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "cost.edit_cost_item", self.file, @@ -98,7 +97,7 @@ def update_cost_item_name(self, context): props = context.scene.BIMCostProperties if not props.is_cost_update_enabled or self.name == "Unnamed": return - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "cost.edit_cost_item", self.file, diff --git a/src/bonsai/bonsai/bim/module/cost/ui.py b/src/bonsai/bonsai/bim/module/cost/ui.py index 67d14437ed..e8df647071 100644 --- a/src/bonsai/bonsai/bim/module/cost/ui.py +++ b/src/bonsai/bonsai/bim/module/cost/ui.py @@ -21,7 +21,6 @@ import bonsai.bim.helper import bonsai.bim.module.cost.prop as CostProp import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.cost.data import CostSchedulesData from typing import Any @@ -37,7 +36,7 @@ class BIM_PT_cost_schedules(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): @@ -709,18 +708,16 @@ class BIM_UL_cost_items_trait: class BIM_UL_cost_items(BIM_UL_cost_items_trait, UIList): - def __init__(self): - self.contract_operator = "bim.contract_cost_item" - self.expand_operator = "bim.expand_cost_item" + contract_operator = "bim.contract_cost_item" + expand_operator = "bim.expand_cost_item" class BIM_UL_cost_item_rates(BIM_UL_cost_items_trait, UIList): # A schedule of rates UIList is identical to a regular cost items UIList but # we want a separate UIList instance so that you can browse both lists # independently in Blender. So we use a trait. - def __init__(self): - self.contract_operator = "bim.contract_cost_item_rate" - self.expand_operator = "bim.expand_cost_item_rate" + contract_operator = "bim.contract_cost_item_rate" + expand_operator = "bim.expand_cost_item_rate" def draw_quantity_column(self, layout, cost_item): self.draw_uom_column(layout, cost_item) diff --git a/src/bonsai/bonsai/bim/module/covering/prop.py b/src/bonsai/bonsai/bim/module/covering/prop.py index 2a26d88e35..3dc65903aa 100644 --- a/src/bonsai/bonsai/bim/module/covering/prop.py +++ b/src/bonsai/bonsai/bim/module/covering/prop.py @@ -18,9 +18,13 @@ import bpy from bpy.types import PropertyGroup +from typing import TYPE_CHECKING class BIMCoveringProperties(PropertyGroup): ceiling_height: bpy.props.FloatProperty( name="ceiling_height", default=2.7, subtype="DISTANCE", description="Ceiling height" ) + + if TYPE_CHECKING: + ceiling_height: float diff --git a/src/bonsai/bonsai/bim/module/covering/workspace.py b/src/bonsai/bonsai/bim/module/covering/workspace.py index 383a2322d9..e0b0195b83 100644 --- a/src/bonsai/bonsai/bim/module/covering/workspace.py +++ b/src/bonsai/bonsai/bim/module/covering/workspace.py @@ -24,6 +24,7 @@ import bonsai.tool as tool from bonsai.bim.helper import prop_with_search from bonsai.bim.module.model.data import AuthoringData from bpy.types import WorkSpaceTool +from functools import partial class CoveringTool(WorkSpaceTool): @@ -45,9 +46,7 @@ class CoveringTool(WorkSpaceTool): CoveringToolUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) -def add_layout_hotkey(layout: bpy.types.UILayout, text: str, hotkey: str, description: str) -> None: - args = ("covering", layout, text, hotkey, description) - tool.Blender.add_layout_hotkey_operator(*args) +add_layout_hotkey = partial(tool.Blender.add_layout_hotkey_operator, tool_name="covering", module_name=__name__) class CoveringToolUI: @@ -55,7 +54,7 @@ class CoveringToolUI: def draw(cls, context, layout, ifc_element_type=None): cls.layout = layout cls.props = tool.Model.get_model_props() - cls.covering_props = context.scene.BIMCoveringProperties + cls.covering_props = tool.Covering.get_covering_props() row = cls.layout.row(align=True) if not tool.Ifc.get(): @@ -88,7 +87,7 @@ class CoveringToolUI: row = cls.layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_A") - row.operator("bim.add_constr_type_instance", text="Add") + row.operator("bim.add_occurrence", text="Add") row = cls.layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") @@ -136,8 +135,8 @@ class CoveringToolUI: if AuthoringData.data["ifc_classes"]: if cls.props.ifc_class: box = cls.layout.box() - if AuthoringData.data["type_thumbnail"]: - box.template_icon(icon_value=AuthoringData.data["type_thumbnail"], scale=5) + if thumbnail := AuthoringData.data["relating_type_data"].get("thumbnail"): + box.template_icon(icon_value=thumbnail, scale=5) else: op = box.operator("bim.load_type_thumbnails", text="Load Thumbnails", icon="FILE_REFRESH") op.ifc_class = cls.props.ifc_class @@ -145,8 +144,8 @@ class CoveringToolUI: class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.covering_hotkey" - bl_label = "Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -159,12 +158,11 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return operator.description or "" def _execute(self, context): - # self.props = context.scene.BIMCoveringProperties + self.props = tool.Covering.get_covering_props() getattr(self, f"hotkey_{self.hotkey}")() def invoke(self, context, event): # https://blender.stackexchange.com/questions/276035/how-do-i-make-operators-remember-their-property-values-when-called-from-a-hotkey - # self.props = context.scene.BIMSpatialProperties return self.execute(context) def draw(self, context): @@ -175,22 +173,22 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): element = tool.Ifc.get_entity(active_obj) container = tool.Root.get_default_container() - if AuthoringData.data["predefined_type"] == "FLOORING": + if AuthoringData.data["relating_type_data"].get("predefined_type") == "FLOORING": if element and bpy.context.selected_objects and element.is_a("IfcWall"): bpy.ops.bim.add_instance_flooring_coverings_from_walls() elif container: bpy.ops.bim.add_instance_flooring_covering_from_cursor() else: - bpy.ops.bim.add_constr_type_instance() - elif AuthoringData.data["predefined_type"] == "CEILING": + bpy.ops.bim.add_occurrence() + elif AuthoringData.data["relating_type_data"].get("predefined_type") == "CEILING": if element and bpy.context.selected_objects and element.is_a("IfcWall"): bpy.ops.bim.add_instance_ceiling_coverings_from_walls() elif container: bpy.ops.bim.add_instance_ceiling_covering_from_cursor() else: - bpy.ops.bim.add_constr_type_instance() + bpy.ops.bim.add_occurrence() else: - bpy.ops.bim.add_constr_type_instance() + bpy.ops.bim.add_occurrence() def hotkey_S_G(self): active_obj = bpy.context.active_object diff --git a/src/bonsai/bonsai/bim/module/covetool/operator.py b/src/bonsai/bonsai/bim/module/covetool/operator.py index 0bafcafa4f..a11e836750 100644 --- a/src/bonsai/bonsai/bim/module/covetool/operator.py +++ b/src/bonsai/bonsai/bim/module/covetool/operator.py @@ -20,8 +20,8 @@ import bpy import json import ifcopenshell import ifcopenshell.util.element +import bonsai.tool as tool from math import degrees, atan2 -from bonsai.bim.ifc import IfcStore from .api import Api @@ -92,7 +92,7 @@ class RunAnalysis(bpy.types.Operator): bl_label = "Run Analysis" def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.inputs = { "floors": [], "walls": [], @@ -181,12 +181,12 @@ class RunAnalysis(bpy.types.Operator): if modifier.type == "TRIANGULATE": return True - def get_covetool_category(self, obj): + def get_covetool_category(self, obj: bpy.types.Object): if not hasattr(obj, "data") or not isinstance(obj.data, bpy.types.Mesh): return - if not obj.BIMObjectProperties.ifc_definition_id: + if not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): return - element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = self.file.by_id(ifc_id) ifc_class = element.is_a() if "IfcSlab" in ifc_class: return "floors" diff --git a/src/bonsai/bonsai/bim/module/csv/operator.py b/src/bonsai/bonsai/bim/module/csv/operator.py index 7d59bc5a92..34a296fe6f 100644 --- a/src/bonsai/bonsai/bim/module/csv/operator.py +++ b/src/bonsai/bonsai/bim/module/csv/operator.py @@ -27,7 +27,7 @@ import ifcopenshell import ifcopenshell.util.selector import bonsai.tool as tool import bonsai.bim.module.drawing.scheduler as scheduler -from bonsai.bim.ifc import IfcStore +from bpy_extras.io_utils import ExportHelper, ImportHelper from bonsai.bim.handler import refresh_ui_data from typing import TYPE_CHECKING from collections import Counter @@ -43,7 +43,8 @@ class AddCsvAttribute(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - attribute = context.scene.CsvProperties.csv_attributes.add() + props = tool.Blender.get_csv_props() + props.csv_attributes.add() return {"FINISHED"} @@ -54,7 +55,8 @@ class RemoveCsvAttribute(bpy.types.Operator): index: bpy.props.IntProperty() def execute(self, context): - context.scene.CsvProperties.csv_attributes.remove(self.index) + props = tool.Blender.get_csv_props() + props.csv_attributes.remove(self.index) return {"FINISHED"} @@ -65,7 +67,8 @@ class RemoveAllCsvAttributes(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.CsvProperties.csv_attributes.clear() + props = tool.Blender.get_csv_props() + props.csv_attributes.clear() return {"FINISHED"} @@ -77,8 +80,9 @@ class ReorderCsvAttribute(bpy.types.Operator): new_index: bpy.props.IntProperty() def execute(self, context): - old = context.scene.CsvProperties.csv_attributes[self.old_index] - new = context.scene.CsvProperties.csv_attributes[self.new_index] + props = tool.Blender.get_csv_props() + old = props.csv_attributes[self.old_index] + new = props.csv_attributes[self.new_index] props = ["name", "header", "sort", "group", "varies_value", "summary", "formatting"] for prop in props: value = getattr(new, prop) @@ -87,16 +91,16 @@ class ReorderCsvAttribute(bpy.types.Operator): return {"FINISHED"} -class ImportCsvAttributes(bpy.types.Operator): +class ImportCsvAttributes(bpy.types.Operator, ImportHelper): bl_idname = "bim.import_csv_attributes" bl_label = "Load CSV Settings" bl_description = "Import a json template for CSV export" bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filename_ext = ".json" def execute(self, context): - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() data = json.load(open(self.filepath)) tool.Search.import_filter_query(data["query"], props.filter_groups) @@ -122,22 +126,17 @@ class ImportCsvAttributes(bpy.types.Operator): setattr(new, prop, attribute[prop]) return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class ExportCsvAttributes(bpy.types.Operator): +class ExportCsvAttributes(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_csv_attributes" bl_label = "Save CSV Settings" bl_options = {"REGISTER", "UNDO"} bl_description = "Save a json template for CSV export" filename_ext = ".json" filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() settings = {} for prop in [ @@ -176,43 +175,42 @@ class ExportCsvAttributes(bpy.types.Operator): return {"FINISHED"} - def invoke(self, context, event): - self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".json") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class ExportIfcCsv(bpy.types.Operator): +class ExportIfcCsv(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_ifccsv" bl_label = "Export IFC" + bl_description = "Export IFC data as a spreadsheet." filename_ext = ".csv" filepath: bpy.props.StringProperty(subtype="FILE_PATH") @classmethod def poll(cls, context): - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() if not props.should_load_from_memory and not props.csv_ifc_file: cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.") return False return True + @classmethod + def description(cls, context, properties): + props = tool.Blender.get_csv_props() + if props.format == "web": + return "Open Web UI for spreadsheet data export." + return f"Export IFC data as a spreadsheet by the provided filepath in '{props.format}' format ." + def invoke(self, context, event): - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() if props.format == "web": return self.execute(context) - self.filepath = bpy.path.ensure_ext(bpy.data.filepath, f".{props.format}") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def execute(self, context): import ifccsv - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() self.filepath = bpy.path.ensure_ext(self.filepath, f".{props.format}") if props.should_load_from_memory: - ifc_file = IfcStore.get_file() + ifc_file = tool.Ifc.get() else: ifc_file = ifcopenshell.open(props.csv_ifc_file) results = ifcopenshell.util.selector.filter_elements( @@ -288,16 +286,16 @@ class ExportIfcCsv(bpy.types.Operator): ] -class ImportIfcCsv(bpy.types.Operator, tool.Ifc.Operator): +class ImportIfcCsv(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_ifccsv" bl_label = "Import to IFC" + bl_description = "Import IFC data from a spreadsheet." bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.csv;*.ods;*.xlsx", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") @classmethod def poll(cls, context): - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() if not props.should_load_from_memory and not props.csv_ifc_file: cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.") return False @@ -305,16 +303,14 @@ class ImportIfcCsv(bpy.types.Operator, tool.Ifc.Operator): def invoke(self, context, event): self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".csv") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def _execute(self, context): import ifccsv - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() if props.should_load_from_memory: - ifc_file = IfcStore.get_file() + ifc_file = tool.Ifc.get() else: ifc_file = ifcopenshell.open(props.csv_ifc_file) ifc_csv = ifccsv.IfcCsv() @@ -338,18 +334,15 @@ class ImportIfcCsv(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class SelectCsvIfcFile(bpy.types.Operator): +class SelectCsvIfcFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_csv_ifc_file" bl_label = "Select CSV IFC File" + bl_description = "Select IFC file for spreadsheet import/export." bl_options = {"REGISTER", "UNDO"} filename_ext = ".ifc" filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): - context.scene.CsvProperties.csv_ifc_file = self.filepath + props = tool.Blender.get_csv_props() + props.csv_ifc_file = self.filepath return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/csv/prop.py b/src/bonsai/bonsai/bim/module/csv/prop.py index 9426eea5a5..f66ee75c51 100644 --- a/src/bonsai/bonsai/bim/module/csv/prop.py +++ b/src/bonsai/bonsai/bim/module/csv/prop.py @@ -29,6 +29,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal class CsvAttribute(PropertyGroup): @@ -59,6 +60,14 @@ class CsvAttribute(PropertyGroup): ) formatting: StringProperty(default="{{value}}", name="Formatting") + if TYPE_CHECKING: + header: str + sort: Literal["NONE", "ASC", "DESC"] + group: Literal["NONE", "GROUP", "CONCAT", "VARIES", "SUM", "AVERAGE", "MIN", "MAX"] + varies_value: str + summary: Literal["NONE", "SUM", "AVERAGE", "MIN", "MAX"] + formatting: str + class CsvProperties(PropertyGroup): csv_ifc_file: StringProperty(default="", name="IFC File") @@ -104,3 +113,26 @@ class CsvProperties(PropertyGroup): name="Load from Memory", description="Use IFC file currently loaded in Bonsai", ) + + if TYPE_CHECKING: + csv_ifc_file: str + ifc_selector: str + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + csv_attributes: bpy.types.bpy_prop_collection_idprop[CsvAttribute] + should_generate_svg: bool + should_preserve_existing: bool + include_global_id: bool + null_value: str + empty_value: str + true_value: str + false_value: str + concat_value: str + csv_delimiter: Literal["NONE", "ASC", "DESC"] + format: Literal["csv", "xlsx", "ods", "web"] + csv_custom_delimiter: str + should_show_settings: bool + should_show_sort: bool + should_show_group: bool + should_show_summary: bool + should_show_formatting: bool + should_load_from_memory: bool diff --git a/src/bonsai/bonsai/bim/module/csv/ui.py b/src/bonsai/bonsai/bim/module/csv/ui.py index 0d4d6ef11e..bc994a0815 100644 --- a/src/bonsai/bonsai/bim/module/csv/ui.py +++ b/src/bonsai/bonsai/bim/module/csv/ui.py @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . import bonsai.bim.helper +import bonsai.tool as tool from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.search.data import SearchData @@ -33,11 +33,9 @@ class BIM_PT_ifccsv(Panel): def draw(self, context): layout = self.layout + props = tool.Blender.get_csv_props() - scene = context.scene - props = scene.CsvProperties - - if IfcStore.get_file(): + if tool.Ifc.get(): row = layout.row(align=True) row.prop(props, "should_load_from_memory") row.operator("bim.import_csv_attributes", icon="IMPORT", text="") @@ -49,7 +47,7 @@ class BIM_PT_ifccsv(Panel): row.operator("bim.export_csv_attributes", icon="EXPORT", text="") row.prop(props, "should_show_settings", icon="PREFERENCES", text="") - if not IfcStore.get_file() or not props.should_load_from_memory: + if not tool.Ifc.get() or not props.should_load_from_memory: row = layout.row(align=True) row.prop(props, "csv_ifc_file") row.operator("bim.select_csv_ifc_file", icon="FILE_FOLDER", text="") @@ -126,5 +124,8 @@ class BIM_PT_ifccsv(Panel): row.operator("bim.remove_csv_attribute", icon="X", text="").index = index row = layout.row(align=True) - row.operator("bim.export_ifccsv", icon="EXPORT", text="Export IFC to " + props.format.upper()) + if props.format == "web": + row.operator("bim.export_ifccsv", icon="EXPORT", text="Open Web UI") + else: + row.operator("bim.export_ifccsv", icon="EXPORT", text="Export IFC to " + props.format.upper()) row.operator("bim.import_ifccsv", icon="IMPORT") diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 0058c70a3a..0b1c59211a 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -36,6 +36,7 @@ import bonsai.core.profile import bonsai.core.type import bonsai.bim.handler import bonsai.bim.import_ifc as import_ifc +from bpy_extras.io_utils import ImportHelper, ExportHelper from pathlib import Path from bonsai import get_debug_info, format_debug_info from bonsai.bim.ifc import IfcStore @@ -78,10 +79,10 @@ class PrintIfcFile(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def execute(self, context): - print(IfcStore.get_file().wrapped_data.to_string()) + print(tool.Ifc.get().wrapped_data.to_string()) return {"FINISHED"} @@ -101,13 +102,14 @@ class ConvertToBlender(bpy.types.Operator): if tool.Geometry.has_mesh_properties(data): if data.library: continue - data.BIMMeshProperties.ifc_definition_id = 0 + tool.Geometry.get_mesh_props(data).ifc_definition_id = 0 for material in bpy.data.materials: if material.library: continue tool.Ifc.unlink(obj=material) - context.scene.BIMProperties.ifc_file = "" - context.scene.BIMDebugProperties.attributes.clear() + bim_props = tool.Blender.get_bim_props() + bim_props.ifc_file = "" + tool.Debug.get_debug_props().attributes.clear() IfcStore.purge() bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} @@ -160,7 +162,8 @@ class ProfileImportIFC(bpy.types.Operator): if not tool.Ifc.get(): cls.poll_message_set("No IFC file loaded.") return False - if not context.scene.BIMProperties.ifc_file: + bim_props = tool.Blender.get_bim_props() + if not bim_props.ifc_file: cls.poll_message_set("Current IFC file is not saved.") return False return True @@ -184,10 +187,10 @@ class CreateAllShapes(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() elements = self.file.by_type("IfcElement") + self.file.by_type("IfcSpace") total = len(elements) @@ -256,7 +259,7 @@ class CreateShapeFromStepId(bpy.types.Operator): logger = logging.getLogger("ImportIFC") self.ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) self.file = tool.Ifc.get() - element = self.file.by_id(self.step_id or int(context.scene.BIMDebugProperties.step_id)) + element = self.file.by_id(self.step_id or int(tool.Debug.get_debug_props().step_id)) settings = ifcopenshell.geom.settings() settings.set("keep-bounding-boxes", True) if self.should_include_curves: @@ -309,7 +312,7 @@ class RewindInspector(bpy.types.Operator): bl_description = "Rewind the Inspector to the previously inspected element" def execute(self, context): - props = context.scene.BIMDebugProperties + props = tool.Debug.get_debug_props() total_breadcrumbs = len(props.step_id_breadcrumb) if total_breadcrumbs < 2: return {"FINISHED"} @@ -328,13 +331,13 @@ class InspectFromStepId(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def execute(self, context): - self.file = IfcStore.get_file() - debug_props = context.scene.BIMDebugProperties + self.file = tool.Ifc.get() + debug_props = tool.Debug.get_debug_props() debug_props.active_step_id = self.step_id - crumb = context.scene.BIMDebugProperties.step_id_breadcrumb.add() + crumb = debug_props.step_id_breadcrumb.add() crumb.name = str(self.step_id) element = self.file.by_id(self.step_id) debug_props.attributes.clear() @@ -380,12 +383,12 @@ class InspectFromObject(bpy.types.Operator): def get_active_object_ifc_definition(cls, context: bpy.types.Context) -> Union[int, None]: obj = context.active_object assert obj - if ifc_id := obj.BIMObjectProperties.ifc_definition_id: + if ifc_id := tool.Blender.get_ifc_definition_id(obj): return ifc_id if ( (data := obj.data) and tool.Geometry.has_mesh_properties(data) - and (ifc_id := data.BIMMeshProperties.ifc_definition_id) + and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) ): return ifc_id @@ -422,7 +425,7 @@ class PrintObjectPlacement(bpy.types.Operator): return self.execute(context) def execute(self, context): - placement = ifcopenshell.util.placement.get_local_placement(IfcStore.get_file().by_id(self.step_id)) + placement = ifcopenshell.util.placement.get_local_placement(tool.Ifc.get().by_id(self.step_id)) if self.create_empty_object: bpy.ops.object.empty_add(type="ARROWS") si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -438,28 +441,25 @@ class ParseExpress(bpy.types.Operator): bl_label = "Parse Express" def execute(self, context): - core.parse_express(tool.Debug, context.scene.BIMDebugProperties.express_file) + props = tool.Debug.get_debug_props() + core.parse_express(tool.Debug, props.express_file) bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} -class SelectExpressFile(bpy.types.Operator): +class SelectExpressFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_express_file" bl_label = "Select Express File" bl_options = {"REGISTER", "UNDO"} bl_description = "Select an IFC EXPRESS definition" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.exp", options={"HIDDEN"}) def execute(self, context): + props = tool.Debug.get_debug_props() if os.path.exists(self.filepath) and "exp" in os.path.splitext(self.filepath)[1]: - context.scene.BIMDebugProperties.express_file = self.filepath + props.express_file = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class PurgeHdf5Cache(bpy.types.Operator): bl_idname = "bim.purge_hdf5_cache" @@ -497,7 +497,7 @@ class PrintUnusedElementStats(bpy.types.Operator): ignore_styled_items: bpy.props.BoolProperty(name="Ignore Styled Items", default=True) def execute(self, context): - props = context.scene.BIMDebugProperties + props = tool.Debug.get_debug_props() # ignore some classes that could have zero 0 inverse references by their nature ignore_classes = [] if self.ignore_contexts: @@ -509,6 +509,7 @@ class PrintUnusedElementStats(bpy.types.Operator): if self.ignore_styled_items: ignore_classes += ["IfcStyledItem"] ignore_classes += [ + "IfcDocumentReference", # Document references for sheet elements (drawings, schedules, etc). "IfcIndexedColourMap", # Only referenced by inverse attributes. "IfcIndexedTextureMap", # Only referenced by inverse attributes. ] @@ -518,12 +519,12 @@ class PrintUnusedElementStats(bpy.types.Operator): return {"FINISHED"} -class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): +class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator, ExportHelper): bl_idname = "bim.purge_unused_elements_by_class" bl_label = "Purge Unused Elements By Class" bl_description = ( "Will find all elements of class that have no inverse references and will remove them, use very carefully.\n" - "If IFC class is provided in neighbour field, will purge only elemnts of the provided class. Otherwise will purge all white-listed elements.\n" + "If IFC class is provided in neighbour field, will purge only elements of the provided class. Otherwise will purge all white-listed elements.\n" "ALT+CLICK to provide a path where to save the IFC file with the removed elements (note changes will be applied to the current IFC too)" ) bl_options = {"REGISTER", "UNDO"} @@ -532,7 +533,7 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): def invoke(self, context, event): if event.type == "LEFTMOUSE" and event.alt: - context.window_manager.fileselect_add(self) + return ExportHelper.invoke(self, context, event) return self.execute(context) @classmethod @@ -543,7 +544,7 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): - props = context.scene.BIMDebugProperties + props = tool.Debug.get_debug_props() if props.ifc_class_purge: purged_elements = core.purge_unused_elements(tool.Ifc, tool.Debug, props.ifc_class_purge) self.report({"INFO"}, f"{purged_elements} unused elements found and removed.") @@ -609,18 +610,30 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): "IfcUnitAssignment", "IfcVirtualGridIntersection", ] + whitelist_exceptions = { + # Document references for sheet elements (drawings, schedules, etc). + "IfcExternalReference": ("IfcDocumentReference",), + } total_purged = 0 + schema = tool.Ifc.schema() while True: total_batches = 0 print("*" * 100) total_batch_purged = 0 for ifc_class in whitelisted_classes: total_class_purged = 0 + + # Ensure class is present in the schema. try: - elements = tool.Ifc.get().by_type(ifc_class) - except: - continue # Probably not in this schema? + schema.declaration_by_name(ifc_class) + except RuntimeError: + continue + + elements = tool.Ifc.get().by_type(ifc_class) + if ifc_class in whitelist_exceptions: + elements = [e for e in elements if not any(e.is_a(c) for c in whitelist_exceptions[ifc_class])] + to_purge = set() for element in elements: try: @@ -687,15 +700,17 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): if purged == 0: return - scene = context.scene if object_type == "PROFILE": - if scene.BIMProfileProperties.is_editing: + props = tool.Profile.get_profile_props() + if props.is_editing: bpy.ops.bim.load_profiles() elif object_type == "STYLE": - if scene.BIMStylesProperties.is_editing: + props = tool.Style.get_style_props() + if props.is_editing: bpy.ops.bim.load_styles() elif object_type == "MATERIAL": - if scene.BIMMaterialProperties.is_editing: + props = tool.Material.get_material_props() + if props.is_editing: bpy.ops.bim.load_materials() @@ -735,15 +750,17 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): if merged == 0: return - scene = context.scene if object_type == "PROFILE": - if scene.BIMProfileProperties.is_editing: + props = tool.Profile.get_profile_props() + if props.is_editing: bpy.ops.bim.load_profiles() elif object_type == "STYLE": - if scene.BIMStylesProperties.is_editing: + props = tool.Style.get_style_props() + if props.is_editing: bpy.ops.bim.load_styles() elif object_type == "MATERIAL": - if scene.BIMMaterialProperties.is_editing: + props = tool.Material.get_material_props() + if props.is_editing: bpy.ops.bim.load_materials() @@ -799,7 +816,7 @@ class DebugActiveDrawing(bpy.types.Operator): ) def execute(self, context: bpy.types.Context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() drawing_item = props.drawings[props.active_drawing_index] drawing = tool.Ifc.get().by_id(drawing_item.ifc_definition_id) diff --git a/src/bonsai/bonsai/bim/module/debug/prop.py b/src/bonsai/bonsai/bim/module/debug/prop.py index 01076ccde3..5a28b22f79 100644 --- a/src/bonsai/bonsai/bim/module/debug/prop.py +++ b/src/bonsai/bonsai/bim/module/debug/prop.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bpy from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup from bpy.props import ( @@ -28,6 +29,9 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal, get_args + +DisplayType = Literal["BOUNDS", "WIRE", "SOLID", "TEXTURED"] class BIMDebugProperties(PropertyGroup): @@ -41,14 +45,23 @@ class BIMDebugProperties(PropertyGroup): inverse_references: CollectionProperty(name="Inverse References", type=Attribute) express_file: StringProperty(name="Express File") display_type: EnumProperty( - items=[ - ("BOUNDS", "Bounds", ""), - ("WIRE", "Wire", ""), - ("SOLID", "Solid", ""), - ("TEXTURED", "Textured", ""), - ], + items=[(display_type, display_type.capitalize(), "") for display_type in get_args(DisplayType)], name="Display Type", default="BOUNDS", ) ifc_class_purge: StringProperty(name="Unused Elements IFC Class", default="") package_name: StringProperty(name="Package Name", default="") + + if TYPE_CHECKING: + step_id: int + number_of_polygons: int + percentile_of_polygons: int + active_step_id: int + step_id_breadcrumb: bpy.types.bpy_prop_collection_idprop[StrProperty] + attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + inverse_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + inverse_references: bpy.types.bpy_prop_collection_idprop[Attribute] + express_file: str + display_type: str + ifc_class_purge: str + package_name: str diff --git a/src/bonsai/bonsai/bim/module/debug/ui.py b/src/bonsai/bonsai/bim/module/debug/ui.py index 3611920095..c2c43bd4bb 100644 --- a/src/bonsai/bonsai/bim/module/debug/ui.py +++ b/src/bonsai/bonsai/bim/module/debug/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bpy.types import Panel @@ -32,10 +33,11 @@ class BIM_PT_debug(Panel): def draw(self, context): layout = self.layout - props = context.scene.BIMDebugProperties + props = tool.Debug.get_debug_props() + bim_props = tool.Blender.get_bim_props() row = self.layout.row(align=True) - row.prop(context.scene.BIMProperties, "ifc_file", text="") + row.prop(bim_props, "ifc_file", text="") row.operator("bim.validate_ifc_file", icon="CHECKMARK", text="") row.operator("bim.select_ifc_file", icon="FILE_FOLDER", text="") @@ -87,27 +89,25 @@ class BIM_PT_debug(Panel): row.prop(props, "step_id", text="") row = layout.split(factor=0.7, align=True) - row.operator("bim.select_high_polygon_meshes").threshold = context.scene.BIMDebugProperties.number_of_polygons + row.operator("bim.select_high_polygon_meshes").threshold = props.number_of_polygons row.prop(props, "number_of_polygons", text="") row = layout.split(factor=0.7, align=True) - row.operator("bim.select_highest_polygon_meshes").percentile = ( - context.scene.BIMDebugProperties.percentile_of_polygons - ) + row.operator("bim.select_highest_polygon_meshes").percentile = props.percentile_of_polygons row.prop(props, "percentile_of_polygons", text="") row = layout.split(factor=0.5, align=True) row.prop(props, "display_type", text="") - row.operator("bim.override_display_type").display = context.scene.BIMDebugProperties.display_type + row.operator("bim.override_display_type").display = props.display_type layout.operator("bim.purge_unused_representations") row = layout.row(align=True) - row.prop(context.scene.BIMDebugProperties, "ifc_class_purge", text="") + row.prop(props, "ifc_class_purge", text="") row.operator("bim.purge_unused_elements_by_class", text="Purge Orphaned", icon="TRASH") row.operator("bim.print_unused_elements_stats", text="", icon="INFO") - if context.active_object and context.active_object.data: - mprops = context.active_object.data.BIMMeshProperties + if context.active_object and (data := context.active_object.data): + mprops = tool.Geometry.get_mesh_props(data) row = layout.row() row.operator("bim.get_representation_ifc_parameters") for index, ifc_parameter in enumerate(mprops.ifc_parameters): @@ -123,7 +123,7 @@ class BIM_PT_debug(Panel): row.operator("bim.rewind_inspector", icon="FRAME_PREV", text="") row.prop(props, "active_step_id", text="") row = layout.row(align=True) - row.operator("bim.inspect_from_step_id").step_id = context.scene.BIMDebugProperties.active_step_id + row.operator("bim.inspect_from_step_id").step_id = props.active_step_id row.operator("bim.inspect_from_object") if props.attributes: diff --git a/src/bonsai/bonsai/bim/module/diff/data.py b/src/bonsai/bonsai/bim/module/diff/data.py index c8fda6850c..ac3e545f43 100644 --- a/src/bonsai/bonsai/bim/module/diff/data.py +++ b/src/bonsai/bonsai/bim/module/diff/data.py @@ -43,7 +43,7 @@ class DiffData: @classmethod def diff_json(cls): - props = bpy.context.scene.DiffProperties + props = tool.Blender.get_diff_props() if not props.diff_json_file: cls.diff = None return diff --git a/src/bonsai/bonsai/bim/module/diff/operator.py b/src/bonsai/bonsai/bim/module/diff/operator.py index ead5dd6e04..954e43ac1e 100644 --- a/src/bonsai/bonsai/bim/module/diff/operator.py +++ b/src/bonsai/bonsai/bim/module/diff/operator.py @@ -24,24 +24,23 @@ import ifcopenshell import bonsai.bim.handler import bonsai.bim.import_ifc import bonsai.tool as tool +from bpy_extras.io_utils import ImportHelper, ExportHelper from bonsai.bim.ifc import IfcStore -class SelectDiffJsonFile(bpy.types.Operator): +class SelectDiffJsonFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_diff_json_file" bl_label = "Select Diff JSON File" + bl_description = "Select filepath for IFC diff results." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) + filename_ext = ".json" def execute(self, context): - context.scene.DiffProperties.diff_json_file = self.filepath + props = tool.Blender.get_diff_props() + props.diff_json_file = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class VisualiseDiff(bpy.types.Operator): bl_idname = "bim.visualise_diff" @@ -50,7 +49,8 @@ class VisualiseDiff(bpy.types.Operator): def execute(self, context): ifc_file = tool.Ifc.get() - with open(context.scene.DiffProperties.diff_json_file, "r") as file: + props = tool.Blender.get_diff_props() + with open(props.diff_json_file, "r") as file: diff = json.load(file) for obj in context.visible_objects: obj.color = (1.0, 1.0, 1.0, 1.0) @@ -65,13 +65,14 @@ class VisualiseDiff(bpy.types.Operator): obj.color = (0.0, 0.0, 0.7, 1.0) continue - if not obj.BIMObjectProperties.ifc_definition_id: + if not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): continue ifc_file = "" for scene in obj.users_scene: - if scene.BIMProperties.ifc_file: - ifc_file = scene.BIMProperties.ifc_file + bim_props = tool.Blender.get_bim_props(scene) + if bim_props.ifc_file: + ifc_file = bim_props.ifc_file if scene.library: break @@ -83,7 +84,7 @@ class VisualiseDiff(bpy.types.Operator): element_file = ifc_file try: - element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = element_file.by_id(ifc_id) except: continue global_id = getattr(element, "GlobalId", None) @@ -100,55 +101,45 @@ class VisualiseDiff(bpy.types.Operator): return {"FINISHED"} -class SelectDiffOldFile(bpy.types.Operator): +class SelectDiffOldFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_diff_old_file" bl_label = "Select Diff Old File" + bl_description = "Select filepath for an old IFC file to compare." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) + filename_ext = ".ifc" def execute(self, context): - context.scene.DiffProperties.old_file = self.filepath + props = tool.Blender.get_diff_props() + props.old_file = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectDiffNewFile(bpy.types.Operator): +class SelectDiffNewFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_diff_new_file" bl_label = "Select Diff New File" + bl_description = "Select filepath for a new IFC file to compare." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) + filename_ext = ".ifc" def execute(self, context): - context.scene.DiffProperties.new_file = self.filepath + props = tool.Blender.get_diff_props() + props.new_file = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class ExecuteIfcDiff(bpy.types.Operator): +class ExecuteIfcDiff(bpy.types.Operator, ExportHelper): bl_idname = "bim.execute_ifc_diff" bl_label = "Execute IFC Diff" + bl_description = "Compare two IFC files and save a json diff report by the provided filepath." filename_ext = ".json" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) - def invoke(self, context, event): - self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".json") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} - def execute(self, context): import ifcdiff - self.props = context.scene.DiffProperties + self.props = tool.Blender.get_diff_props() if tool.Ifc.get(): if self.props.active_file == "NONE": @@ -237,7 +228,8 @@ class SelectDiffObjects(bpy.types.Operator): def execute(self, context): ifc_file = tool.Ifc.get() - with open(context.scene.DiffProperties.diff_json_file, "r") as file: + props = tool.Blender.get_diff_props() + with open(props.diff_json_file, "r") as file: diff = json.load(file) for obj in context.visible_objects: obj.select_set(False) @@ -252,13 +244,14 @@ class SelectDiffObjects(bpy.types.Operator): obj.select_set(True) continue - if not obj.BIMObjectProperties.ifc_definition_id: + if not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): continue ifc_file = "" for scene in obj.users_scene: - if scene.BIMProperties.ifc_file: - ifc_file = scene.BIMProperties.ifc_file + bim_props = tool.Blender.get_bim_props(scene) + if bim_props.ifc_file: + ifc_file = bim_props.ifc_file if scene.library: break @@ -270,7 +263,7 @@ class SelectDiffObjects(bpy.types.Operator): element_file = ifc_file try: - element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = element_file.by_id(ifc_id) except: continue global_id = getattr(element, "GlobalId", None) diff --git a/src/bonsai/bonsai/bim/module/diff/prop.py b/src/bonsai/bonsai/bim/module/diff/prop.py index 3f09b39900..61da124a7c 100644 --- a/src/bonsai/bonsai/bim/module/diff/prop.py +++ b/src/bonsai/bonsai/bim/module/diff/prop.py @@ -30,18 +30,25 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal, get_args -def update_diff_json_file(self, context): +def update_diff_json_file(self: "DiffProperties", context: bpy.types.Context) -> None: DiffData.data["diff_json"] = DiffData.diff_json() +RelationshipType = Literal["type", "property", "container", "aggregate", "classification"] + + class Relationships(PropertyGroup): relationship: EnumProperty( name="Relationship", - items=[(r, r.capitalize(), r) for r in ["type", "property", "container", "aggregate", "classification"]], + items=[(r, r.capitalize(), r) for r in get_args(RelationshipType)], ) + if TYPE_CHECKING: + relationship: RelationshipType + class DiffProperties(PropertyGroup): diff_json_file: StringProperty(default="", name="JSON Output", update=update_diff_json_file) @@ -59,3 +66,12 @@ class DiffProperties(PropertyGroup): name="Active File", default="NEW", ) + + if TYPE_CHECKING: + diff_json_file: str + old_file: str + new_file: str + diff_relationships: bpy.types.bpy_prop_collection_idprop[Relationships] + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + should_load_changed_elements: bool + active_file: Literal["NONE", "OLD", "NEW"] diff --git a/src/bonsai/bonsai/bim/module/diff/ui.py b/src/bonsai/bonsai/bim/module/diff/ui.py index b147f884d1..91e0a6b48e 100644 --- a/src/bonsai/bonsai/bim/module/diff/ui.py +++ b/src/bonsai/bonsai/bim/module/diff/ui.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.diff.data import DiffData import bonsai.bim.helper import bonsai.tool as tool @@ -38,9 +37,7 @@ class BIM_PT_diff(Panel): layout = self.layout layout.use_property_split = True - - scene = context.scene - props = scene.DiffProperties + props = tool.Blender.get_diff_props() layout.label(text="IFC Diff Setup:") diff --git a/src/bonsai/bonsai/bim/module/document/data.py b/src/bonsai/bonsai/bim/module/document/data.py index 0a75605b5c..5cde82e499 100644 --- a/src/bonsai/bonsai/bim/module/document/data.py +++ b/src/bonsai/bonsai/bim/module/document/data.py @@ -52,7 +52,7 @@ class DocumentData: @classmethod def parent_document(cls): - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() if len(props.breadcrumbs): parent = tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) if tool.Ifc.get_schema() == "IFC2X3": diff --git a/src/bonsai/bonsai/bim/module/document/operator.py b/src/bonsai/bonsai/bim/module/document/operator.py index 70c3401fb0..2ed1a0a8d5 100644 --- a/src/bonsai/bonsai/bim/module/document/operator.py +++ b/src/bonsai/bonsai/bim/module/document/operator.py @@ -24,7 +24,6 @@ import ifcopenshell.util.element import bonsai.bim.handler import bonsai.tool as tool import bonsai.core.document as core -from bonsai.bim.ifc import IfcStore class LoadProjectDocuments(bpy.types.Operator): @@ -116,7 +115,7 @@ class EditDocument(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) diff --git a/src/bonsai/bonsai/bim/module/document/prop.py b/src/bonsai/bonsai/bim/module/document/prop.py index 2f8d32408f..ef4fb29ab9 100644 --- a/src/bonsai/bonsai/bim/module/document/prop.py +++ b/src/bonsai/bonsai/bim/module/document/prop.py @@ -30,6 +30,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING def update_document_name(self: "Document", context: bpy.types.Context) -> None: @@ -57,6 +58,11 @@ class Document(PropertyGroup): ) ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + identification: str + is_information: bool + ifc_definition_id: int + class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) @@ -65,3 +71,11 @@ class BIMDocumentProperties(PropertyGroup): breadcrumbs: CollectionProperty(name="Breadcrumbs", type=StrProperty) active_document_index: IntProperty(name="Active Document Index") is_editing: BoolProperty(name="Is Editing", default=False) + + if TYPE_CHECKING: + document_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + active_document_id: int + documents: bpy.types.bpy_prop_collection_idprop[Document] + breadcrumbs: bpy.types.bpy_prop_collection_idprop[StrProperty] + active_document_index: int + is_editing: bool diff --git a/src/bonsai/bonsai/bim/module/document/ui.py b/src/bonsai/bonsai/bim/module/document/ui.py index d2cf2e7ab6..fefb87fe6c 100644 --- a/src/bonsai/bonsai/bim/module/document/ui.py +++ b/src/bonsai/bonsai/bim/module/document/ui.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.module.document.data import DocumentData, ObjectDocumentData @@ -33,13 +33,13 @@ class BIM_PT_documents(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not DocumentData.is_loaded: DocumentData.load() - self.props = context.scene.BIMDocumentProperties + self.props = tool.Document.get_document_props() row = self.layout.row(align=True) row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE") @@ -90,20 +90,22 @@ class BIM_PT_object_documents(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - if not IfcStore.get_element(context.active_object.BIMObjectProperties.ifc_definition_id): + if not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): return False - return bool(context.active_object.BIMObjectProperties.ifc_definition_id) + if not tool.Ifc.get_object_by_identifier(ifc_id): + return False + return True def draw(self, context): if not ObjectDocumentData.is_loaded: ObjectDocumentData.load() obj = context.active_object - self.oprops = obj.BIMObjectProperties - self.props = context.scene.BIMDocumentProperties - self.file = IfcStore.get_file() + self.oprops = tool.Blender.get_object_bim_props(obj) + self.props = tool.Document.get_document_props() + self.file = tool.Ifc.get() self.draw_add_ui() diff --git a/src/bonsai/bonsai/bim/module/drawing/annotation.py b/src/bonsai/bonsai/bim/module/drawing/annotation.py index 8bc9ee46d5..d312298f1b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/annotation.py +++ b/src/bonsai/bonsai/bim/module/drawing/annotation.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import os import bpy import math @@ -50,7 +51,7 @@ class Annotator: curve.font = font props = tool.Drawing.get_text_props(obj) props.font_size = "2.5" - collection = bpy.context.scene.camera.BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(bpy.context.scene.camera).collection collection.objects.link(obj) Annotator.resize_text(obj) return obj @@ -125,12 +126,14 @@ class Annotator: return obj @staticmethod - def get_annotation_obj(drawing: ifcopenshell.entity_instance, object_type: str, data_type: str) -> bpy.types.Object: + def get_annotation_obj( + drawing: ifcopenshell.entity_instance, object_type: str, data_type: tool.Drawing.ANNOTATION_DATA_TYPE + ) -> bpy.types.Object: camera = tool.Ifc.get_object(drawing) co1, _, _, _ = Annotator.get_placeholder_coords(camera) matrix_world = tool.Drawing.get_camera_matrix(camera) matrix_world.translation = co1 - collection = camera.BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(camera).collection if object_type == "TEXT": obj = bpy.data.objects.new(object_type, None) @@ -147,11 +150,17 @@ class Annotator: collection.objects.link(obj) return obj - if object_type != "ANGLE": - for obj in collection.objects: - element = tool.Ifc.get_entity(obj) - if element and element.ObjectType == object_type and obj.type == object_type.upper(): - return obj + # TODO: remove as outdated? + # Is reusing the same objects preventing the creation of new annotations. + # if object_type != "ANGLE": + # for obj in collection.objects: + # element = tool.Ifc.get_entity(obj) + # if ( + # element + # and ifcopenshell.util.element.get_predefined_type(element) == object_type + # and obj.type == data_type.upper() + # ): + # return obj if data_type == "mesh": data = bpy.data.meshes.new(object_type) diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index be252a29c9..b5103b7b8c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -88,7 +88,8 @@ class SheetsData: project = tool.Ifc.get().by_type("IfcProject")[0] titleblocks_dir = ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "TitleblocksDir") if not titleblocks_dir: - titleblocks_dir = bpy.context.scene.DocProperties.titleblocks_dir + props = tool.Drawing.get_document_props() + titleblocks_dir = props.titleblocks_dir titleblocks_dir = tool.Ifc.resolve_uri(titleblocks_dir) if os.path.exists(titleblocks_dir): files.extend([str(f.stem) for f in Path(titleblocks_dir).glob("*.svg")]) @@ -120,23 +121,25 @@ class DrawingsData: @classmethod def location_hint(cls): - if bpy.context.scene.DocProperties.target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]: + props = tool.Drawing.get_document_props() + if props.target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]: results = [("0", "Origin", "")] results.extend( [(str(s.id()), s.Name or "Unnamed", "") for s in tool.Ifc.get().by_type("IfcBuildingStorey")] ) return results - elif bpy.context.scene.DocProperties.target_view in ["MODEL_VIEW"]: + elif props.target_view in ["MODEL_VIEW"]: return [(h.upper(), h, "") for h in ["Orthographic", "Perspective"]] return [(h.upper(), h, "") for h in ["North", "South", "East", "West"]] @classmethod def active_drawing_pset_data(cls): ifc_file = tool.Ifc.get() - drawing_id = bpy.context.scene.DocProperties.active_drawing_id + props = tool.Drawing.get_document_props() + drawing_id = props.active_drawing_id if drawing_id == 0: return {} - drawing = ifc_file.by_id(bpy.context.scene.DocProperties.active_drawing_id) + drawing = ifc_file.by_id(drawing_id) return ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") @@ -233,7 +236,8 @@ class DecoratorData: # stores 1 type of data per object data = {} cut_cache = {} - layerset_cache = {} + slice_cache = {} + fill_cache = {} @classmethod def get_batting_thickness(cls, obj): @@ -361,7 +365,11 @@ class DecoratorData: element = tool.Ifc.get_entity(obj) supported_object_types = ("DIMENSION", "DIAMETER", "SECTION_LEVEL", "PLAN_LEVEL", "RADIUS") - if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in supported_object_types: + if ( + not element + or not element.is_a("IfcAnnotation") + or ifcopenshell.util.element.get_predefined_type(element) not in supported_object_types + ): return None dimension_style = "arrow" @@ -379,6 +387,8 @@ class DecoratorData: suppress_zero_inches = pset_data.get("SuppressZeroInches", False) text_prefix = pset_data.get("TextPrefix", None) or "" text_suffix = pset_data.get("TextSuffix", None) or "" + custom_unit_list = pset_data.get("CustomUnit", None) or "" + custom_unit = custom_unit_list[0] if custom_unit_list else "" dimension_data = { "dimension_style": dimension_style, @@ -387,6 +397,7 @@ class DecoratorData: "text_prefix": text_prefix, "text_suffix": text_suffix, "fill_bg": fill_bg, + "custom_unit": custom_unit, } cls.data[obj.name] = dimension_data return dimension_data diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index e8fba1914d..19480b6a0d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import os import gpu import bpy import blf @@ -29,7 +28,7 @@ import ifcopenshell.util.unit import bonsai.tool as tool import bonsai.bim.module.drawing.helper as helper from pathlib import Path -from math import pi, sin, cos, tan, acos, atan, degrees, radians, ceil +from math import pi, sin, cos, acos, atan, degrees, radians from bpy.types import SpaceView3D from mathutils import Vector, Matrix from bpy_extras.view3d_utils import location_3d_to_region_2d @@ -423,7 +422,8 @@ class BaseDecorator: # font_size = 16 <-- this is a good default # TODO: need to synchronize it better with svg - magic_font_scale = bpy.context.scene.DocProperties.magic_font_scale + props = tool.Drawing.get_document_props() + magic_font_scale = props.magic_font_scale font_size_px = int(magic_font_scale * mm_to_px) * font_size_mm / 2.5 pos = pos - line_no * font_size_px * rotation_matrix[1] @@ -494,14 +494,20 @@ class BaseDecorator: self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs) @lru_cache(maxsize=None) - def format_value(self, context, value): + def format_value(self, context, value, custom_unit=None): drawing_pset_data = DrawingsData.data["active_drawing_pset_data"] precision = drawing_pset_data.get("MetricPrecision", None) if not precision: precision = drawing_pset_data.get("ImperialPrecision", None) decimal_places = drawing_pset_data.get("DecimalPlaces", None) - return format_distance(value, precision=precision, decimal_places=decimal_places) + return format_distance( + value, + precision=precision, + decimal_places=decimal_places, + suppress_zero_inches=True, + custom_unit=custom_unit, + ) def draw_asterisk(self, context: bpy.types.Context, pos: Vector, rotation: float = 0.0, scale: float = 1.0) -> None: """`pos` is a world space position\n @@ -588,7 +594,8 @@ class BaseDecorator: region3d = context.region_data text_dir = self.get_annotation_direction(context, obj) - pos = location_3d_to_region_2d(region, region3d, text_world_position) + if not (pos := location_3d_to_region_2d(region, region3d, text_world_position)): + return props = tool.Drawing.get_text_props(obj) text_data = DecoratorData.get_ifc_text_data(obj) if props.is_editing: @@ -721,6 +728,8 @@ class DimensionDecorator(BaseDecorator): v1 = Vector(vertices[i1]) p0 = location_3d_to_region_2d(region, region3d, v0) p1 = location_3d_to_region_2d(region, region3d, v1) + if not p0 or not p1: + return text_dir = p1 - p0 if text_dir.length < 1: continue @@ -736,7 +745,7 @@ class DimensionDecorator(BaseDecorator): if not show_description_only: length = (v1 - v0).length - text = self.format_value(context, length) + text = self.format_value(context, length, custom_unit=dimension_data["custom_unit"]) if isinstance(self, DiameterDecorator): text = "D" + text text = text_prefix + text + text_suffix @@ -871,6 +880,8 @@ class AngleDecorator(BaseDecorator): # calculate angle position p0, p1, p2 = [location_3d_to_region_2d(region, region3d, p) for p in vertices[i0 : i1 + 2]] + if not p0 or not p1 or not p2: + continue edge0 = p0 - p1 edge1 = p2 - p1 radius = min(edge0.length_squared, edge1.length_squared) ** 0.5 @@ -935,6 +946,8 @@ class RadiusDecorator(BaseDecorator): spline_points = self.get_spline_points(obj) p0, p1 = [location_3d_to_region_2d(region, region3d, p) for p in self.get_spline_points(obj)] + if not p0 or not p1: + return element = tool.Ifc.get_entity(obj) description = element.Description dimension_data = DecoratorData.get_dimension_data(obj) @@ -945,7 +958,7 @@ class RadiusDecorator(BaseDecorator): def get_text(): length = (spline_points[-1] - spline_points[-2]).length - return "R" + self.format_value(context, length) + return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"]) self.draw_dimension_text( context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center" @@ -967,7 +980,8 @@ class FallDecorator(BaseDecorator): region = context.region region3d = context.region_data dir = Vector((1, 0)) - pos = location_3d_to_region_2d(region, region3d, self.get_spline_end(obj)) + if not (pos := location_3d_to_region_2d(region, region3d, self.get_spline_end(obj))): + return spline = obj.data.splines[0] spline_points = spline.bezier_points if spline.bezier_points else spline.points @@ -976,6 +990,7 @@ class FallDecorator(BaseDecorator): # same function as in svgwriter.py def get_label_text(): element = tool.Ifc.get_entity(obj) + assert element B, A = [v.co.xyz for v in spline_points[:2]] rise = abs(A.z - B.z) O = A.copy() @@ -988,13 +1003,14 @@ class FallDecorator(BaseDecorator): angle = 90 # ues SLOPE_ANGLE as default - if element.ObjectType in ("FALL", "SLOPE_ANGLE"): + object_type = ifcopenshell.util.element.get_predefined_type(element) + if object_type in ("FALL", "SLOPE_ANGLE"): return f"{angle}°" - elif element.ObjectType == "SLOPE_FRACTION": + elif object_type == "SLOPE_FRACTION": if angle == 90: return "-" return f"{self.format_value(context, rise)} / {self.format_value(context, run)}" - elif element.ObjectType == "SLOPE_PERCENT": + elif object_type == "SLOPE_PERCENT": if angle == 90: return "-" return f"{round(angle_tg * 100)} %" @@ -1156,7 +1172,6 @@ class PlanLevelDecorator(BaseDecorator): self.draw_labels(context, obj, self.get_splines(obj)) def draw_labels(self, context, obj, splines): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) region = context.region region3d = context.region_data @@ -1166,6 +1181,8 @@ class PlanLevelDecorator(BaseDecorator): for verts in splines: p0, p1 = [location_3d_to_region_2d(region, region3d, v) for v in verts[:2]] + if not p0 or not p1: + continue text_dir = p1 - p0 if text_dir.length < 1: continue @@ -1432,8 +1449,10 @@ class GridDecorator(BaseDecorator): p1 = location_3d_to_region_2d(region, region3d, v1) dir = Vector((1, 0)) text = obj.name.split("/")[1].split(".")[0] - self.draw_label(context, text, p0, dir, vcenter=True, gap=0) - self.draw_label(context, text, p1, dir, vcenter=True, gap=0) + if p0: + self.draw_label(context, text, p0, dir, vcenter=True, gap=0) + if p1: + self.draw_label(context, text, p1, dir, vcenter=True, gap=0) class ElevationDecorator(BaseDecorator): @@ -1586,6 +1605,9 @@ class CutDecorator: @classmethod def install(cls, context): + DecoratorData.cut_cache.clear() + DecoratorData.slice_cache.clear() + DecoratorData.fill_cache.clear() if cls.installed: cls.uninstall() handler = cls() @@ -1600,38 +1622,50 @@ class CutDecorator: cls.installed = None def __call__(self, context): + if not context.scene.camera: + return + self.addon_prefs = tool.Blender.get_addon_preferences() selected_elements_color = self.addon_prefs.decorator_color_selected + self.fallback_colour = (0.3, 0.3, 0.3, 1) all_vertices = [] all_edges = [] selected_vertices = [] selected_edges = [] - layer_vertices = [] - layer_edges = [] + fills = {} all_vertex_i_offset = 0 selected_vertex_i_offset = 0 - layer_vertex_i_offset = 0 for obj in [o for o in bpy.context.visible_objects if o.type == "MESH"]: - verts, edges = self.decorate(context, obj) - if not verts: + if not (element := tool.Ifc.get_entity(obj)): continue + self.decorate(context, obj, element) - obj_layer_verts, obj_layer_edges = self.slice_layersets(context, obj, verts, edges) - if obj_layer_verts: - layer_vertices.extend(obj_layer_verts) - layer_edges.extend([[vi + layer_vertex_i_offset for vi in e] for e in obj_layer_edges]) - layer_vertex_i_offset += len(obj_layer_verts) + verts, edges = DecoratorData.cut_cache[element.id()] + if verts: + if obj.select_get(): + selected_vertices.extend(verts) + selected_edges.extend([[vi + selected_vertex_i_offset for vi in e] for e in edges]) + selected_vertex_i_offset += len(verts) + else: + all_vertices.extend(verts) + all_edges.extend([[vi + all_vertex_i_offset for vi in e] for e in edges]) + all_vertex_i_offset += len(verts) - if obj.select_get(): - selected_vertices.extend(verts) - selected_edges.extend([[vi + selected_vertex_i_offset for vi in e] for e in edges]) - selected_vertex_i_offset += len(verts) - else: - all_vertices.extend(verts) - all_edges.extend([[vi + all_vertex_i_offset for vi in e] for e in edges]) - all_vertex_i_offset += len(verts) + verts, edges = DecoratorData.slice_cache.get(element.id(), (None, None)) + if verts: + if obj.select_get(): + selected_vertices.extend(verts) + selected_edges.extend([[vi + selected_vertex_i_offset for vi in e] for e in edges]) + selected_vertex_i_offset += len(verts) + else: + all_vertices.extend(verts) + all_edges.extend([[vi + all_vertex_i_offset for vi in e] for e in edges]) + all_vertex_i_offset += len(verts) + + for colour, element_fills in DecoratorData.fill_cache[element.id()].items(): + fills.setdefault(colour, []).append(element_fills) gpu.state.point_size_set(1) gpu.state.blend_set("ALPHA") @@ -1649,9 +1683,10 @@ class CutDecorator: black = (0, 0, 0, 1) - if layer_vertices: - self.draw_batch("LINES", layer_vertices, black, layer_edges) - self.draw_batch("POINTS", layer_vertices, black) + for colour, element_fills in fills.items(): + for verts_tris in element_fills: + for verts, tris in verts_tris: + self.draw_batch("TRIS", verts, colour, tris) gpu.state.point_size_set(2) self.line_shader.uniform_float("lineWidth", 3.0) @@ -1669,310 +1704,176 @@ class CutDecorator: shader.uniform_float("color", color) batch.draw(shader) - def decorate(self, context, obj): - element = tool.Ifc.get_entity(obj) - if not element: - return None, None + def decorate(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + has_cut_cache = element.id() in DecoratorData.cut_cache + has_fill_cache = element.id() in DecoratorData.fill_cache - # Currently selected objects shall not be cached as they may be being moved / edited. - # If the camera is selected, we also disable the cache as the user may be moving the camera. - if obj.select_get() or context.scene.camera.select_get(): - verts, edges = None, None - else: - verts, edges = DecoratorData.cut_cache.get(element.id(), (None, None)) + # Currently selected objects must be recalculated as they may be being moved / edited. + # If the camera is selected, we also recalculate as the user may be moving the camera. - if verts is False: - return None, None - elif verts: - return verts, edges + if not has_cut_cache or obj.select_get() or context.scene.camera.select_get(): + self.recalculate_cut(context, obj, element) + if not has_fill_cache or obj.select_get() or context.scene.camera.select_get(): + self.recalculate_fill(context, obj, element) + def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): DecoratorData.cut_cache[element.id()] = (False, False) - return None, None - - if verts is None: + else: verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera) DecoratorData.cut_cache[element.id()] = (verts, edges) - return verts, edges - def slice_layersets(self, context, obj, cut_verts, cut_edges): - element = tool.Ifc.get_entity(obj) - - # Currently selected objects shall not be cached as they may be being moved / edited. - # If the camera is selected, we also disable the cache as the user may be moving the camera. - if obj.select_get() or context.scene.camera.select_get(): - verts, edges = None, None - else: - verts, edges = DecoratorData.layerset_cache.get(element.id(), (None, None)) - - if verts is False: - return None, None - elif verts is not None: - return verts, edges + def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: + element_id = element.id() if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): - DecoratorData.layerset_cache[element.id()] = (False, False) - return None, None + DecoratorData.fill_cache[element_id] = {} + return - if tool.Model.get_usage_type(element) != "LAYER2": - DecoratorData.layerset_cache[element.id()] = (False, False) - return None, None + DecoratorData.fill_cache[element_id] = {} - self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - layers = self.get_layer_data(element) + mesh = obj.data + bm_original = bmesh.new() + bm_original.from_mesh(mesh) - if not layers: - DecoratorData.layerset_cache[element.id()] = (False, False) - return None, None - - minx = min([co[0] for co in obj.bound_box]) - maxx = max([co[0] for co in obj.bound_box]) - min_edge = [Vector((minx, layers["offset"])), Vector((maxx, layers["offset"]))] - max_edge = [ - Vector((minx, layers["offset"] + layers["thickness"])), - Vector((maxx, layers["offset"] + layers["thickness"])), - ] - centerline = [ - Vector((minx, layers["offset"] + layers["thickness"] / 2)), - Vector((maxx, layers["offset"] + layers["thickness"] / 2)), - ] - connections = self.get_connections(element, obj, centerline, min_edge, max_edge) - - start_point = None - end_point = None - if connections["ATSTART"]: - boundary_edge = min_edge if connections["ATSTART"]["angle"] > 0 else max_edge - rel_dir = connections["ATSTART"]["centerline"][1] - connections["ATSTART"]["centerline"][0] - offset, _ = tool.Cad.intersect_edges(boundary_edge, [centerline[0], centerline[0] + rel_dir]) - offset_x = (offset - centerline[0]).length + abs(offset.x) - intersect, _ = tool.Cad.intersect_edges(boundary_edge, connections["ATSTART"]["centerline"]) - if tool.Cad.is_point_on_edge(intersect, boundary_edge): - centerline[0] = centerline[0] + Vector((offset_x, 0)) - start_point = centerline[0] + rel_dir - if connections["ATEND"]: - boundary_edge = min_edge if connections["ATEND"]["angle"] > 0 else max_edge - rel_dir = connections["ATEND"]["centerline"][1] - connections["ATEND"]["centerline"][0] - offset, _ = tool.Cad.intersect_edges(boundary_edge, [centerline[1], centerline[1] + rel_dir]) - - offset_x = (offset - centerline[1]).length + abs((maxx - abs(offset.x))) - intersect, _ = tool.Cad.intersect_edges(boundary_edge, connections["ATEND"]["centerline"]) - if tool.Cad.is_point_on_edge(intersect, boundary_edge): - centerline[1] = centerline[1] - Vector((offset_x, 0)) - end_point = centerline[1] + rel_dir - - min_segments = self.get_segments(connections["MINPATH"], centerline, start_point, end_point) - max_segments = self.get_segments(connections["MAXPATH"], centerline, start_point, end_point) - - final_segments = [] - for segment in min_segments: - segment_line = shapely.LineString([co for co in segment]) - for distance in layers["min_layers"]: - distance *= -1 - layer_line = shapely.offset_curve(segment_line, distance, join_style=shapely.BufferJoinStyle.mitre) - final_segments.append(list(layer_line.coords)) - for segment in max_segments: - segment_line = shapely.LineString([co for co in segment]) - for distance in layers["max_layers"]: - layer_line = shapely.offset_curve(segment_line, distance, join_style=shapely.BufferJoinStyle.mitre) - final_segments.append(list(layer_line.coords)) - - # Extrude and bisect layers - bm = bmesh.new() - verts = [] - edges = [] - offset = 0 - for segment in final_segments: - if not segment: - # Why does this occur? - continue - if isinstance(segment[0], tuple): - segment = [Vector(co) for co in segment] - verts.extend([bm.verts.new(co.to_3d()) for co in segment]) - [bm.edges.new((verts[i + offset], verts[i + 1 + offset])) for i in range(0, len(segment) - 1)] - offset += len(segment) - - extrusion_dir = (0, 0, 3) - extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=bm.edges[:]) - bmesh.ops.translate( - bm, vec=extrusion_dir, verts=[v for v in extruded_geom["geom"] if isinstance(v, bmesh.types.BMVert)] - ) - - verts, edges = self.bisect_mesh(obj, bm, context.scene.camera) - layer_linestrings = [shapely.LineString((verts[e[0]], verts[e[1]])) for e in edges] - - clipped_linestrings = [] - - polygons = shapely.polygonize([shapely.LineString((cut_verts[e[0]], cut_verts[e[1]])) for e in cut_edges]) - for polygon in polygons.geoms: - clipped_linestrings.extend([polygon.intersection(ls) for ls in layer_linestrings]) - - verts = [] - edges = [] - offset = 0 - for linestring in clipped_linestrings: - try: - linestring = list(linestring.coords) - except: - print("Failed ... ", linestring) - continue - verts.extend(linestring) - edges.extend([(i + offset, i + 1 + offset) for i in range(0, len(linestring) - 1)]) - offset += len(linestring) - - DecoratorData.layerset_cache[element.id()] = (verts, edges) - return verts, edges - - def bisect_mesh(self, obj, bm, camera): - camera_matrix = obj.matrix_world.inverted() @ camera.matrix_world + # Slice our mesh into a 2D drawing cut (2D is always easier) + camera_matrix = obj.matrix_world.inverted() @ context.scene.camera.matrix_world + global_offset = context.scene.camera.matrix_world.col[2].xyz * -context.scene.camera.data.clip_start plane_co = camera_matrix.translation plane_no = camera_matrix.col[2].xyz + geom = bm_original.verts[:] + bm_original.edges[:] + bm_original.faces[:] + bmesh.ops.bisect_plane( + bm_original, + geom=geom, + dist=0.0001, + plane_co=plane_co, + plane_no=plane_no, + clear_outer=True, + clear_inner=True, + ) + bmesh.ops.remove_doubles(bm_original, verts=bm_original.verts, dist=0.000001) + bmesh.ops.triangle_fill(bm_original, use_dissolve=True, edges=bm_original.edges) - global_offset = camera.matrix_world.col[2].xyz * -camera.data.clip_start + if not (material := ifcopenshell.util.element.get_material(element)): + verts, tris = self.get_bmesh_tris(obj, bm_original, context.scene.camera) + DecoratorData.fill_cache[element_id].setdefault(self.fallback_colour, []).append((verts, tris)) + return - # Run the bisect operation - geom = bm.verts[:] + bm.edges[:] + bm.faces[:] - results = bmesh.ops.bisect_plane(bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no) + if material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialLayerSetUsage"): + # Constituents, lists, and item styles not supported yet + material = ifcopenshell.util.element.get_materials(element)[0] + verts, tris = self.get_bmesh_tris(obj, bm_original, context.scene.camera) + colour = self.get_material_colour(material) + DecoratorData.fill_cache[element_id].setdefault(colour, []).append((verts, tris)) + return + + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if material.is_a("IfcMaterialLayerSetUsage"): + usage = material + layer_set = material.ForLayerSet + offset = usage.OffsetFromReferenceLine * self.unit_scale + sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 + elif material.is_a("IfcMaterialLayerSet"): + usage = None + layer_set = material + offset = 0 + sense_factor = 1 + + if len(layer_set.MaterialLayers) == 1: + material = layer_set.MaterialLayers[0].Material + verts, tris = self.get_bmesh_tris(obj, bm_original, context.scene.camera) + colour = self.get_material_colour(material) + DecoratorData.fill_cache[element_id].setdefault(colour, []).append((verts, tris)) + return + + bm = bm_original.copy() + prev_co = None + if not usage: + sense_factor = 1 # Assume the extrusion vector points in the direction sense + no = tool.Drawing.get_extrusion_vector(element).normalized() + co = Vector((0.0, 0.0, offset)) + elif usage.LayerSetDirection == "AXIS2": + co = Vector((0.0, offset, 0.0)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = no.cross(Vector([1.0, 0.0, 0.0])) + elif usage.LayerSetDirection == "AXIS3": + co = Vector((0.0, 0.0, offset)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = Vector([0.0, 0.0, 1.0]) + elif usage.LayerSetDirection == "AXIS1": + co = Vector((0.0, 0.0, offset)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = Vector([1.0, 0.0, 0.0]) + no *= sense_factor + last_i = len(layer_set.MaterialLayers) - 1 vert_map = {} verts = [] edges = [] + j = 0 + + for i, layer in enumerate(layer_set.MaterialLayers): + prev_co = co.copy() + co += no * layer.LayerThickness * self.unit_scale + if i != last_i: + bisect = bmesh.ops.bisect_plane( + bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no + ) + for geom in bisect["geom_cut"]: + if isinstance(geom, bmesh.types.BMVert): + verts.append(tuple((obj.matrix_world @ geom.co) + global_offset)) + vert_map[geom.index] = j + j += 1 + else: + # It seems as though edges always appear after verts + edges.append([vert_map[v.index] for v in geom.verts]) + + colour = self.get_material_colour(layer.Material) + bm_fill = bm_original.copy() + if i != last_i: + geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:] + bmesh.ops.bisect_plane(bm_fill, geom=geom, dist=0.0001, plane_co=co, plane_no=no, clear_outer=True) + if i != 0: + geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:] + bmesh.ops.bisect_plane(bm_fill, geom=geom, dist=0.0001, plane_co=prev_co, plane_no=no, clear_inner=True) + + DecoratorData.fill_cache[element_id].setdefault(colour, []).append( + self.get_bmesh_tris(obj, bm_fill, context.scene.camera) + ) + + DecoratorData.slice_cache[element.id()] = (verts, edges) + bm_original.free() + + def get_material_colour(self, material: ifcopenshell.entity_instance) -> tuple[float, float, float, float]: + body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + style = ifcopenshell.util.representation.get_material_style(material, body) + if not style: + return self.fallback_colour + if not (styles := [s for s in style.Styles if s.is_a("IfcSurfaceStyleShading")]): + return self.fallback_colour + colour = styles[0].SurfaceColour + return (colour.Red, colour.Green, colour.Blue, 1) + + def get_bmesh_tris(self, obj, bm, camera): + global_offset = camera.matrix_world.col[2].xyz * -camera.data.clip_start + triangulate = bmesh.ops.triangulate(bm, faces=bm.faces) + vert_map = {} + verts = [] + tris = [] i = 0 - for geom in results["geom_cut"]: - if isinstance(geom, bmesh.types.BMVert): - verts.append(tuple((obj.matrix_world @ geom.co) + global_offset)) - vert_map[geom.index] = i - i += 1 - else: - # It seems as though edges always appear after verts - edges.append([vert_map[v.index] for v in geom.verts]) - - bm.free() - - return verts, edges - - def get_layer_data(self, element): - usage = ifcopenshell.util.element.get_material(element) - offset = usage.OffsetFromReferenceLine * self.unit_scale - layer_set = usage.ForLayerSet - - if len(layer_set.MaterialLayers) == 1: - return # No use slicing if there's only one layer - - total_thickness = layer_set.TotalThickness - half_thickness = total_thickness / 2 - min_layers = [] - max_layers = [] - current_thickness = 0 - for layer in layer_set.MaterialLayers: - current_thickness += layer.LayerThickness - if current_thickness / total_thickness < 0.5: - min_layers.append((half_thickness - current_thickness) * self.unit_scale) - else: - max_layers.append((current_thickness - half_thickness) * self.unit_scale) - min_layers.reverse() - max_layers.pop() - if usage.DirectionSense == "NEGATIVE": - total_thickness *= -1 - offset *= -1 - min_layers = [l * -1 for l in min_layers] - max_layers = [l * -1 for l in max_layers] - return { - "offset": offset, - "min_layers": min_layers, - "max_layers": max_layers, - "thickness": total_thickness * self.unit_scale, - } - - def get_segments(self, connections, centerline, start_point, end_point): - segments = [] - total_connections = len(connections) - if total_connections: - for i in range(0, total_connections + 1): - if i == 0: - connection = connections[i] - segments.append([centerline[0], connection["intersection"], connection["out_point"]]) - elif i == total_connections: - segments.append([segments[-1][-1], segments[-1][-2], centerline[1]]) + for face in triangulate["faces"]: + tri = [] + for vert in face.verts: + if index := vert_map.get(vert.index, None): + tri.append(index) else: - connection = connections[i] - segments.append( - [segments[-1][-1], segments[-1][-2], connection["intersection"], connection["out_point"]] - ) - else: - segments.append([centerline[0], centerline[1]]) - if start_point: - segments[0].insert(0, start_point) - if end_point: - segments[-1].append(end_point) - return segments - - def get_connections(self, wall, obj, centerline, min_edge, max_edge): - connections = {"ATEND": None, "ATSTART": None, "ATPATH": [], "MINPATH": [], "MAXPATH": []} - for rel in wall.ConnectedTo: - # How do you join to a non layered element? Not sure. - if tool.Model.get_usage_type(rel.RelatedElement) != "LAYER2": - continue - if rel.RelatingConnectionType == "ATPATH": - metadata = self.get_connection_metadata(obj, rel.RelatedElement, centerline, min_edge, max_edge) - if not metadata: - continue - connections["ATPATH"].append(metadata) - else: - metadata = self.get_connection_metadata(obj, rel.RelatedElement, centerline, min_edge, max_edge) - if not metadata: - continue - connections[rel.RelatingConnectionType] = metadata - for rel in wall.ConnectedFrom: - if tool.Model.get_usage_type(rel.RelatingElement) != "LAYER2": - continue - # We only consider ATPATH since in this situation, we have the - # priority. The non-priority wall never has any layers that need to - # "turn a corner". - if rel.RelatedConnectionType == "ATPATH": - metadata = self.get_connection_metadata(obj, rel.RelatingElement, centerline, min_edge, max_edge) - if not metadata: - continue - connections["ATPATH"].append(metadata) - connections["ATPATH"] = sorted(connections["ATPATH"], key=lambda c: c["intersection"].x) - for connection in connections["ATPATH"]: - if connection["angle"] > 0: - connections["MINPATH"].append(connection) - else: - connections["MAXPATH"].append(connection) - return connections - - def get_connection_metadata(self, obj, rel_element, centerline, min_edge, max_edge): - rel_obj = tool.Ifc.get_object(rel_element) - layers = self.get_layer_data(rel_element) - if not layers: - return - minx = min([co[0] for co in rel_obj.bound_box]) - maxx = max([co[0] for co in rel_obj.bound_box]) - rel_centerline = [ - Vector((minx, layers["offset"] + layers["thickness"] / 2)), - Vector((maxx, layers["offset"] + layers["thickness"] / 2)), - ] - rel_centerline = [obj.matrix_world.inverted() @ rel_obj.matrix_world @ v.to_3d() for v in rel_centerline] - rel_centerline = [v.to_2d() for v in rel_centerline] - intersection = tool.Cad.intersect_edges(centerline, rel_centerline) - if intersection: - intersection, _ = intersection - else: - return - closest_centerline_point = tool.Cad.closest_vector(intersection, tuple(rel_centerline)) - if closest_centerline_point == rel_centerline[1]: - rel_centerline = [rel_centerline[1], rel_centerline[0]] - angle = tool.Cad.angle_edges(centerline, rel_centerline, signed=True) - # A little extreme, but maybe it's OK? - out_point = tool.Cad.furthest_vector(intersection, tuple(rel_centerline)) - return { - "element": rel_element, - "obj": rel_obj, - "centerline": rel_centerline, - "intersection": intersection, - "angle": angle, - "out_point": out_point, - } + verts.append(tuple((obj.matrix_world @ vert.co) + global_offset)) + vert_map[vert.index] = i + tri.append(i) + i += 1 + tris.append(tri) + return verts, tris class DecorationsHandler: @@ -2022,7 +1923,8 @@ class DecorationsHandler: for object_type in ("SLOPE_ANGLE", "SLOPE_FRACTION", "SLOPE_PERCENT"): self.decorators[object_type] = self.decorators["FALL"] self.decorators["MULTI_SYMBOL"] = self.decorators["SYMBOL"] - if drawing_font := bpy.context.scene.DocProperties.drawing_font: + props = tool.Drawing.get_document_props() + if drawing_font := props.drawing_font: drawing_font_path = tool.Blender.get_data_dir_path(Path("fonts") / drawing_font) if drawing_font_path.is_file(): font_id = blf.load(drawing_font_path.__str__()) @@ -2045,7 +1947,7 @@ class DecorationsHandler: if not element.is_a("IfcAnnotation"): continue - object_type: Union[str, None] = element.ObjectType + object_type: Union[str, None] = ifcopenshell.util.element.get_predefined_type(element) if object_type == "DRAWING": continue diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 8e4f9bb97e..952ea0cb96 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -19,6 +19,7 @@ import bpy import blf import gpu +import bonsai.tool as tool from bpy import types from mathutils import Vector from mathutils import geometry @@ -478,21 +479,25 @@ class ExtrusionWidget(types.GizmoGroup): bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} @classmethod - def poll(cls, ctx): - obj = ctx.object + def poll(cls, context): + obj = context.active_object return ( obj - and obj.type == "MESH" - and obj.data.BIMMeshProperties.ifc_parameters.get("IfcExtrudedAreaSolid/Depth") is not None + and (data := obj.data) + and isinstance(data, bpy.types.Mesh) + and tool.Geometry.get_mesh_props(data).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") is not None ) - def setup(self, ctx): - target = ctx.object - prop = target.data.BIMMeshProperties.ifc_parameters.get("IfcExtrudedAreaSolid/Depth") + def setup(self, context: bpy.types.Context) -> None: + target = context.object + assert target + mesh = target.data + assert isinstance(mesh, bpy.types.Mesh) + prop = tool.Geometry.get_mesh_props(mesh).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") basis = target.matrix_world.normalized() - theme = ctx.preferences.themes[0].user_interface - scale_value = self.get_scale_value(ctx.scene.unit_settings.system, ctx.scene.unit_settings.length_unit) + theme = context.preferences.themes[0].user_interface + scale_value = self.get_scale_value(context.scene.unit_settings.system, context.scene.unit_settings.length_unit) # setup handle gz = self.handle = self.gizmos.new("BIM_GT_uglydot_3d") @@ -521,23 +526,26 @@ class ExtrusionWidget(types.GizmoGroup): # gz.use_draw_modal = True # gz.target_set_prop('value', target.demo, 'depth') - def refresh(self, ctx): + def refresh(self, context: bpy.types.Context) -> None: """updating gizmos""" - target = ctx.object + target = context.active_object basis = target.matrix_world.normalized() self.handle.matrix_basis = basis self.guides.matrix_basis = basis - def update(self, ctx): + def update(self, context: bpy.types.Context) -> None: """updating object""" bpy.ops.bim.update_parametric_representation() - target = ctx.object - prop = target.data.BIMMeshProperties.ifc_parameters.get("IfcExtrudedAreaSolid/Depth") + target = context.active_object + assert target + mesh = target.data + assert isinstance(mesh, bpy.types.Mesh) + prop = tool.Geometry.get_mesh_props(mesh).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") self.handle.target_set_prop("offset", prop, "value") self.guides.target_set_prop("depth", prop, "value") @staticmethod - def get_scale_value(system, length_unit): + def get_scale_value(system: str, length_unit: str) -> float: scale_value = 1 if system == "METRIC": if length_unit == "KILOMETERS": diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py index abfc8bb261..d1622d1908 100644 --- a/src/bonsai/bonsai/bim/module/drawing/handler.py +++ b/src/bonsai/bonsai/bim/module/drawing/handler.py @@ -24,7 +24,8 @@ from bpy.app.handlers import persistent @persistent def load_post(*args): - if bpy.context.scene.DocProperties.should_draw_decorations: + props = tool.Drawing.get_document_props() + if props.should_draw_decorations: decoration.DecorationsHandler.install(bpy.context) else: decoration.DecorationsHandler.uninstall() @@ -35,9 +36,11 @@ def depsgraph_update_pre_handler(scene): set_active_camera_resolution(scene) -def set_active_camera_resolution(scene): - if not scene.camera or "/" not in scene.camera.name or not scene.DocProperties.drawings: +def set_active_camera_resolution(scene: bpy.types.Scene) -> None: + props = tool.Drawing.get_document_props() + if not scene.camera or "/" not in scene.camera.name or not props.drawings: return + assert isinstance(scene.camera.data, bpy.types.Camera) props = scene.camera.data.BIMCameraProperties ortho_scale = max((props.width, props.height)) aspect_ratio = props.width / props.height @@ -60,5 +63,3 @@ def set_active_camera_resolution(scene): scene.render.resolution_x = scene.camera.data.BIMCameraProperties.raster_x = int(raster_x) scene.render.resolution_y = scene.camera.data.BIMCameraProperties.raster_y = int(raster_y) - - current_drawing = scene.DocProperties.drawings[scene.DocProperties.current_drawing_index] diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 5bd1661bfa..91149b639d 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -19,8 +19,11 @@ import bpy import math import mathutils.geometry +import ifcopenshell +import ifcopenshell.util.unit import bonsai.tool as tool from mathutils import Vector +from typing import Union # Code taken and updated from https://blenderartists.org/t/detecting-intersection-of-bounding-boxes/457520/2 @@ -128,15 +131,53 @@ def format_distance( decimal_places=None, suppress_zero_inches=False, in_unit_length=False, + custom_unit=None, ): - s_code = "\u00b2" # Superscript two THIS IS LEGACY (but being kept for when Area Measurements are re-implimented) - - # Get Scene Unit Settings - scaleFactor = bpy.context.scene.unit_settings.scale_length + # Get Blender Scene Unit Settings + unit_scale = bpy.context.scene.unit_settings.scale_length unit_system = bpy.context.scene.unit_settings.system unit_length = bpy.context.scene.unit_settings.length_unit + area_unit_symbol = " m2" if unit_system == "METRIC" else " ft2" - value *= scaleFactor + # Get IFC Unit Settings + if tool.Ifc.get(): + unit_scale = 1 + if length_unit := ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT"): + unit_system = "METRIC" if length_unit.Name == "METRE" else "IMPERIAL" + unit_length = length_unit.Name.upper() + if hasattr(length_unit, "Prefix") and length_unit.Prefix: + unit_length = length_unit.Prefix + length_unit.Name + unit_length_mapping = { + "FOOT": "FEET", + "INCH": "INCHES", + "METRE": "METERS", + "DECIMETRE": "DECIMETERS", + "CENTIMETRE": "CENTIMETERS", + "MILLIMETRE": "MILLIMETERS", + } + unit_length = unit_length_mapping[unit_length] + # For now we only format area in IFC Units + if area_unit := ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "AREAUNIT"): + area_unit_symbol = " " + ifcopenshell.util.unit.get_unit_symbol(area_unit) + + unit_fraction = True if unit_system == "IMPERIAL" else False + + # Custom Unit Settings + if custom_unit: + unit_mapping = { + "Feet and Inches - Fractional": ("IMPERIAL", "FEET", True), + "Feet - Decimal": ("IMPERIAL", "FEET", False), + "Inches - Fractional": ("IMPERIAL", "INCHES", True), + "Inches - Decimal": ("IMPERIAL", "INCHES", False), + "Meters": ("METRIC", "METERS", False), + "Decimeters": ("METRIC", "DECIMETERS", False), + "Centimeters": ("METRIC", "CENTIMETERS", False), + "Millimeters": ("METRIC", "MILLIMETERS", False), + } + if custom_unit in unit_mapping: + unit_system, unit_length, unit_fraction = unit_mapping[custom_unit] + + value *= unit_scale # Imperial Formatting if unit_system == "IMPERIAL": @@ -161,10 +202,14 @@ def format_distance( base = int(precision) decInches = value * toInches + decFeet = decInches / 12 # Separate ft and inches - # Unless Inches are the specified Length Unit - if unit_length != "INCHES": + # Unless Inches are the specified Length Unit or unit_fraction is False + if unit_length == "FEET" and not unit_fraction: + feet = round(decInches / inPerFoot, 3) # keep decimal + decInches = 0 + elif unit_length != "INCHES": feet = int(decInches / inPerFoot) # remove decimal decInches -= feet * inPerFoot else: @@ -199,6 +244,10 @@ def format_distance( feet += 1 inches = 0 + # Check whether decimal or fractional + if not unit_fraction: + inches = round(decInches, 3) + frac = None if not isArea: add_inches = bool(inches) or not suppress_zero_inches or (inches == 0 and frac) tx_dist = "" @@ -224,12 +273,33 @@ def format_distance( tx_dist += str(frac) + "/" + str(base) if add_inches or frac: tx_dist += '"' + if precision == "12" and unit_system == "IMPERIAL": + tx_dist = str(round(decFeet)) + "'" else: - tx_dist = str("%1.3f" % (value * toInches / inPerFoot)) + " sq. ft." + fmt = "%1.3f" + sq_feet = round(value * toInches / inPerFoot, 4) + tx_dist = "" + if area_unit_symbol == " ft2": + fmt += area_unit_symbol + tx_dist = fmt % sq_feet + if area_unit_symbol == " in2": + sq_inch = sq_feet * 144 + fmt += area_unit_symbol + tx_dist = fmt % sq_inch + if area_unit_symbol == " yd2": + sq_yard = sq_feet / 9 + fmt += area_unit_symbol + tx_dist = fmt % sq_yard + if area_unit_symbol == " mi2": + sq_mile = sq_feet / 27878400 + fmt += area_unit_symbol + tx_dist = fmt % sq_mile # METRIC FORMATTING elif unit_system == "METRIC": if in_unit_length: + if unit_length == "DECIMETERS": + value = value / 10 if unit_length == "CENTIMETERS": value = value / 100 if unit_length == "MILLIMETERS": @@ -248,6 +318,14 @@ def format_distance( if hide_units is False: fmt += " m" tx_dist = fmt % value + # Decimeters + elif unit_length == "DECIMETERS": + if decimal_places is None: + fmt = "%1.1f" + if hide_units is False: + fmt += " dm" + d_dm = value * (10) + tx_dist = fmt % d_dm # Centimeters elif unit_length == "CENTIMETERS": if decimal_places is None: @@ -287,19 +365,41 @@ def format_distance( d_mm = value * (1000) tx_dist = fmt % d_mm if isArea: - tx_dist += s_code + if area_unit_symbol == " m2": + if decimal_places is None: + fmt = "%1.3f" + if hide_units is False: + fmt += area_unit_symbol + tx_dist = fmt % value + if area_unit_symbol == " cm2": + if decimal_places is None: + fmt = "%1.1f" + if hide_units is False: + fmt += area_unit_symbol + d_cm = value * (10000) + tx_dist = fmt % d_cm + if area_unit_symbol == " mm2": + if decimal_places is None: + fmt = "%1.0f" + if hide_units is False: + fmt += area_unit_symbol + d_cm = value * (1000000) + tx_dist = fmt % d_cm + else: tx_dist = fmt % value return tx_dist -def get_active_drawing(scene): +def get_active_drawing( + scene: bpy.types.Scene, +) -> Union[tuple[bpy.types.Collection, bpy.types.Camera], tuple[None, None]]: """Get active drawing collection and camera""" - props = scene.DocProperties + props = tool.Drawing.get_document_props() try: camera = tool.Ifc.get_object(tool.Ifc.get().by_id(props.active_drawing_id)) - return camera.BIMObjectProperties.collection, camera + return tool.Blender.get_object_bim_props(camera).collection, camera except: return None, None diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 950d701e4a..56a59506d0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -43,6 +43,7 @@ import bonsai.bim.module.drawing.svgwriter as svgwriter import bonsai.bim.module.drawing.annotation as annotation import bonsai.bim.module.drawing.sheeter as sheeter import bonsai.bim.export_ifc +from bpy_extras.io_utils import ImportHelper from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.data import DecoratorData, DrawingsData from typing import NamedTuple, List, Union, Optional, Literal @@ -134,19 +135,19 @@ class AddDrawing(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Add a drawing view to the IFC project" def _execute(self, context): - self.props = context.scene.DocProperties - hint = self.props.location_hint - if self.props.target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]: + props = tool.Drawing.get_document_props() + hint = props.location_hint + if props.target_view in ["PLAN_VIEW", "REFLECTED_PLAN_VIEW"]: hint = int(hint) core.add_drawing( tool.Ifc, tool.Collector, tool.Drawing, - target_view=self.props.target_view, + target_view=props.target_view, location_hint=hint, ) try: - drawing = tool.Ifc.get().by_id(self.props.active_drawing_id) + drawing = tool.Ifc.get().by_id(props.active_drawing_id) core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=drawing) except: pass @@ -162,7 +163,7 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -176,7 +177,7 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): row.prop(self, "should_duplicate_annotations") def _execute(self, context): - self.props = context.scene.DocProperties + props = tool.Drawing.get_document_props() core.duplicate_drawing( tool.Ifc, tool.Drawing, @@ -184,7 +185,7 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): should_duplicate_annotations=self.should_duplicate_annotations, ) try: - drawing = tool.Ifc.get().by_id(self.props.active_drawing_id) + drawing = tool.Ifc.get().by_id(props.active_drawing_id) core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=drawing) except: pass @@ -244,9 +245,9 @@ class CreateDrawing(bpy.types.Operator): return self.execute(context) def execute(self, context): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() - active_drawing_id = context.scene.camera.BIMObjectProperties.ifc_definition_id + active_drawing_id = tool.Blender.get_ifc_definition_id(context.scene.camera) if self.print_all: original_drawing_id = active_drawing_id drawings_to_print = [d.ifc_definition_id for d in self.props.drawings if d.is_selected and d.is_drawing] @@ -261,7 +262,7 @@ class CreateDrawing(bpy.types.Operator): self.camera = context.scene.camera self.camera_element = tool.Ifc.get_entity(self.camera) self.camera_document = tool.Drawing.get_drawing_document(self.camera_element) - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() with profile("Drawing generation process"): with profile("Initialize drawing generation process"): @@ -385,13 +386,13 @@ class CreateDrawing(bpy.types.Operator): obj.hide_render = obj.name not in visible_object_names context.scene.render.filepath = str(Path(svg_path).with_suffix(".png")) - drawing_style = context.scene.DocProperties.drawing_styles[self.cprops.active_drawing_style_index] + drawing_style = self.props.drawing_styles[self.cprops.active_drawing_style_index] if drawing_style.render_type == "DEFAULT": bpy.ops.render.render(write_still=True) else: previous_visibility = {} - for obj in self.camera.BIMObjectProperties.collection.objects: + for obj in tool.Blender.get_object_bim_props(self.camera).collection.objects: if bpy.context.view_layer.objects.get(obj.name): previous_visibility[obj.name] = obj.hide_get() obj.hide_set(True) @@ -562,6 +563,112 @@ class CreateDrawing(bpy.types.Operator): path.attrib["d"] = d group.append(g) + def generate_wall_layers(self, context: bpy.types.Context, root): + for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): + if "projection" in el.get("class", "").split(): + continue + element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid")) + if not (obj := tool.Ifc.get_object(element)): + continue + if not (material := ifcopenshell.util.element.get_material(element)): + continue + if material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialLayerSetUsage"): + continue + + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if material.is_a("IfcMaterialLayerSetUsage"): + usage = material + layer_set = material.ForLayerSet + offset = usage.OffsetFromReferenceLine * self.unit_scale + sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 + elif material.is_a("IfcMaterialLayerSet"): + usage = None + layer_set = material + offset = 0 + sense_factor = 1 + + camera_matrix_i = context.scene.camera.matrix_world.inverted() + + group = root.find("{http://www.w3.org/2000/svg}g") + raw_width, raw_height = self.get_camera_dimensions() + x_offset = raw_width / 2 + y_offset = raw_height / 2 + svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm + + mesh = obj.data + bm = bmesh.new() + bm.from_mesh(mesh) + + # Slice our mesh into a 2D drawing cut (2D is always easier) + camera_matrix = obj.matrix_world.inverted() @ context.scene.camera.matrix_world + plane_co = camera_matrix.translation + plane_no = camera_matrix.col[2].xyz + geom = bm.verts[:] + bm.edges[:] + bm.faces[:] + bmesh.ops.bisect_plane( + bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no, clear_outer=True, clear_inner=True + ) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001) + bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges) + + prev_co = None + if not usage: + sense_factor = 1 # Assume the extrusion vector points in the direction sense + no = tool.Drawing.get_extrusion_vector(element).normalized() + co = Vector((0.0, 0.0, offset)) + elif usage.LayerSetDirection == "AXIS2": + co = Vector((0.0, offset, 0.0)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = no.cross(Vector([1.0, 0.0, 0.0])) + elif usage.LayerSetDirection == "AXIS3": + co = Vector((0.0, 0.0, offset)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = Vector([0.0, 0.0, 1.0]) + elif usage.LayerSetDirection == "AXIS1": + co = Vector((0.0, 0.0, offset)) + no = tool.Drawing.get_extrusion_vector(element).normalized() + no = Vector([1.0, 0.0, 0.0]) + no *= sense_factor + last_i = len(layer_set.MaterialLayers) - 1 + for i, layer in enumerate(layer_set.MaterialLayers): + prev_co = co.copy() + co += no * layer.LayerThickness * self.unit_scale + + bm_fill = bm.copy() + if i != last_i: + geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:] + bmesh.ops.bisect_plane(bm_fill, geom=geom, dist=0.0001, plane_co=co, plane_no=no, clear_outer=True) + if i != 0: + geom = bm_fill.verts[:] + bm_fill.edges[:] + bm_fill.faces[:] + bmesh.ops.bisect_plane( + bm_fill, geom=geom, dist=0.0001, plane_co=prev_co, plane_no=no, clear_inner=True + ) + + bm_fill.verts.ensure_lookup_table() + bm_fill.edges.ensure_lookup_table() + verts = [tuple(obj.matrix_world @ v.co) for v in bm_fill.verts] + edges = [[v.index for v in e.verts] for e in bm_fill.edges] + + g = etree.SubElement(root, "{http://www.w3.org/2000/svg}g") + g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId + g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or "" + g.attrib["{http://www.ifcopenshell.org/ns}layer-id"] = str(layer.id()) + + lines = [] + for edge in edges: + start = [o for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy] + end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] + coords = [start, end] + d = " ".join( + ["L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) for p in coords] + ) + d = "M{}".format(d[1:]) + path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path") + path.attrib["d"] = d + group.append(g) + + bm.free() + def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None: if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): return @@ -712,9 +819,11 @@ class CreateDrawing(bpy.types.Operator): } cached_linework -= edited_guids - files = {context.scene.BIMProperties.ifc_file: tool.Ifc.get()} + bim_props = tool.Blender.get_bim_props() + files = {bim_props.ifc_file: tool.Ifc.get()} - for link in context.scene.BIMProjectProperties.links: + props = tool.Project.get_project_props() + for link in props.links: if link.name not in IfcStore.session_files: IfcStore.session_files[link.name] = ifcopenshell.open(link.name) files[link.name] = IfcStore.session_files[link.name] @@ -729,7 +838,7 @@ class CreateDrawing(bpy.types.Operator): # Don't use draw.main() just whilst we're prototyping and experimenting # TODO: hash paths are never used ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest() - ifc_cache_path = os.path.join(context.scene.BIMProperties.cache_dir, f"{ifc_hash}.h5") + ifc_cache_path = os.path.join(bim_props.cache_dir, f"{ifc_hash}.h5") self.serialiser.setFile(ifc) drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc) @@ -767,10 +876,12 @@ class CreateDrawing(bpy.types.Operator): if self.camera.data.BIMCameraProperties.cut_mode == "BISECT": self.remove_cut_linework(root) self.generate_bisect_linework(context, root) + self.generate_wall_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) elif self.camera.data.BIMCameraProperties.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) + self.generate_wall_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) @@ -1099,7 +1210,7 @@ class CreateDrawing(bpy.types.Operator): # the style of the face when running tree.select_ray() # tree.enable_face_styles(True) - def get_svg_classes(self, element): + def get_svg_classes(self, element, layer=None): classes = [element.is_a()] material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) material_name = "" @@ -1111,7 +1222,13 @@ class CreateDrawing(bpy.types.Operator): material_name = tool.Drawing.canonicalise_class_name(material_name) classes.append(f"material-{material_name}") else: - classes.append(f"material-null") + classes.append("material-null") + + if layer: + classes.append(layer.is_a()) + material_name = layer.Material.Name or "null" + material_name = tool.Drawing.canonicalise_class_name(material_name) + classes.append(f"layer-material-{material_name}") for key in self.metadata: value = ifcopenshell.util.selector.get_element_value(element, key) @@ -1141,7 +1258,8 @@ class CreateDrawing(bpy.types.Operator): try: return tool.Ifc.get().by_guid(guid) except: - for link in bpy.context.scene.BIMProjectProperties.links: + props = tool.Project.get_project_props() + for link in props.links: if link.name not in IfcStore.session_files: IfcStore.session_files[link.name] = ifcopenshell.open(link.name) try: @@ -1149,6 +1267,23 @@ class CreateDrawing(bpy.types.Operator): except: continue + def get_element_by_id(self, step_id): + try: + step_id = int(step_id) + except: + return + try: + return tool.Ifc.get().by_id(step_id) + except: + props = tool.Project.get_project_props() + for link in props.links: + if link.name not in IfcStore.session_files: + IfcStore.session_files[link.name] = ifcopenshell.open(link.name) + try: + return IfcStore.session_files[link.name].by_id(step_id) + except: + continue + def remove_cut_linework(self, root): for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): if "projection" not in el.get("class", "").split(): @@ -1160,15 +1295,15 @@ class CreateDrawing(bpy.types.Operator): join_criteria = join_criteria.split(",") else: # Drawing convention states that same objects classes with the same material are merged when cut. - join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status"] + join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "Material.Name"] group = root.find("{http://www.w3.org/2000/svg}g") joined_paths = {} self.is_manifold_cache = {} - ifc = tool.Ifc.get() for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid")) + layer = self.get_element_by_id(el.get("{http://www.ifcopenshell.org/ns}layer-id")) if "projection" in el.get("class", "").split(): classes = self.get_svg_classes(element) @@ -1176,7 +1311,7 @@ class CreateDrawing(bpy.types.Operator): el.set("class", " ".join(classes)) continue else: - classes = self.get_svg_classes(element) + classes = self.get_svg_classes(element, layer) classes.append("cut") el.set("class", " ".join(classes)) @@ -1185,7 +1320,12 @@ class CreateDrawing(bpy.types.Operator): if not obj: # This is a linked model object. For now, do nothing. continue - if not self.is_manifold(obj): + if (material := ifcopenshell.util.element.get_material(element)) and material.is_a() in ( + "IfcMaterialLayerSet", + "IfcMaterialLayerSetUsage", + ): + pass # These are always manifold + elif not self.is_manifold(obj): continue # An element group will contain a bunch of paths representing the @@ -1270,6 +1410,15 @@ class CreateDrawing(bpy.types.Operator): else: keys.append(key) + if layer: + for query in join_criteria: + key = ifcopenshell.util.selector.get_element_value(layer, query) + print("got layer key", query, key) + if isinstance(key, (list, tuple)): + keys.extend(key) + else: + keys.append(key) + hash_keys = hash(tuple(keys)) if el.findall("{http://www.w3.org/2000/svg}path"): @@ -1286,7 +1435,8 @@ class CreateDrawing(bpy.types.Operator): for path in el.findall("{http://www.w3.org/2000/svg}path"): for subpath in path.attrib["d"].split("M")[1:]: subpath_co = "M" + subpath.strip(" Z") - coords = [[float(o) for o in co[1:].split(",")] for co in subpath_co.split()] + # Round due to inaccuracies from Blender meshes and bisection + coords = [[round(float(o), 3) for o in co[1:].split(",")] for co in subpath_co.split()] if subpath.strip().lower().endswith("z"): coords.append(coords[0]) if len(coords) > 2 and coords[0] == coords[-1]: @@ -1367,7 +1517,11 @@ class CreateDrawing(bpy.types.Operator): elements = list(elements | filtered_drawing_annotations) annotations = sorted( - elements, key=lambda a: (tool.Drawing.get_annotation_z_index(a), 1 if a.ObjectType == "TEXT" else 0) + elements, + key=lambda a: ( + tool.Drawing.get_annotation_z_index(a), + 1 if ifcopenshell.util.element.get_predefined_type(a) == "TEXT" else 0, + ), ) precision = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "MetricPrecision") @@ -1458,7 +1612,8 @@ class AddSheet(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Add a sheet to the project" def _execute(self, context): - core.add_sheet(tool.Ifc, tool.Drawing, titleblock=context.scene.DocProperties.titleblock) + props = tool.Drawing.get_document_props() + core.add_sheet(tool.Ifc, tool.Drawing, titleblock=props.titleblock) class DuplicateSheet(bpy.types.Operator, tool.Ifc.Operator): @@ -1474,7 +1629,7 @@ class DuplicateSheet(bpy.types.Operator, tool.Ifc.Operator): cls.poll_message_set("Not implemented yet.") return False - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -1483,7 +1638,7 @@ class DuplicateSheet(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): pass """ - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() core.duplicate_sheet( tool.Ifc, tool.Drawing, @@ -1508,7 +1663,7 @@ class OpenLayout(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() sheet = tool.Ifc.get().by_id(self.props.sheets[self.props.active_sheet_index].ifc_definition_id) sheet_builder = sheeter.SheetBuilder() sheet_builder.update_sheet_drawing_sizes(sheet) @@ -1530,7 +1685,8 @@ class SelectAllSheets(bpy.types.Operator): return self.execute(context) def execute(self, context): - for sheet in context.scene.DocProperties.sheets: + props = tool.Drawing.get_document_props() + for sheet in props.sheets: if sheet.is_selected != self.select_all: sheet.is_selected = self.select_all return {"FINISHED"} @@ -1550,7 +1706,7 @@ class OpenSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_sheet_item(is_sheet=True): cls.poll_message_set("No sheet selected.") return False @@ -1564,7 +1720,7 @@ class OpenSheet(bpy.types.Operator, tool.Ifc.Operator): return self.execute(context) def execute(self, context): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() svg2pdf_command = tool.Blender.get_addon_preferences().svg2pdf_command if self.open_all: @@ -1612,9 +1768,10 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() # Won't be visible in UI anyway. - if not props.sheets or not context.scene.BIMProperties.data_dir: + bim_props = tool.Blender.get_bim_props() + if not props.sheets or not bim_props.data_dir: return False if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") @@ -1622,8 +1779,8 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator): return True def _execute(self, context): - props = context.scene.DocProperties - active_drawing = tool.Drawing.get_active_drawing_item() + props = tool.Drawing.get_document_props() + active_drawing = props.drawings[props.active_drawing_index] assert active_drawing active_sheet = tool.Drawing.get_active_sheet(context) @@ -1680,7 +1837,7 @@ class RemoveDrawingFromSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() active_item = tool.Drawing.get_active_sheet_item() if active_item is None: return False @@ -1714,11 +1871,12 @@ class CreateSheets(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_sheet_item(is_sheet=True): cls.poll_message_set("No sheet selected.") return False - return props.sheets and context.scene.BIMProperties.data_dir + bim_props = tool.Blender.get_bim_props() + return props.sheets and bim_props.data_dir def invoke(self, context, event): # opening all sheets on shift+click @@ -1731,7 +1889,7 @@ class CreateSheets(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): scene = context.scene - props = scene.DocProperties + props = tool.Drawing.get_document_props() svg2pdf_command = tool.Blender.get_addon_preferences().svg2pdf_command svg2dxf_command = tool.Blender.get_addon_preferences().svg2dxf_command @@ -1838,7 +1996,8 @@ class SelectAllDrawings(bpy.types.Operator): return self.execute(context) def execute(self, context): - for drawing in context.scene.DocProperties.drawings: + props = tool.Drawing.get_document_props() + for drawing in props.drawings: if drawing.is_selected != self.select_all: drawing.is_selected = self.select_all return {"FINISHED"} @@ -1857,7 +2016,7 @@ class OpenDrawing(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -1871,7 +2030,7 @@ class OpenDrawing(bpy.types.Operator): return self.execute(context) def execute(self, context): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if self.open_all: drawings = [ tool.Ifc.get().by_id(d.ifc_definition_id) for d in self.props.drawings if d.is_drawing and d.is_selected @@ -1907,7 +2066,7 @@ class ActivateModel(bpy.types.Operator): bl_description = "Activate the model view, hide all annotations" def execute(self, context): - dprops = bpy.context.scene.DocProperties + dprops = tool.Drawing.get_document_props() dprops.active_drawing_id = 0 CutDecorator.uninstall() @@ -1971,11 +2130,12 @@ class ActivateDrawingBase: return self.execute(context) def execute(self, context): - if bpy.context.scene.DocProperties.is_editing_drawings == False: + props = tool.Drawing.get_document_props() + if props.is_editing_drawings == False: bpy.ops.bim.load_drawings() drawing = tool.Ifc.get().by_id(self.drawing) - dprops = bpy.context.scene.DocProperties + dprops = tool.Drawing.get_document_props() if self.use_quick_preview: tool.Blender.activate_camera(tool.Drawing.import_temporary_drawing_camera(drawing)) @@ -2007,6 +2167,7 @@ class ActivateDrawingBase: camera_props = camera.data.BIMCameraProperties if camera_props.update_representation(camera): bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") + bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") return {"FINISHED"} @@ -2027,7 +2188,6 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase): @classmethod def poll(cls, context): - props = context.scene.DocProperties if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -2050,29 +2210,27 @@ class ActivateDrawingFromSheet(bpy.types.Operator, ActivateDrawingBase): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_sheet_item(reference_type="DRAWING"): cls.poll_message_set("No drawing selected.") return False return True -class SelectDocIfcFile(bpy.types.Operator): +# TODO: not exposed to the UI. +class SelectDocIfcFile(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_doc_ifc_file" bl_label = "Select Documentation IFC File" bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filename_ext = ".ifc" index: bpy.props.IntProperty() def execute(self, context): - context.scene.DocProperties.ifc_files[self.index].name = self.filepath + props = tool.Drawing.get_document_props() + props.ifc_files[self.index].name = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class ResizeText(bpy.types.Operator): bl_idname = "bim.resize_text" @@ -2081,7 +2239,7 @@ class ResizeText(bpy.types.Operator): # TODO: check undo redo def execute(self, context): - for obj in context.scene.camera.BIMObjectProperties.collection.objects: + for obj in tool.Blender.get_object_bim_props(context.scene.camera).collection.objects: if isinstance(obj.data, bpy.types.TextCurve): annotation.Annotator.resize_text(obj) return {"FINISHED"} @@ -2098,7 +2256,7 @@ class RemoveDrawing(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -2112,12 +2270,10 @@ class RemoveDrawing(bpy.types.Operator, tool.Ifc.Operator): return self.execute(context) def _execute(self, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if self.remove_all: drawings = [ - tool.Ifc.get().by_id(d.ifc_definition_id) - for d in context.scene.DocProperties.drawings - if d.is_drawing and d.is_selected + tool.Ifc.get().by_id(d.ifc_definition_id) for d in props.drawings if d.is_drawing and d.is_selected ] else: if not self.drawing: @@ -2187,7 +2343,8 @@ class ReloadDrawingStyles(bpy.types.Operator): with open(json_path, "r") as fi: shading_styles_json = json.load(fi) - drawing_styles = context.scene.DocProperties.drawing_styles + props = tool.Drawing.get_document_props() + drawing_styles = props.drawing_styles drawing_styles.clear() styles = [style for style in shading_styles_json] for style_name in styles: @@ -2212,7 +2369,8 @@ class AddDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - drawing_styles = context.scene.DocProperties.drawing_styles + props = tool.Drawing.get_document_props() + drawing_styles = props.drawing_styles new = drawing_styles.add() # drawing style is saved to ifc on rename new.name = tool.Blender.ensure_unique_name("New Drawing Style", drawing_styles) @@ -2227,7 +2385,8 @@ class RemoveDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): index: bpy.props.IntProperty() def execute(self, context): - context.scene.DocProperties.drawing_styles.remove(self.index) + props = tool.Drawing.get_document_props() + props.drawing_styles.remove(self.index) context.scene.camera.data.BIMCameraProperties.active_drawing_style_index = max(self.index - 1, 0) bpy.ops.bim.save_drawing_styles_data() return {"FINISHED"} @@ -2283,7 +2442,8 @@ class SaveDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): index = int(self.index) else: index = context.scene.camera.data.BIMCameraProperties.active_drawing_style_index - scene.DocProperties.drawing_styles[index].raster_style = json.dumps(style) + props = tool.Drawing.get_document_props() + props.drawing_styles[index].raster_style = json.dumps(style) bpy.ops.bim.save_drawing_styles_data() return {"FINISHED"} @@ -2309,7 +2469,8 @@ class SaveDrawingStylesData(bpy.types.Operator, tool.Ifc.Operator): if not DrawingsData.is_loaded: DrawingsData.load() drawing_pset_data = DrawingsData.data["active_drawing_pset_data"] - drawing_styles = context.scene.DocProperties.drawing_styles + props = tool.Drawing.get_document_props() + drawing_styles = props.drawing_styles rel_path = drawing_pset_data["ShadingStyles"] current_style = drawing_pset_data.get("CurrentShadingStyle", None) @@ -2338,7 +2499,7 @@ class SaveDrawingStylesData(bpy.types.Operator, tool.Ifc.Operator): new_style_name = None ifc_file = tool.Ifc.get() - drawing = ifc_file.by_id(context.scene.DocProperties.active_drawing_id) + drawing = ifc_file.by_id(props.active_drawing_id) pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") ifcopenshell.api.run( "pset.edit_pset", ifc_file, pset=pset, properties={"CurrentShadingStyle": new_style_name} @@ -2357,17 +2518,18 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): scene = context.scene ifc_file = tool.Ifc.get() active_drawing_style_index = scene.camera.data.BIMCameraProperties.active_drawing_style_index + props = tool.Drawing.get_document_props() - if active_drawing_style_index >= len(scene.DocProperties.drawing_styles): + if active_drawing_style_index >= len(props.drawing_styles): self.report({"ERROR"}, "Could not find active drawing style") return {"CANCELLED"} - self.drawing_style = scene.DocProperties.drawing_styles[active_drawing_style_index] + self.drawing_style = props.drawing_styles[active_drawing_style_index] self.set_raster_style(context) self.set_query(context) - drawing = ifc_file.by_id(scene.DocProperties.active_drawing_id) + drawing = ifc_file.by_id(props.active_drawing_id) pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") ifcopenshell.api.run( "pset.edit_pset", ifc_file, pset=pset, properties={"CurrentShadingStyle": self.drawing_style.name} @@ -2392,7 +2554,8 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator): def set_query(self, context: bpy.types.Context) -> None: self.include_global_ids = [] self.exclude_global_ids = [] - for ifc_file in context.scene.DocProperties.ifc_files: + props = tool.Drawing.get_document_props() + for ifc_file in props.ifc_files: try: ifc = ifcopenshell.open(ifc_file.name) except: @@ -2446,13 +2609,12 @@ class RemoveSheet(bpy.types.Operator, tool.Ifc.Operator): core.remove_sheet(tool.Ifc, tool.Drawing, sheet=tool.Ifc.get().by_id(self.sheet)) -class AddSchedule(bpy.types.Operator, tool.Ifc.Operator): +class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.add_schedule" bl_label = "Add Schedule" bl_options = {"REGISTER", "UNDO"} bl_description = "Add an .ods, .xls or .xlsx file as a schedule" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) @@ -2460,10 +2622,6 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator): filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath) - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_schedule" @@ -2507,14 +2665,15 @@ class AddScheduleToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not props.schedules: cls.poll_message_set("No schedule selected.") return False - return props.schedules and props.sheets and context.scene.BIMProperties.data_dir + bim_props = tool.Blender.get_bim_props() + return props.schedules and props.sheets and bim_props.data_dir def _execute(self, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() active_schedule = props.schedules[props.active_schedule_index] active_sheet = tool.Drawing.get_active_sheet(context) schedule = tool.Ifc.get().by_id(active_schedule.ifc_definition_id) @@ -2573,14 +2732,15 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not props.references: cls.poll_message_set("No reference selected.") return False - return props.references and props.sheets and context.scene.BIMProperties.data_dir + bim_props = tool.Blender.get_bim_props() + return props.references and props.sheets and bim_props.data_dir def _execute(self, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() active_reference = props.references[props.active_reference_index] active_sheet = tool.Drawing.get_active_sheet(context) extref = tool.Ifc.get().by_id(active_reference.ifc_definition_id) @@ -2631,24 +2791,20 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator): tool.Drawing.import_sheets() -class AddReference(bpy.types.Operator, tool.Ifc.Operator): +class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.add_reference" bl_label = "Add Reference" bl_description = "Import a .svg file to the project as a reference" - bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) + filename_ext = ".svg" def _execute(self, context): filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath) - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class RemoveReference(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_reference" @@ -2682,7 +2838,8 @@ class AddDrawingStyleAttribute(bpy.types.Operator): def execute(self, context): props = context.scene.camera.data.BIMCameraProperties - context.scene.DocProperties.drawing_styles[props.active_drawing_style_index].attributes.add() + dprops = tool.Drawing.get_document_props() + dprops.drawing_styles[props.active_drawing_style_index].attributes.add() return {"FINISHED"} @@ -2695,7 +2852,8 @@ class RemoveDrawingStyleAttribute(bpy.types.Operator): def execute(self, context): props = context.scene.camera.data.BIMCameraProperties - context.scene.DocProperties.drawing_styles[props.active_drawing_style_index].attributes.remove(self.index) + dprops = tool.Drawing.get_document_props() + dprops.drawing_styles[props.active_drawing_style_index].attributes.remove(self.index) return {"FINISHED"} @@ -2979,7 +3137,7 @@ class LoadSheets(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): core.load_sheets(tool.Drawing) - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() sheets_not_found = [] for sheet_prop in props.sheets: if not sheet_prop.is_sheet: @@ -3009,7 +3167,7 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator): document_type: Literal["SHEET", "TITLEBLOCK", "EMBEDDED"] def invoke(self, context, event): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() sheet = tool.Ifc.get().by_id(self.props.sheets[self.props.active_sheet_index].ifc_definition_id) if sheet.is_a("IfcDocumentInformation"): self.document_type = "SHEET" @@ -3023,6 +3181,7 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator): return context.window_manager.invoke_props_dialog(self) def draw(self, context): + props = tool.Drawing.get_document_props() if self.document_type == "SHEET": row = self.layout.row() row.prop(self, "identification", text="Identification") @@ -3030,13 +3189,13 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator): row.prop(self, "name", text="Name") elif self.document_type == "TITLEBLOCK": row = self.layout.row() - row.prop(context.scene.DocProperties, "titleblock", text="Titleblock") + row.prop(props, "titleblock", text="Titleblock") elif self.document_type == "EMBEDDED": row = self.layout.row() row.prop(self, "identification", text="Identification") def _execute(self, context): - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() sheet = tool.Ifc.get().by_id(self.props.sheets[self.props.active_sheet_index].ifc_definition_id) if self.document_type == "SHEET": core.rename_sheet(tool.Ifc, tool.Drawing, sheet=sheet, identification=self.identification, name=self.name) @@ -3128,14 +3287,22 @@ class DisableEditingDrawings(bpy.types.Operator, tool.Ifc.Operator): class ExpandTargetView(bpy.types.Operator): bl_idname = "bim.expand_target_view" bl_label = "Expand Target View" - bl_description = "Show views in this category" + bl_description = "\nSHIFT+CLICK to expand all view categories " bl_options = {"REGISTER", "UNDO"} target_view: bpy.props.StringProperty() + expand_all: bpy.props.BoolProperty(name="Expand All", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + # Expanding all categories on shift+click. + # Make sure to use SKIP_SAVE on property, otherwise it might get stuck (copied from #4771). + if event.type == "LEFTMOUSE" and event.shift: + self.expand_all = True + return self.execute(context) def execute(self, context): - props = context.scene.DocProperties - for drawing in [d for d in props.drawings if d.target_view == self.target_view]: + props = tool.Drawing.get_document_props() + for drawing in [d for d in props.drawings if self.expand_all or d.target_view == self.target_view]: drawing.is_expanded = True core.load_drawings(tool.Drawing) return {"FINISHED"} @@ -3144,14 +3311,22 @@ class ExpandTargetView(bpy.types.Operator): class ContractTargetView(bpy.types.Operator): bl_idname = "bim.contract_target_view" bl_label = "Contract Target View" - bl_description = "Hide views in this category" + bl_description = "\n\nSHIFT+CLICK to hide all view categories" bl_options = {"REGISTER", "UNDO"} target_view: bpy.props.StringProperty() + contract_all: bpy.props.BoolProperty(name="Contract All", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + # Contracting all categories on shift+click. + # Make sure to use SKIP_SAVE on property, otherwise it might get stuck (copied from #4771). + if event.type == "LEFTMOUSE" and event.shift: + self.contract_all = True + return self.execute(context) def execute(self, context): - props = context.scene.DocProperties - for drawing in [d for d in props.drawings if d.target_view == self.target_view]: + props = tool.Drawing.get_document_props() + for drawing in [d for d in props.drawings if self.contract_all or d.target_view == self.target_view]: drawing.is_expanded = False core.load_drawings(tool.Drawing) return {"FINISHED"} @@ -3160,14 +3335,20 @@ class ContractTargetView(bpy.types.Operator): class ExpandSheet(bpy.types.Operator): bl_idname = "bim.expand_sheet" bl_label = "Expand Sheet" - bl_description = "Show views, schedules, references etc\nplaced on this sheet" - + bl_description = "Show views, schedules, references etc\nplaced on this sheet.\n\nShift+click to expand all sheets." bl_options = {"REGISTER", "UNDO"} + sheet: bpy.props.IntProperty() + expand_all: bpy.props.BoolProperty(name="Expand All", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if event.type == "LEFTMOUSE" and event.shift: + self.expand_all = True + return self.execute(context) def execute(self, context): - props = context.scene.DocProperties - for sheet in [s for s in props.sheets if s.ifc_definition_id == self.sheet]: + props = tool.Drawing.get_document_props() + for sheet in [s for s in props.sheets if self.expand_all or s.ifc_definition_id == self.sheet]: sheet.is_expanded = True core.load_sheets(tool.Drawing) return {"FINISHED"} @@ -3176,14 +3357,22 @@ class ExpandSheet(bpy.types.Operator): class ContractSheet(bpy.types.Operator): bl_idname = "bim.contract_sheet" bl_label = "Contract Sheet" - bl_description = "Hide views, schedules, references etc\nplaced on this sheet" - + bl_description = ( + "Hide views, schedules, references etc\nplaced on this sheet.\n\nShift+click to contract all sheets." + ) bl_options = {"REGISTER", "UNDO"} + sheet: bpy.props.IntProperty() + expand_all: bpy.props.BoolProperty(name="Expand All", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if event.type == "LEFTMOUSE" and event.shift: + self.expand_all = True + return self.execute(context) def execute(self, context): - props = context.scene.DocProperties - for sheet in [s for s in props.sheets if s.ifc_definition_id == self.sheet]: + props = tool.Drawing.get_document_props() + for sheet in [s for s in props.sheets if self.expand_all or s.ifc_definition_id == self.sheet]: sheet.is_expanded = False core.load_sheets(tool.Drawing) return {"FINISHED"} @@ -3244,7 +3433,7 @@ class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.activate_drawing(drawing=element.id(), should_view_from_camera=False) -class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator): +class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.add_reference_image" bl_label = "Add Reference Image" bl_description = "Add or import reference image to the IFC project" @@ -3252,9 +3441,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) - filepath: bpy.props.StringProperty( - name="File Path", description="Filepath used to import from", maxlen=1024, default="", subtype="FILE_PATH" - ) filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) @@ -3282,10 +3468,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator): layout.prop(self, "override_existing_image") layout.prop(self, "use_existing_object_by_name") - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - def _execute(self, context): abs_path = Path(self.filepath).absolute().resolve() image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)) @@ -3329,7 +3511,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator): obj.material_slots[0].material = material bpy.ops.bim.add_style() - style = ifc_file.by_id(material.BIMStyleProperties.ifc_definition_id) + style = tool.Ifc.get_entity(material) + assert style tool.Style.assign_style_to_object(style, obj) # TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because @@ -3373,7 +3556,7 @@ class ConvertSVGToDXF(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.DocProperties + props = tool.Drawing.get_document_props() if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False @@ -3387,14 +3570,13 @@ class ConvertSVGToDXF(bpy.types.Operator): return self.execute(context) def execute(self, context): + props = tool.Drawing.get_document_props() if self.convert_all: drawings = [ - tool.Ifc.get().by_id(d.ifc_definition_id) - for d in context.scene.DocProperties.drawings - if d.is_drawing and d.is_selected + tool.Ifc.get().by_id(d.ifc_definition_id) for d in props.drawings if d.is_drawing and d.is_selected ] else: - drawings = [tool.Ifc.get().by_id(context.scene.DocProperties.drawings.get(self.view).ifc_definition_id)] + drawings = [tool.Ifc.get().by_id(props.drawings.get(self.view).ifc_definition_id)] drawing_uris: list[Path] = [] drawings_not_found: list[str] = [] diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 02ce195100..8963fa5b8f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -76,7 +76,7 @@ def update_diagram_scale(self, context): try: element = ( tool.Ifc.get() - .by_id(self.id_data.BIMMeshProperties.ifc_definition_id) + .by_id(tool.Geometry.get_mesh_props(self.id_data).ifc_definition_id) .OfProductRepresentation[0] .ShapeOfProduct[0] ) @@ -93,7 +93,7 @@ def update_diagram_scale(self, context): ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties=diagram_scale) -def update_is_nts(self, context): +def update_is_nts(self: "BIMCameraProperties", context: bpy.types.Context) -> None: if not self.update_props: return if not context.scene.camera or context.scene.camera.data != self.id_data: @@ -104,7 +104,7 @@ def update_is_nts(self, context): try: element = ( tool.Ifc.get() - .by_id(self.id_data.BIMMeshProperties.ifc_definition_id) + .by_id(tool.Geometry.get_mesh_props(self.id_data).ifc_definition_id) .OfProductRepresentation[0] .ShapeOfProduct[0] ) @@ -192,8 +192,8 @@ def get_drawing_style_name(self: "DrawingStyle"): def set_drawing_style_name(self: "DrawingStyle", new_value: str) -> None: """ensure the name is unique""" - scene = bpy.context.scene - drawing_styles = [s.name for s in scene.DocProperties.drawing_styles if s.name != self.name] + props = tool.Drawing.get_document_props() + drawing_styles = [s.name for s in props.drawing_styles if s.name != self.name] new_value = tool.Blender.ensure_unique_name(new_value, drawing_styles) old_value = self.name self["name"] = new_value @@ -266,7 +266,7 @@ def update_titleblocks(self, context): def update_should_draw_decorations(self, context: bpy.types.Context) -> None: if self.should_draw_decorations: # TODO: design a proper text variable templating renderer - collection = context.scene.camera.BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(context.scene.camera).collection for obj in collection.objects: element = tool.Ifc.get_entity(obj) if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]): diff --git a/src/bonsai/bonsai/bim/module/drawing/scheduler.py b/src/bonsai/bonsai/bim/module/drawing/scheduler.py index d00fd1d94f..00ce2c1612 100644 --- a/src/bonsai/bonsai/bim/module/drawing/scheduler.py +++ b/src/bonsai/bonsai/bim/module/drawing/scheduler.py @@ -56,7 +56,7 @@ def a1_to_rc(cell): class Scheduler: - def schedule(self, infile, outfile): + def schedule(self, infile: str, outfile: str) -> None: self.svg = svgwrite.Drawing( outfile, debug=False, @@ -71,11 +71,12 @@ class Scheduler: elif infile.endswith("xlsx"): self.schedule_xlsx(infile, outfile) - def parse_css(self, infile): + def parse_css(self, infile: str) -> None: + props = tool.Drawing.get_document_props() stylesheet_path = os.path.splitext(infile)[0] + ".css" if not os.path.exists(stylesheet_path): - stylesheet_rel_path = getattr(bpy.context.scene.DocProperties, "schedules_stylesheet_path") - ifc_file_path = os.path.dirname(IfcStore.path) + stylesheet_rel_path = props.schedules_stylesheet_path + ifc_file_path = os.path.dirname(tool.Ifc.get_path()) stylesheet_path = ifc_file_path + "\\" + stylesheet_rel_path if not os.path.exists(stylesheet_path): stylesheet_path = tool.Blender.get_data_dir_path(Path("assets") / "schedule.css") @@ -91,7 +92,7 @@ class Scheduler: self.svg.defs.add(self.svg.style(css)) - def schedule_xlsx(self, infile, outfile): + def schedule_xlsx(self, infile: str, outfile: str) -> None: workbook = openpyxl.open(infile, data_only=True) sheet = workbook.active @@ -236,7 +237,7 @@ class Scheduler: self.svg["viewBox"] = "0 0 {} {}".format(total_width, total_height) self.svg.save(pretty=True) - def schedule_ods(self, infile, outfile): + def schedule_ods(self, infile: str, outfile: str) -> None: doc = load_ods(infile) # useful for debugging ods @@ -495,11 +496,11 @@ class Scheduler: self.svg["viewBox"] = "0 0 {} {}".format(total_width, total_height) self.svg.save(pretty=True) - def get_style(self, style_name, styles): + def get_style(self, style_name: str, styles: dict) -> dict: style = styles[style_name] if style_name else {} return style - def get_box_alignment(self, style): + def get_box_alignment(self, style: dict) -> str: if style and "vertical-align" in style and style["vertical-align"] != "automatic": vertical_align = style["vertical-align"] else: diff --git a/src/bonsai/bonsai/bim/module/drawing/sheeter.py b/src/bonsai/bonsai/bim/module/drawing/sheeter.py index c5f274610c..93c42bbae5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/sheeter.py +++ b/src/bonsai/bonsai/bim/module/drawing/sheeter.py @@ -32,6 +32,7 @@ from mathutils import Vector import re VIEW_TITLE_OFFSET_Y = 5 +DRAWING_PADDING = 10 DEFAULT_POSITION = Vector((30, 30)) SVG = "{http://www.w3.org/2000/svg}" XLINK = "{http://www.w3.org/1999/xlink}" @@ -113,13 +114,15 @@ class SheetBuilder: view_width = self.convert_to_mm(view_root.attrib.get("width")) view_height = self.convert_to_mm(view_root.attrib.get("height")) + x, y = self.next_drawing_location(layout_root, view_width) + # add background if os.path.isfile(underlay_path): background = ET.SubElement(view, "image") background.attrib["data-type"] = "background" background.attrib["xlink:href"] = os.path.relpath(underlay_path, layout_dir) - background.attrib["x"] = str(DEFAULT_POSITION.x) - background.attrib["y"] = str(DEFAULT_POSITION.y) + background.attrib["x"] = str(x) + background.attrib["y"] = str(y) background.attrib["width"] = str(view_width) background.attrib["height"] = str(view_height) @@ -128,16 +131,49 @@ class SheetBuilder: foreground = ET.SubElement(view, "image") foreground.attrib["data-type"] = "foreground" foreground.attrib["xlink:href"] = os.path.relpath(drawing_path, layout_dir) - foreground.attrib["x"] = str(DEFAULT_POSITION.x) - foreground.attrib["y"] = str(DEFAULT_POSITION.y) + foreground.attrib["x"] = str(x) + foreground.attrib["y"] = str(y) foreground.attrib["width"] = str(view_width) foreground.attrib["height"] = str(view_height) - self.add_view_title( - DEFAULT_POSITION.x, view_height + DEFAULT_POSITION.y + VIEW_TITLE_OFFSET_Y, view, layout_dir - ) + self.add_view_title(x, view_height + y + VIEW_TITLE_OFFSET_Y, view, layout_dir) layout_tree.write(layout_path) + def next_drawing_location(self, layout_root: ET.Element, next_width: float) -> list: + titleblocks = layout_root.findall(f'{SVG}g[@data-type="titleblock"]') + drawings = layout_root.findall(f'{SVG}g[@data-type="drawing"]') + + # how wide is the title block frame + try: + titleblock_width = self.convert_to_mm(titleblocks[0][0].attrib.get("width")) + except (IndexError, AttributeError): + titleblock_width = 840.0 + + # where does the last drawing finish + try: + last = drawings[-1][0] + last_width = self.convert_to_mm(last.attrib.get("width")) + last_x = self.convert_to_mm(last.attrib.get("x")) + last_y = self.convert_to_mm(last.attrib.get("y")) + except (IndexError, AttributeError): + return [DEFAULT_POSITION.x, DEFAULT_POSITION.y] + + # check if the new drawing fits in the current row + if last_x + last_width + DRAWING_PADDING + next_width + DEFAULT_POSITION.x < titleblock_width: + return [last_x + last_width + DRAWING_PADDING, last_y] + + # start a new row, find the y + for drawing in drawings: + for image in drawing: + try: + image_y = self.convert_to_mm(image.attrib.get("y")) + image_height = self.convert_to_mm(image.attrib.get("height")) + except AttributeError: + return [DEFAULT_POSITION.x, DEFAULT_POSITION.y] + if image_y + image_height + DRAWING_PADDING > last_y: + last_y = image_y + image_height + DRAWING_PADDING + return [DEFAULT_POSITION.x, last_y] + def update_sheet_drawing_sizes(self, sheet: ifcopenshell.entity_instance) -> None: ET.register_namespace("", "http://www.w3.org/2000/svg") @@ -221,6 +257,8 @@ class SheetBuilder: view_width = self.convert_to_mm(view_root.attrib.get("width")) view_height = self.convert_to_mm(view_root.attrib.get("height")) + x, y = self.next_drawing_location(layout_root, view_width) + view = ET.SubElement(layout_root, "g") view.attrib["data-id"] = str(reference.id()) view.attrib["data-type"] = document.Scope.lower() @@ -229,14 +267,12 @@ class SheetBuilder: foreground = ET.SubElement(view, "image") foreground.attrib["data-type"] = "content" foreground.attrib["xlink:href"] = os.path.relpath(view_path, layout_dir) - foreground.attrib["x"] = str(DEFAULT_POSITION.x) - foreground.attrib["y"] = str(DEFAULT_POSITION.y) + foreground.attrib["x"] = str(x) + foreground.attrib["y"] = str(y) foreground.attrib["width"] = str(view_width) foreground.attrib["height"] = str(view_height) - self.add_view_title( - DEFAULT_POSITION.x, view_height + DEFAULT_POSITION.y + VIEW_TITLE_OFFSET_Y, view, layout_dir - ) + self.add_view_title(x, view_height + y + VIEW_TITLE_OFFSET_Y, view, layout_dir) layout_tree.write(layout_path) def add_view_title(self, x: float, y: float, parent: ET.Element, layout_dir: str) -> None: diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index e6dd66150a..0aaa66ef50 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -181,35 +181,35 @@ class SvgWriter: self.decimal_places = decimal_places for element in annotations: obj = tool.Ifc.get_object(element) - if not obj or element.ObjectType == "DRAWING": + if not obj or (object_type := ifcopenshell.util.element.get_predefined_type(element)) == "DRAWING": continue - elif element.ObjectType == "GRID": + elif object_type == "GRID": self.draw_grid_annotation(obj) - elif element.ObjectType == "TEXT_LEADER": + elif object_type == "TEXT_LEADER": self.draw_leader_annotation(obj) - elif element.ObjectType == "STAIR_ARROW": + elif object_type == "STAIR_ARROW": self.draw_stair_annotation(obj) - elif element.ObjectType == "DIMENSION": + elif object_type == "DIMENSION": self.draw_dimension_annotations(obj) - elif element.ObjectType == "ANGLE": + elif object_type == "ANGLE": self.draw_angle_annotations(obj) - elif element.ObjectType == "RADIUS": + elif object_type == "RADIUS": self.draw_radius_annotations(obj) - elif element.ObjectType == "DIAMETER": + elif object_type == "DIAMETER": self.draw_diameter_annotations(obj) - elif element.ObjectType == "ELEVATION": + elif object_type == "ELEVATION": self.draw_elevation_annotation(obj) - elif element.ObjectType == "SECTION": + elif object_type == "SECTION": self.draw_section_annotation(obj) - elif element.ObjectType == "BREAKLINE": + elif object_type == "BREAKLINE": self.draw_break_annotations(obj) - elif element.ObjectType == "PLAN_LEVEL": + elif object_type == "PLAN_LEVEL": self.draw_plan_level_annotation(obj) - elif element.ObjectType == "SECTION_LEVEL": + elif object_type == "SECTION_LEVEL": self.draw_section_level_annotation(obj) - elif element.ObjectType == "TEXT": + elif object_type == "TEXT": self.draw_text_annotation(obj, obj.location) - elif element.ObjectType in ("FALL", "SLOPE_ANGLE", "SLOPE_FRACTION", "SLOPE_PERCENT"): + elif object_type in ("FALL", "SLOPE_ANGLE", "SLOPE_FRACTION", "SLOPE_PERCENT"): self.draw_fall_annotations(obj) else: self.draw_misc_annotation(obj) @@ -1128,7 +1128,12 @@ class SvgWriter: def get_text(): radius = (points[-1].co - points[-2].co).length - radius = helper.format_distance(radius, precision=self.precision, decimal_places=self.decimal_places) + radius = helper.format_distance( + radius, + precision=self.precision, + decimal_places=self.decimal_places, + custom_unit=dimension_data["custom_unit"], + ) text = f"R{radius}" return text @@ -1207,13 +1212,14 @@ class SvgWriter: angle = 90 # ues SLOPE_ANGLE as default - if element.ObjectType in ("FALL", "SLOPE_ANGLE"): + object_type = ifcopenshell.util.element.get_predefined_type(element) + if object_type in ("FALL", "SLOPE_ANGLE"): return f"{angle}°" - elif element.ObjectType == "SLOPE_FRACTION": + elif object_type == "SLOPE_FRACTION": if angle == 90: return "-" return f"{helper.format_distance(rise, precision=self.precision, decimal_places=self.decimal_places)} / {helper.format_distance(run, precision=self.precision, decimal_places=self.decimal_places)}" - elif element.ObjectType == "SLOPE_PERCENT": + elif object_type == "SLOPE_PERCENT": if angle == 90: return "-" return f"{round(angle_tg * 100)} %" @@ -1255,6 +1261,7 @@ class SvgWriter: text_prefix=dimension_data["text_prefix"], text_suffix=dimension_data["text_suffix"], fill_bg=dimension_data["fill_bg"], + custom_unit=dimension_data["custom_unit"], ) def draw_dimension_annotations(self, obj): @@ -1279,6 +1286,7 @@ class SvgWriter: text_prefix=dimension_data["text_prefix"], text_suffix=dimension_data["text_suffix"], fill_bg=dimension_data["fill_bg"], + custom_unit=dimension_data["custom_unit"], ) def draw_measureit_arch_dimension_annotations(self): @@ -1305,6 +1313,7 @@ class SvgWriter: text_prefix="", text_suffix="", fill_bg=False, + custom_unit=None, ): offset = Vector([self.raw_width, self.raw_height]) / 2 v0 = self.project_point_onto_camera(v0_global) @@ -1338,6 +1347,7 @@ class SvgWriter: precision=self.precision, decimal_places=self.decimal_places, suppress_zero_inches=suppress_zero_inches, + custom_unit=custom_unit, ) text = text_prefix + str(dimension) + text_suffix else: diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index f35fc55325..6bf62ddec2 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -49,7 +49,7 @@ class BIM_PT_camera(Panel): return self.layout.use_property_split = True - dprops = context.scene.DocProperties + dprops = tool.Drawing.get_document_props() props = context.scene.camera.data.BIMCameraProperties col = self.layout.column(align=True) @@ -161,7 +161,7 @@ class BIM_PT_drawing_underlay(Panel): layout.use_property_split = True camera = context.scene.camera assert camera - dprops = context.scene.DocProperties + dprops = tool.Drawing.get_document_props() props = camera.data.BIMCameraProperties drawing_index_is_valid = props.active_drawing_style_index < len(dprops.drawing_styles) @@ -229,7 +229,7 @@ class BIM_PT_drawings(Panel): draw_project_not_saved_ui(self) return - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if not self.props.is_editing_drawings: row = self.layout.row(align=True) @@ -302,7 +302,7 @@ class BIM_PT_schedules(Panel): draw_project_not_saved_ui(self) return - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if not self.props.is_editing_schedules: row = self.layout.row(align=True) @@ -352,7 +352,7 @@ class BIM_PT_references(Panel): draw_project_not_saved_ui(self) return - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if not self.props.is_editing_references: row = self.layout.row(align=True) @@ -394,7 +394,7 @@ class BIM_PT_sheets(Panel): draw_project_not_saved_ui(self) return - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if not self.props.is_editing_sheets: row = self.layout.row(align=True) @@ -601,7 +601,7 @@ class BIM_UL_drawinglist(bpy.types.UIList): selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT" row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False) row.prop(item, "name", text="", emboss=False) - self.props = context.scene.DocProperties + self.props = tool.Drawing.get_document_props() if ( self.props.drawings and self.props.active_drawing_id diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 30d57bf3db..cbe9de1dc3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -19,6 +19,7 @@ import os import bpy +import ifcopenshell.util.element import bonsai.core.type import bonsai.core.drawing as core import bonsai.tool as tool @@ -26,7 +27,7 @@ import ifcopenshell.util.representation from bonsai.bim.module.drawing.data import DecoratorData, AnnotationData from bonsai.bim.helper import prop_with_search from bpy.types import WorkSpaceTool -from typing import Union +from functools import partial class LaunchAnnotationTypeManager(bpy.types.Operator): @@ -134,23 +135,9 @@ class AnnotationTool(WorkSpaceTool): AnnotationToolUI.draw(context, layout) -def add_layout_hotkey_operator( - layout: bpy.types.UILayout, text: str, hotkey: str, description: Union[str, None] -) -> tuple[bpy.types.OperatorProperties, bpy.types.UILayout]: - modifiers = { - "A": "EVENT_ALT", - "S": "EVENT_SHIFT", - } - modifier, key = hotkey.split("_") - - row = layout.row(align=True) - row.label(text="", icon=modifiers[modifier]) - row.label(text="", icon=f"EVENT_{key}") - - op = row.operator("bim.annotation_hotkey", text=text) - op.hotkey = hotkey - op.description = description - return op, row +add_layout_hotkey_operator = partial( + tool.Blender.add_layout_hotkey_operator, tool_name="annotation", module_name=__name__ +) # TODO: move to operator @@ -197,6 +184,8 @@ def create_annotation_occurrence(context): class AnnotationToolUI: + layout: bpy.types.UILayout + @classmethod def draw(cls, context, layout): cls.layout = layout @@ -224,7 +213,8 @@ class AnnotationToolUI: @classmethod def draw_create_object_interface(cls): row = cls.layout.row(align=True) - row.prop(bpy.context.scene.DocProperties, "should_draw_decorations", text="Viewport Annotations") + props = tool.Drawing.get_document_props() + row.prop(props, "should_draw_decorations", text="Viewport Annotations") @classmethod def draw_edit_object_interface(cls, context): @@ -261,8 +251,8 @@ class AnnotationToolUI: class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.annotation_hotkey" - bl_label = "Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -330,7 +320,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not element or not element.is_a("IfcAnnotation"): continue - annotation_type = element.ObjectType + annotation_type = ifcopenshell.util.element.get_predefined_type(element) if annotation_type not in tool.Drawing.ANNOTATION_TYPES_SUPPORT_SETUP: self.report({"ERROR"}, f"Annotation type {annotation_type} is not supported for readjustment.") continue diff --git a/src/bonsai/bonsai/bim/module/fm/operator.py b/src/bonsai/bonsai/bim/module/fm/operator.py index 6776c27e14..fac40439c2 100644 --- a/src/bonsai/bonsai/bim/module/fm/operator.py +++ b/src/bonsai/bonsai/bim/module/fm/operator.py @@ -24,14 +24,15 @@ import logging import tempfile import ifcopenshell import bonsai.tool as tool +from bpy_extras.io_utils import ExportHelper, ImportHelper -class ExecuteIfcFM(bpy.types.Operator): +class ExecuteIfcFM(bpy.types.Operator, ExportHelper): bl_idname = "bim.execute_ifcfm" bl_label = "Execute IfcFM" + bl_description = "Export IfcFM data as a spreadsheet." file_format: bpy.props.StringProperty() filter_glob: bpy.props.StringProperty(default="*.csv;*.ods;*.xlsx", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") @classmethod def poll(cls, context): @@ -44,9 +45,7 @@ class ExecuteIfcFM(bpy.types.Operator): def invoke(self, context, event): props = context.scene.BIMFMProperties self.filepath = bpy.path.ensure_ext(bpy.data.filepath, f".{props.format}") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def execute(self, context): props = context.scene.BIMFMProperties @@ -82,12 +81,12 @@ class ExecuteIfcFM(bpy.types.Operator): return {"FINISHED"} -class SelectFMSpreadsheetFiles(bpy.types.Operator): +class SelectFMSpreadsheetFiles(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_fm_spreadsheet_files" bl_label = "Select FM Spreadsheet Files" + bl_description = "Select FM spreadsheets to merge." bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.ods;*.xlsx", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") files: bpy.props.CollectionProperty(name="File Path", type=bpy.types.OperatorFileListElement) def execute(self, context): @@ -99,16 +98,12 @@ class SelectFMSpreadsheetFiles(bpy.types.Operator): new.name = os.path.join(dirname, f.name) return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class ExecuteIfcFMFederate(bpy.types.Operator): +class ExecuteIfcFMFederate(bpy.types.Operator, ExportHelper): bl_idname = "bim.execute_ifcfm_federate" bl_label = "Merge IfcFM SpreadSheets" + bl_description = "Merge added IfcFM spreadsheets." filter_glob: bpy.props.StringProperty(default="*.ods;*.xlsx", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") @classmethod def poll(cls, context): @@ -121,9 +116,7 @@ class ExecuteIfcFMFederate(bpy.types.Operator): def invoke(self, context, event): props = context.scene.BIMFMProperties self.filepath = bpy.path.ensure_ext(bpy.data.filepath, f".{props.format}") - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def execute(self, context): props = context.scene.BIMFMProperties diff --git a/src/bonsai/bonsai/bim/module/fm/ui.py b/src/bonsai/bonsai/bim/module/fm/ui.py index 1e76ec61c3..89cd764adf 100644 --- a/src/bonsai/bonsai/bim/module/fm/ui.py +++ b/src/bonsai/bonsai/bim/module/fm/ui.py @@ -18,7 +18,6 @@ import bonsai.tool as tool from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.fm.data import FMData @@ -40,11 +39,11 @@ class BIM_PT_fm(Panel): scene = context.scene props = scene.BIMFMProperties - if IfcStore.get_file(): + if tool.Ifc.get(): row = layout.row() row.prop(props, "should_load_from_memory") - if not IfcStore.get_file() or not props.should_load_from_memory: + if not tool.Ifc.get() or not props.should_load_from_memory: row = layout.row() props.ifc_files.layout_file_select(row, "*.ifc;*.ifczip;*.ifcxml", "IFC File(s)") diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index 533cdcd6bf..47dcaeddce 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -19,6 +19,7 @@ import bpy from . import ui, prop, operator from bpy.app.handlers import persistent +import ifcopenshell.util.element classes = ( operator.AddCurvelikeItem, @@ -98,12 +99,19 @@ addon_keymaps = [] @persistent -def block_scale(scene): +def block_scale(scene: bpy.types.Scene) -> None: + import bonsai.tool as tool + if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active): - if isinstance(obj, bpy.types.Object) and obj.BIMObjectProperties.ifc_definition_id: - if obj.scale != (1, 1, 1): - obj.scale = (1, 1, 1) - elif isinstance(obj, bpy.types.Mesh) and obj.BIMMeshProperties.ifc_definition_id: + if isinstance(obj, bpy.types.Object) and tool.Blender.get_ifc_definition_id(obj): + if obj.type == "CAMERA": + camera = tool.Ifc.get_entity(obj) + if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": + obj.scale = (-1, -1, -1) + else: + if obj.scale != (1, 1, 1): + obj.scale = (1, 1, 1) + elif isinstance(obj, bpy.types.Mesh) and tool.Geometry.get_mesh_props(obj).ifc_definition_id: if obj.scale != (1, 1, 1): obj.scale = (1, 1, 1) @@ -116,6 +124,7 @@ def register(): operator.OverrideDuplicateMoveLinkedMacro.define("BIM_OT_override_object_duplicate_move_linked") operator.OverrideDuplicateMoveLinkedMacro.define("TRANSFORM_OT_translate") operator.DuplicateMoveLinkedAggregateMacro.define("BIM_OT_object_duplicate_move_linked_aggregate") + operator.DuplicateMoveLinkedAggregateMacro.define("BIM_OT_override_move") operator.DuplicateMoveLinkedAggregateMacro.define("TRANSFORM_OT_translate") operator.OverrideMoveMacro.define("BIM_OT_override_move") operator.OverrideMoveMacro.define("TRANSFORM_OT_translate") diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index a719d27893..2522615885 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -53,15 +53,16 @@ class ViewportData: obj = bpy.context.active_object element = tool.Ifc.get_entity(obj) - modes = [obj_mode] - - if bpy.context.scene.BIMGeometryProperties.representation_obj: + modes: list[tuple[str, str, str, str, int]] = [obj_mode] + gprops = tool.Geometry.get_geometry_props() + if gprops.representation_obj: modes.append(item_mode) if not obj: return modes - if obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: + pprops = tool.Project.get_project_props() + if obj in pprops.clipping_planes_objs: pass elif element: if tool.Geometry.is_locked(element): @@ -107,8 +108,9 @@ class RepresentationsData: element = tool.Ifc.get_entity(obj) active_representation_id = None - if obj.data and hasattr(obj.data, "BIMMeshProperties"): - active_representation_id = obj.data.BIMMeshProperties.ifc_definition_id + active_representation = tool.Geometry.get_active_representation(obj) + if active_representation: + active_representation_id = active_representation.id() for representation in tool.Geometry.get_representations_iter(element): representation_type = representation.RepresentationType @@ -155,15 +157,17 @@ class RepresentationsData: if not cls.data["representations"]: return [] obj = tool.Geometry.get_active_or_representation_obj() + assert obj if not obj.data: return [] element = tool.Ifc.get_entity(obj) - if not (active_representation_id := obj.data.BIMMeshProperties.ifc_definition_id): + assert element + base_representation = tool.Geometry.get_active_representation(obj) + if not base_representation: return [] # Maybe in profile editing mode - base_representation = tool.Ifc.get().by_id(active_representation_id) # shape aspects matching context of the active representation - matching_shape_aspects = [] + matching_shape_aspects: list[ifcopenshell.entity_instance] = [] for shape_aspect in ifcopenshell.util.element.get_shape_aspects(element): matching_representation = tool.Geometry.get_shape_aspect_representation(shape_aspect, base_representation) if matching_representation: @@ -390,7 +394,7 @@ class PlacementData: def load(cls): cls.data = {"has_placement": cls.has_placement()} - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() obj = bpy.context.active_object if obj and props.has_blender_offset: xyz = cls.original_xyz(obj) @@ -413,7 +417,7 @@ class PlacementData: @classmethod def original_xyz(cls, obj): unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() xyz = ifcopenshell.util.geolocation.xyz2enh( obj.matrix_world[0][3], obj.matrix_world[1][3], diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py index 2be10a27f0..5993cc4469 100644 --- a/src/bonsai/bonsai/bim/module/geometry/decorator.py +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -46,12 +46,14 @@ class ItemDecorator: obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]] = {} objs: dict[str, dict[str, list]] = {} obj_matrix: dict[str, Matrix] = {} - for item_obj in context.scene.BIMGeometryProperties.item_objs: + props = tool.Geometry.get_geometry_props() + for item_obj in props.item_objs: if obj := item_obj.obj: obj: bpy.types.Object objs[obj.name] = cls.get_obj_data(obj) obj_is_selected[obj.name] = obj.select_get() - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + item = tool.Geometry.get_active_representation(obj) + assert item obj_is_boolean[obj.name] = [i for i in tool.Ifc.get().get_inverse(item) if i.is_a("IfcBooleanResult")] obj_matrix[obj.name] = obj.matrix_world.copy() @@ -143,7 +145,8 @@ class ItemDecorator: color = selected_elements_color blf.color(font_id, *color) - for item in context.scene.BIMGeometryProperties.item_objs: + props = tool.Geometry.get_geometry_props() + for item in props.item_objs: if (obj := item.obj) and obj.hide_get() == False: if obj.select_get(): centroid = obj.matrix_world @ Vector(obj.bound_box[0]).lerp(Vector(obj.bound_box[6]), 0.5) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index e231f878dc..f1c6d8630f 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -23,12 +23,14 @@ import numpy as np import numpy.typing as npt import ifcopenshell import ifcopenshell.api.layer +import ifcopenshell.api.style import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.representation import ifcopenshell.util.shape_builder import ifcopenshell.util.unit import ifcopenshell.api +import ifcopenshell.api.boundary import ifcopenshell.api.grid import bonsai.core.geometry import bonsai.core.geometry as core @@ -77,7 +79,8 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): self.separate_element(element) def separate_item(self, context, obj): - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + item = tool.Geometry.get_active_representation(obj) + assert item if tool.Geometry.is_meshlike_item(item): previous_selected_objects = context.selected_objects bpy.ops.mesh.separate(type=self.type) @@ -85,7 +88,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): if obj in previous_selected_objects: continue self.add_meshlike_item(obj) - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(tool.Geometry.get_geometry_props().representation_obj) else: self.report({"INFO"}, f"Separating an {item.is_a()} is not supported") @@ -113,14 +116,14 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): representation_type = representation.RepresentationType if representation_type in ("Brep", "AdvancedBrep"): item = builder.faceted_brep(verts, faces) - elif representation_type in ("Tessellation"): + elif representation_type == "Tessellation": item = builder.mesh(verts, faces) else: assert False, f"Unexpected representation type: '{representation_type}'." representation.Items = list(representation.Items) + [item] obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Ifc.link(item, obj) props.add_item_object(obj, item) def separate_element(self, element): @@ -301,11 +304,12 @@ class AddRepresentation(bpy.types.Operator, tool.Ifc.Operator): return context.window_manager.invoke_props_dialog(self) def draw(self, context): + props = tool.Geometry.get_geometry_props() row = self.layout.row() row.prop(self, "representation_conversion_method", text="") if self.representation_conversion_method == "OBJECT": row = self.layout.row() - row.prop(context.scene.BIMGeometryProperties, "representation_from_object", text="") + row.prop(props, "representation_from_object", text="") class SelectConnection(bpy.types.Operator, tool.Ifc.Operator): @@ -456,13 +460,18 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} def update_obj_mesh_representation(self, context: bpy.types.Context, obj: bpy.types.Object) -> None: - product = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) + data = obj.data + assert tool.Geometry.is_data_supported_for_adding_representation(data) + mprops = tool.Geometry.get_mesh_props(data) + + product = tool.Ifc.get_entity(obj) + assert product material = ifcopenshell.util.element.get_material(product, should_skip_usage=True) # NOTE: Currently iterator doesn't detect whether opening is actually affected the representation # or it's just present on the element. In theory, we can also allow editing representations # if we know that representation wasn't affected by existing openings. - has_openings = tool.Geometry.has_openings(product) and obj.data.BIMMeshProperties.has_openings_applied + has_openings = tool.Geometry.has_openings(product) and tool.Geometry.get_mesh_props(data).has_openings_applied if has_openings and not self.apply_openings: # Meshlike things with openings can only be updated without openings applied. if self.from_ui: @@ -474,18 +483,19 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): if product.is_a("IfcGridAxis"): # Grid geometry does not follow the "representation" paradigm and needs to be treated specially - ifcopenshell.api.grid.create_axis_curve(self.file, axis_curve=obj, grid_axis=product) + tool.Model.create_axis_curve(obj, product) return elif product.is_a("IfcRelSpaceBoundary"): # TODO refactor settings = tool.Boundary.get_assign_connection_geometry_settings(obj) - ifcopenshell.api.run("boundary.assign_connection_geometry", tool.Ifc.get(), **settings) + ifcopenshell.api.boundary.assign_connection_geometry(tool.Ifc.get(), **settings) return if tool.Ifc.is_moved(obj) or tool.Geometry.is_scaled(obj): core.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - old_representation = self.file.by_id(obj.data.BIMMeshProperties.ifc_definition_id) + old_representation = tool.Geometry.get_active_representation(obj) + assert old_representation if material and material.is_a() in ["IfcMaterialProfileSet", "IfcMaterialLayerSet"]: if self.ifc_representation_class == "IfcTessellatedFaceSet": # We are explicitly casting to a tessellation, so remove all parametric materials. @@ -544,12 +554,12 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): tool.Geometry.run_style_add_style(obj=mat) for mat in tool.Geometry.get_object_materials_without_styles(obj) ] - ifcopenshell.api.run( - "style.assign_representation_styles", + props = tool.Geometry.get_geometry_props() + ifcopenshell.api.style.assign_representation_styles( self.file, shape_representation=new_representation, styles=tool.Geometry.get_styles(obj, only_assigned_to_faces=True), - should_use_presentation_style_assignment=context.scene.BIMGeometryProperties.should_use_presentation_style_assignment, + should_use_presentation_style_assignment=props.should_use_presentation_style_assignment, ) tool.Geometry.record_object_materials(obj) @@ -568,8 +578,8 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): continue representation.RepresentationIdentifier = "Reference" - obj.data.BIMMeshProperties.ifc_definition_id = int(new_representation.id()) - obj.data.name = f"{old_representation.ContextOfItems.id()}/{new_representation.id()}" + tool.Ifc.link(new_representation, data) + data.name = tool.Loader.get_mesh_name(new_representation) # TODO: In simple scenarios, a type has a ShapeRepresentation of ID # 123. This is then mapped through mapped representations by @@ -585,7 +595,7 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): # transformations. core.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_representation) - if obj.data.BIMMeshProperties.ifc_parameters: + if mprops.ifc_parameters: core.get_representation_ifc_parameters(tool.Geometry, obj=obj) @@ -597,12 +607,13 @@ class UpdateParametricRepresentation(bpy.types.Operator): @classmethod def poll(cls, context): - return context.active_object and context.active_object.mode == "OBJECT" + return (obj := context.active_object) and obj.mode == "OBJECT" and tool.Geometry.has_mesh_properties(obj.data) def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() obj = context.active_object - props = obj.data.BIMMeshProperties + assert obj and tool.Geometry.has_mesh_properties(obj.data) + props = tool.Geometry.get_mesh_props(obj.data) parameter = props.ifc_parameters[self.index] self.file.by_id(parameter.step_id)[parameter.index] = parameter.value show_representation_parameters = bool(props.ifc_parameters) @@ -626,8 +637,10 @@ class GetRepresentationIfcParameters(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.get_representation_ifc_parameters(tool.Geometry, obj=context.active_object) - parameters = context.active_object.data.BIMMeshProperties.ifc_parameters + obj = context.active_object + assert obj and tool.Geometry.has_mesh_properties((data := obj.data)) + core.get_representation_ifc_parameters(tool.Geometry, obj=obj) + parameters = tool.Geometry.get_mesh_props(data).ifc_parameters self.report({"INFO"}, f"{len(parameters)} parameters found.") @@ -671,7 +684,7 @@ class CopyRepresentation(bpy.types.Operator, tool.Ifc.Operator): ) -def lock_error_message(name): +def lock_error_message(name: str) -> str: return f"'{name}' is locked. Unlock it via the Spatial panel in the Project Overview tab." @@ -719,8 +732,10 @@ class OverrideDelete(bpy.types.Operator): if self.is_batch: row = self.layout.row() row.label(text="Warning: Faster deletion will use more memory.", icon="ERROR") + row = self.layout.row() + row.label(text="See system console for deletion progress.") - def _execute(self, context): + def _execute(self, context: bpy.types.Context): start_time = time() if self.is_batch: @@ -728,10 +743,19 @@ class OverrideDelete(bpy.types.Operator): self.process_arrays(context) clear_active_object = True - for obj in context.selected_objects: - try: - obj.name - except: + objects_to_remove = context.selected_objects + for i, obj in enumerate(objects_to_remove, 1): + # Log time. + time_since_start = time() - start_time + is_valid_data_block = tool.Blender.is_valid_data_block(obj) + if time_since_start > 10: + obj_name = f" ({obj.name})" if is_valid_data_block else "" + print( + f"Removing object {i}/{len(objects_to_remove)}{obj_name}. " + f"Time since start: {time_since_start:.2f} seconds." + ) + + if not is_valid_data_block: continue element = tool.Ifc.get_entity(obj) if element: @@ -781,7 +805,7 @@ class OverrideDelete(bpy.types.Operator): data["old_file"].redo() tool.Ifc.set(data["new_file"]) - def process_arrays(self, context): + def process_arrays(self, context: bpy.types.Context) -> None: selected_objects = set(context.selected_objects) array_parents = set() for obj in context.selected_objects: @@ -824,7 +848,7 @@ class OverrideOutlinerDelete(bpy.types.Operator): # unintended IFC spatial modifications. To make life less confusing for # the user, Delete means Delete. End of story. # Deep magick from the dawn of time - if IfcStore.get_file(): + if tool.Ifc.get(): return IfcStore.execute_ifc_operator(self, context) # https://blender.stackexchange.com/questions/203729/python-get-selected-objects-in-outliner objects_to_delete = set() @@ -880,7 +904,7 @@ class OverrideOutlinerDelete(bpy.types.Operator): if element := tool.Ifc.get_entity(obj): if tool.Geometry.is_locked(element): self.report({"ERROR"}, lock_error_message(obj.name)) - if collection := obj.BIMObjectProperties.collection: + if collection := tool.Blender.get_object_bim_props(obj).collection: collections_to_delete.discard(collection) continue tool.Geometry.delete_ifc_object(obj) @@ -948,7 +972,7 @@ class OverrideDuplicateMove(bpy.types.Operator): @staticmethod def execute_duplicate_operator(self, context, linked=False): # Deep magick from the dawn of time - if IfcStore.get_file(): + if tool.Ifc.get(): IfcStore.execute_ifc_operator(self, context) if self.new_active_obj: context.view_layer.objects.active = self.new_active_obj @@ -1029,7 +1053,8 @@ class OverrideDuplicateMove(bpy.types.Operator): # Unlink from previous boolean element # and keep object tracked for decorations. if is_tracked_opening: - new_obj.data.BIMMeshProperties.ifc_boolean_id = 0 + mprops = tool.Geometry.get_mesh_props(new_obj.data) + mprops.ifc_boolean_id = 0 tool.Root.add_tracked_opening(new_obj, tracked_opening_type) if obj == context.active_object: @@ -1043,7 +1068,7 @@ class OverrideDuplicateMove(bpy.types.Operator): continue # clear object's collection so it will be able to have it's own - new_obj.BIMObjectProperties.collection = None + tool.Blender.get_object_bim_props(new_obj).collection = None # copy the actual class new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) @@ -1053,7 +1078,7 @@ class OverrideDuplicateMove(bpy.types.Operator): if new.is_a("IfcRelSpaceBoundary"): surface = new.ConnectionGeometry.SurfaceOnRelatingElement temp_data.name = f"0/{surface.id()}" - temp_data.BIMMeshProperties.ifc_definition_id = surface.id() + tool.Ifc.link(surface, temp_data) else: tool.Blender.remove_data_block(temp_data) @@ -1089,12 +1114,14 @@ class OverrideDuplicateMove(bpy.types.Operator): @staticmethod def duplicate_item(obj: bpy.types.Object) -> None: props = tool.Geometry.get_geometry_props() - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + item = tool.Geometry.get_active_representation(obj) + assert item new_item = ifcopenshell.util.element.copy_deep(tool.Ifc.get(), item) new_obj = obj.copy() + assert tool.Geometry.has_mesh_properties(obj.data) temp_data = obj.data.copy() new_obj.data = temp_data - new_obj.data.BIMMeshProperties.ifc_definition_id = new_item.id() + tool.Ifc.link(new_item, temp_data) new_obj.name = obj.data.name = f"Item/{new_item.is_a()}/{new_item.id()}" props.add_item_object(new_obj, new_item) @@ -1670,7 +1697,8 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): def join_item(self) -> None: props = tool.Geometry.get_geometry_props() ifc_file = tool.Ifc.get() - item = tool.Ifc.get().by_id(self.target.data.BIMMeshProperties.ifc_definition_id) + item = tool.Geometry.get_active_representation(self.target) + assert item if tool.Geometry.is_meshlike_item(item): tool.Geometry.dissolve_triangulated_edges(self.target) item_objs = [i.obj for i in props.item_objs if i.obj] @@ -1693,7 +1721,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): for item_data in items_data: props.add_item_object(item_data["obj"], ifc_file.by_id(item_data["ifc_definition_id"])) - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) bpy.context.view_layer.update() tool.Root.reload_item_decorator() @@ -1701,7 +1729,8 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): ifc_file = tool.Ifc.get() builder = ShapeBuilder(ifc_file) si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - representation = ifc_file.by_id(self.target.data.BIMMeshProperties.ifc_definition_id) + representation = tool.Geometry.get_active_representation(self.target) + assert representation representation_type = representation.RepresentationType if representation_type in ("Tessellation", "Brep"): for obj in bpy.context.selected_objects: @@ -1740,7 +1769,8 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): continue # Only objects of the same representation type can be joined - obj_rep = ifc_file.by_id(obj.data.BIMMeshProperties.ifc_definition_id) + obj_rep = tool.Geometry.get_active_representation(obj) + assert obj_rep if obj_rep.RepresentationType != representation_type: obj.select_set(False) self.report( @@ -1891,9 +1921,10 @@ class OverrideEscape(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - if context.scene.BIMGeometryProperties.mode == "ITEM": + props = tool.Geometry.get_geometry_props() + if props.mode == "ITEM": tool.Geometry.disable_item_mode() - elif context.scene.BIMGeometryProperties.mode == "EDIT": + elif props.mode == "EDIT": bpy.ops.bim.override_mode_set_object("INVOKE_DEFAULT", should_save=False) tool.Geometry.disable_item_mode() elif tool.Model.get_model_props().openings: @@ -1913,7 +1944,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def invoke(self, context, event): - return IfcStore.execute_ifc_operator(self, context, is_invoke=True) + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") def _invoke(self, context, event): if not tool.Ifc.get(): @@ -1949,6 +1980,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): def handle_single_object(self, context: bpy.types.Context, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) props = tool.Geometry.get_geometry_props() + pprops = tool.Project.get_project_props() if obj == props.representation_obj: self.report({"ERROR"}, f"Element '{obj.name}' is in item mode and cannot be edited directly") elif obj in [o.obj for o in context.scene.BIMAggregateProperties.not_editing_objects]: @@ -1956,7 +1988,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): self.report( {"ERROR"}, f"Element '{obj.name}' does not belong to this aggregate and cannot be edited directly" ) - elif obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: + elif obj in pprops.clipping_planes_objs: self.report({"ERROR"}, "Clipping planes cannot be edited") elif element: if not obj.data: @@ -2004,12 +2036,15 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): self.handle_single_object(context, obj) def enable_editing_representation_item(self, context: bpy.types.Context, obj: bpy.types.Object) -> None: - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) - element = tool.Ifc.get_entity(context.scene.BIMGeometryProperties.representation_obj) + item = tool.Geometry.get_active_representation(obj) + assert item + element = tool.Ifc.get_entity(tool.Geometry.get_geometry_props().representation_obj) if tool.Geometry.is_meshlike_item(item): tool.Geometry.dissolve_triangulated_edges(obj) tool.Blender.select_and_activate_single_object(context, obj) - obj.data.BIMMeshProperties.mesh_checksum = tool.Geometry.get_mesh_checksum(obj.data) + assert isinstance(mesh := obj.data, bpy.types.Mesh) + props = tool.Geometry.get_mesh_props(mesh) + props.mesh_checksum = tool.Geometry.get_mesh_checksum(mesh) self.enable_edit_mode(context) elif ( item.is_a("IfcSweptAreaSolid") @@ -2026,21 +2061,21 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.", ) return - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Ifc.link(item, obj.data) self.enable_edit_mode(context) ProfileDecorator.install(context) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") elif item.is_a("IfcAnnotationFillArea"): tool.Model.import_annotation_fill_area(item, obj=obj) - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Ifc.link(item, obj.data) self.enable_edit_mode(context) ProfileDecorator.install(context) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") elif tool.Geometry.is_curvelike_item(item): tool.Model.import_curve(item, obj=obj) - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Ifc.link(item, obj.data) self.enable_edit_mode(context) ProfileDecorator.install(context) if not bpy.app.background: @@ -2051,10 +2086,11 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): def enable_edit_mode(self, context: bpy.types.Context) -> Union[None, set[str]]: if tool.Blender.toggle_edit_mode(context) == {"CANCELLED"}: return {"CANCELLED"} - context.scene.BIMGeometryProperties.is_changing_mode = True - if context.scene.BIMGeometryProperties.mode != "EDIT": - context.scene.BIMGeometryProperties.mode = "EDIT" - context.scene.BIMGeometryProperties.is_changing_mode = False + props = tool.Geometry.get_geometry_props() + props.is_changing_mode = True + if props.mode != "EDIT": + props.mode = "EDIT" + props.is_changing_mode = False def has_aggregates(self, objs): for obj in objs: @@ -2089,7 +2125,7 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): should_save: bpy.props.BoolProperty(name="Should Save", default=True) def invoke(self, context, event): - return IfcStore.execute_ifc_operator(self, context, is_invoke=True) + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") def _invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: if not tool.Ifc.get(): @@ -2101,14 +2137,15 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.toggle_edit_mode(context) - context.scene.BIMGeometryProperties.is_changing_mode = True - if context.scene.BIMGeometryProperties.representation_obj: - if context.scene.BIMGeometryProperties.mode != "ITEM": - context.scene.BIMGeometryProperties.mode = "ITEM" + props = tool.Geometry.get_geometry_props() + props.is_changing_mode = True + if props.representation_obj: + if props.mode != "ITEM": + props.mode = "ITEM" else: - if context.scene.BIMGeometryProperties.mode != "OBJECT": - context.scene.BIMGeometryProperties.mode = "OBJECT" - context.scene.BIMGeometryProperties.is_changing_mode = False + if props.mode != "OBJECT": + props.mode = "OBJECT" + props.is_changing_mode = False if context.active_object and self.should_save: element = tool.Ifc.get_entity(context.active_object) @@ -2136,7 +2173,8 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): continue if tool.Profile.is_editing_profile(): - profile_id = context.scene.BIMProfileProperties.active_profile_id + props = tool.Profile.get_profile_props() + profile_id = props.active_profile_id if profile_id: profile = tool.Ifc.get().by_id(profile_id) if tool.Ifc.get_object(profile): # We are editing an arbitrary profile @@ -2148,29 +2186,32 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): elif tool.Model.get_usage_type(element) == "PROFILE": bpy.ops.bim.edit_extrusion_axis() # if in the process of editing arbitrary profile - elif context.scene.BIMProfileProperties.active_arbitrary_profile_id: + elif props.active_arbitrary_profile_id: bpy.ops.bim.edit_arbitrary_profile() else: bpy.ops.bim.edit_extrusion_profile() return self.execute(context) - elif obj.data.BIMMeshProperties.ifc_definition_id: - if not tool.Geometry.has_geometric_data(obj): + elif representation := tool.Geometry.get_active_representation(obj): + if not tool.Geometry.is_geometric_data(obj.data): self.is_valid = False self.should_save = False - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + assert tool.Geometry.has_mesh_properties(obj.data) + mesh_props = tool.Geometry.get_mesh_props(obj.data) if tool.Geometry.is_meshlike( representation - ) and obj.data.BIMMeshProperties.mesh_checksum != tool.Geometry.get_mesh_checksum(obj.data): + ) and mesh_props.mesh_checksum != tool.Geometry.get_mesh_checksum(obj.data): self.edited_objs.append(obj) elif getattr(element, "HasOpenings", None): self.unchanged_objs_with_openings.append(obj) else: tool.Ifc.finish_edit(obj) elif element.is_a("IfcGridAxis"): - if not tool.Geometry.has_geometric_data(obj): + if not tool.Geometry.is_geometric_data(obj.data): self.is_valid = False self.should_save = False - if obj.data.BIMMeshProperties.mesh_checksum != tool.Geometry.get_mesh_checksum(obj.data): + assert tool.Geometry.has_mesh_properties(obj.data) + mesh_props = tool.Geometry.get_mesh_props(obj.data) + if mesh_props.mesh_checksum != tool.Geometry.get_mesh_checksum(obj.data): self.edited_objs.append(obj) else: tool.Ifc.finish_edit(obj) @@ -2195,9 +2236,11 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} def edit_representation_item(self, obj: bpy.types.Object) -> None: - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + props = tool.Geometry.get_geometry_props() + item = tool.Geometry.get_active_representation(obj) + assert item if tool.Geometry.is_meshlike_item(item): - if tool.Geometry.has_geometric_data(obj) and obj.data.polygons: + if tool.Geometry.is_geometric_data(obj.data) and obj.data.polygons: tool.Geometry.edit_meshlike_item(obj) else: tool.Geometry.import_item(obj) @@ -2214,15 +2257,17 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): return old_profile = item.SweptArea + profile.ProfileName = old_profile.ProfileName for inverse in tool.Ifc.get().get_inverse(old_profile): ifcopenshell.util.element.replace_attribute(inverse, old_profile, profile) + tool.Profile.replace_profile_in_profiles_ui(old_profile.id(), profile.id()) ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_profile) - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) tool.Geometry.import_item(obj) tool.Geometry.import_item_attributes(obj) - element = tool.Ifc.get_entity(bpy.context.scene.BIMGeometryProperties.representation_obj) + element = tool.Ifc.get_entity(props.representation_obj) # Only certain classes should have a footprint if element.is_a() in ("IfcSlab", "IfcRamp"): footprint_context = ifcopenshell.util.representation.get_context( @@ -2275,9 +2320,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): for inverse in tool.Ifc.get().get_inverse(item): ifcopenshell.util.element.replace_attribute(inverse, item, profile) ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), item) - obj.data.BIMMeshProperties.ifc_definition_id = profile.id() + tool.Ifc.link(profile, obj.data) - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) tool.Geometry.import_item(obj) tool.Geometry.import_item_attributes(obj) elif tool.Geometry.is_curvelike_item(item): @@ -2303,10 +2348,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.util.element.replace_attribute(inverse, item, new) ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), item) - obj.data.BIMMeshProperties.ifc_definition_id = new.id() + tool.Ifc.link(new, obj.data) tool.Geometry.import_item(obj) - props = tool.Geometry.get_geometry_props() for item in additional_curves: representation = tool.Geometry.get_active_representation(props.representation_obj) representation = ifcopenshell.util.representation.resolve_representation(representation) @@ -2315,7 +2359,7 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): name = f"Item/{item.is_a()}/{item.id()}" mesh = bpy.data.meshes.new(name) new_obj = bpy.data.objects.new(name, mesh) - new_obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Ifc.link(item, new_obj.data) bpy.context.collection.objects.link(new_obj) props.add_item_object(new_obj, item) new_obj.matrix_world = obj.matrix_world @@ -2328,10 +2372,11 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): def enable_edit_mode(self, context): if tool.Blender.toggle_edit_mode(context) == {"CANCELLED"}: return {"CANCELLED"} - context.scene.BIMGeometryProperties.is_changing_mode = True - if context.scene.BIMGeometryProperties.mode != "EDIT": - context.scene.BIMGeometryProperties.mode = "EDIT" - context.scene.BIMGeometryProperties.is_changing_mode = False + props = tool.Geometry.get_geometry_props() + props.is_changing_mode = True + if props.mode != "EDIT": + props.mode = "EDIT" + props.is_changing_mode = False class FlipObject(bpy.types.Operator): @@ -2368,13 +2413,33 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): item.tags += "," item.tags += tag - if obj.data and hasattr(obj.data, "BIMMeshProperties"): - active_representation_id = obj.data.BIMMeshProperties.ifc_definition_id - element = tool.Ifc.get().by_id(active_representation_id) + if tool.Geometry.has_mesh_properties((data := obj.data)): + representation = tool.Geometry.get_data_representation(data) + assert representation + + # Shape aspects must be considered from the PartOfProductDefinitionShape level + element = tool.Ifc.get_entity(obj) + assert element + product_reps = [] + if element.is_a("IfcProduct"): + product_reps = [element.Representation] + if element_type := ifcopenshell.util.element.get_type(element): + product_reps.extend(element_type.RepresentationMaps or []) + elif element.is_a("IfcTypeProduct"): + product_reps = element.RepresentationMaps + item_aspect = {} + for product_rep in product_reps: + for aspect in product_rep.HasShapeAspects: + for aspect_rep in aspect.ShapeRepresentations: + if aspect_rep.ContextOfItems != representation.ContextOfItems: + continue + for item in aspect_rep.Items: + item_aspect[item] = aspect + # IfcShapeRepresentation or IfcTopologyRepresentation. - if not element.is_a("IfcShapeModel"): + if not representation.is_a("IfcShapeModel"): return - queue = list(element.Items) + queue = list(representation.Items) while queue: item = queue.pop() if item.is_a("IfcMappedItem"): @@ -2397,16 +2462,15 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): elif inverse.is_a("IfcPresentationLayerAssignment"): new.layer = inverse.Name or "Unnamed" new.layer_id = inverse.id() - elif inverse.is_a("IfcShapeRepresentation"): - if inverse.OfShapeAspect: - shape_aspect = inverse.OfShapeAspect[0] - new.shape_aspect = shape_aspect.Name - new.shape_aspect_id = shape_aspect.id() elif inverse.is_a("IfcIndexedTextureMap"): add_tag(new, "UV") elif inverse.is_a("IfcIndexedColourMap"): add_tag(new, "Colour") + if aspect := item_aspect.get(item, None): + new.shape_aspect = aspect.Name + new.shape_aspect_id = aspect.id() + # sort created items sorted_items = sorted(props.items[:], key=lambda i: (not i.shape_aspect, i.shape_aspect)) for i, item in enumerate(sorted_items[:-1]): # last item is sorted automatically @@ -2420,7 +2484,8 @@ class DisableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - obj.BIMGeometryProperties.is_editing = False + assert obj + tool.Geometry.get_object_geometry_props(obj).is_editing = False class RemoveRepresentationItem(bpy.types.Operator, tool.Ifc.Operator): @@ -2431,9 +2496,12 @@ class RemoveRepresentationItem(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - if context.scene.BIMGeometryProperties.representation_obj: + if tool.Geometry.get_geometry_props().representation_obj: return False # Artificial restriction for now to prevent removing when in item mode - if not (obj := tool.Geometry.get_active_or_representation_obj()) or len(obj.BIMGeometryProperties.items) <= 1: + if ( + not (obj := tool.Geometry.get_active_or_representation_obj()) + or len(tool.Geometry.get_object_geometry_props(obj).items) <= 1 + ): cls.poll_message_set( "Active object need to have more than 1 representation items to keep representation valid" ) @@ -2468,13 +2536,17 @@ class SelectRepresentationItem(bpy.types.Operator): def execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - item = tool.Ifc.get().by_id(obj.BIMGeometryProperties.active_item.ifc_definition_id) + obj_props = tool.Geometry.get_object_geometry_props(obj) + assert obj_props.active_item + item = tool.Ifc.get().by_id(obj_props.active_item.ifc_definition_id) item_ids = self.get_nested_item_ids(item) props = tool.Geometry.get_geometry_props() for item_obj in props.item_objs: - if item_obj.obj.data.BIMMeshProperties.ifc_definition_id in item_ids: - tool.Blender.select_object(item_obj.obj) + obj_ = item_obj.obj + props = tool.Geometry.get_mesh_props(obj_.data) + if props.ifc_definition_id in item_ids: + tool.Blender.select_object(obj_) return {"FINISHED"} def get_nested_item_ids(self, item): @@ -2492,7 +2564,7 @@ class SelectRepresentationItem(bpy.types.Operator): def poll_editing_representation_item_style(cls, context): if not (obj := tool.Geometry.get_active_or_representation_obj()): return False - props = obj.BIMGeometryProperties + props = tool.Geometry.get_object_geometry_props(obj) if not props.is_editing: return False if not (item := props.active_item): @@ -2527,7 +2599,8 @@ class EnableEditingRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - props = obj.BIMGeometryProperties + assert obj + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_style = True ifc_file = tool.Ifc.get() @@ -2546,7 +2619,8 @@ class EditRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - props = obj.BIMGeometryProperties + assert obj + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_style = False ifc_file = tool.Ifc.get() @@ -2568,7 +2642,7 @@ class DisableEditingRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operato def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - props = obj.BIMGeometryProperties + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_style = False @@ -2584,7 +2658,8 @@ class UnassignRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): active_obj = tool.Geometry.get_active_or_representation_obj() - active_props = active_obj.BIMGeometryProperties + assert active_obj + active_props = tool.Geometry.get_object_geometry_props(active_obj) active_props.is_editing_item_style = False # Get active representation item @@ -2651,7 +2726,8 @@ class EnableEditingRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Op def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - props = obj.BIMGeometryProperties + assert obj + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_shape_aspect = True # set dropdown to currently active shape aspect @@ -2667,11 +2743,13 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMGeometryProperties + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_shape_aspect = False ifc_file = tool.Ifc.get() + assert props.active_item representation_item_id = props.active_item.ifc_definition_id representation_item = ifc_file.by_id(representation_item_id) @@ -2724,7 +2802,8 @@ class DisableEditingRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.O def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() - props = obj.BIMGeometryProperties + assert obj + props = tool.Geometry.get_object_geometry_props(obj) props.is_editing_item_shape_aspect = False @@ -2735,10 +2814,12 @@ class RemoveRepresentationItemFromShapeAspect(bpy.types.Operator, tool.Ifc.Opera def _execute(self, context): obj = tool.Geometry.get_active_or_representation_obj() + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMGeometryProperties + props = tool.Geometry.get_object_geometry_props(obj) ifc_file = tool.Ifc.get() + assert props.active_item representation_item_id = props.active_item.ifc_definition_id representation_item = ifc_file.by_id(representation_item_id) shape_aspect = ifc_file.by_id(props.active_item.shape_aspect_id) @@ -2807,6 +2888,15 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): else: assert False, "Unexpected mesh type." + if not item_ids: + # It is possible that the user has created a shape that + # IfcOpenShell cannot render (i.e. boolean clipped everything), but + # we still want to edit items. I'm not sure the best way to handle + # this, but for now perhaps we can detect when there are no + # item_ids at all. + representation = tool.Ifc.get_entity(data) + item_ids = [i["item"].id() for i in ifcopenshell.util.representation.resolve_items(representation)] + queue = list(set(item_ids)) processed_ids = set() boolean_ids = set() @@ -2821,7 +2911,7 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): boolean_ids.add(item.SecondOperand.id()) continue item_mesh = bpy.data.meshes.new(f"Item/{item.is_a()}/{item_id}") - item_mesh.BIMMeshProperties.ifc_definition_id = item_id + tool.Ifc.link(item, item_mesh) item_obj = bpy.data.objects.new(f"Item/{item.is_a()}/{item_id}", item_mesh) item_obj.matrix_world = obj.matrix_world @@ -2851,7 +2941,7 @@ class UpdateItemAttributes(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object tool.Geometry.sync_item_positions() tool.Geometry.update_item_attributes(obj) - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(tool.Geometry.get_geometry_props().representation_obj) tool.Geometry.import_item(obj) tool.Root.reload_item_decorator() @@ -2868,6 +2958,10 @@ class NameProfile(bpy.types.Operator, tool.Ifc.Operator): options={"SKIP_SAVE"}, ) + if TYPE_CHECKING: + extrusion_item_obj: str + profile_name: str + def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self) @@ -2882,7 +2976,7 @@ class NameProfile(bpy.types.Operator, tool.Ifc.Operator): ifc_file = tool.Ifc.get() extrusion_item_obj = bpy.data.objects[self.extrusion_item_obj] - mesh_props = extrusion_item_obj.data.BIMMeshProperties + mesh_props = tool.Geometry.get_mesh_props(extrusion_item_obj.data) extrusion = ifc_file.by_id(mesh_props.ifc_definition_id) assert extrusion.is_a("IfcSweptAreaSolid") profile = extrusion.SweptArea @@ -2952,9 +3046,9 @@ class AddMeshlikeItem(bpy.types.Operator, tool.Ifc.Operator): props.add_item_object(obj, item) representation.Items = list(representation.Items) + [item] - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Geometry.get_mesh_props(obj.data).ifc_definition_id = item.id() tool.Root.reload_item_decorator() @@ -3000,10 +3094,10 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator): props.add_item_object(obj, item) representation.Items = list(representation.Items) + [item] - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Geometry.get_mesh_props(obj.data).ifc_definition_id = item.id() tool.Geometry.import_item(obj) tool.Geometry.import_item_attributes(obj) tool.Root.reload_item_decorator() @@ -3071,10 +3165,10 @@ class AddCurvelikeItem(bpy.types.Operator, tool.Ifc.Operator): props.add_item_object(obj, item) representation.Items = list(representation.Items) + [item] - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Geometry.get_mesh_props(obj.data).ifc_definition_id = item.id() tool.Geometry.import_item(obj) tool.Geometry.import_item_attributes(obj) @@ -3120,10 +3214,10 @@ class AddHalfSpaceSolidItem(bpy.types.Operator, tool.Ifc.Operator): representation = ifcopenshell.util.representation.resolve_representation(representation) representation.Items = list(representation.Items) + [item] - tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.reload_representation(props.representation_obj) obj.name = obj.data.name = f"Item/{item.is_a()}/{item.id()}" - obj.data.BIMMeshProperties.ifc_definition_id = item.id() + tool.Geometry.get_mesh_props(obj.data).ifc_definition_id = item.id() tool.Geometry.import_item(obj) # TODO refactor to core and not rely on selection @@ -3149,7 +3243,7 @@ class OverrideMove(bpy.types.Operator): def execute(self, context): # Deep magick from the dawn of time - if IfcStore.get_file(): + if tool.Ifc.get(): IfcStore.execute_ifc_operator(self, context) if self.new_active_obj: context.view_layer.objects.active = self.new_active_obj diff --git a/src/bonsai/bonsai/bim/module/geometry/prop.py b/src/bonsai/bonsai/bim/module/geometry/prop.py index 2ab4ec5efe..6e67aca973 100644 --- a/src/bonsai/bonsai/bim/module/geometry/prop.py +++ b/src/bonsai/bonsai/bim/module/geometry/prop.py @@ -32,7 +32,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) -from typing import Optional, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING, Union, Literal def get_contexts(self, context): @@ -121,7 +121,7 @@ def get_layers_no_active(self, context): return LayersData.data["layers_enum_no_active"] -def update_shape_aspect(self, context): +def update_shape_aspect(self: "BIMObjectGeometryProperties", context: bpy.types.Context) -> None: shape_aspect_id = self.representation_item_shape_aspect attrs = self.shape_aspect_attrs @@ -250,9 +250,8 @@ class BIMObjectGeometryProperties(PropertyGroup): representation_item_layer: EnumProperty(items=get_layers, name="Representation Item's Layer") @property - def active_item(self): - if 0 <= self.active_item_index < len(self.items): - return self.items[self.active_item_index] + def active_item(self) -> Union[RepresentationItem, None]: + return tool.Blender.get_active_uilist_element(self.items, self.active_item_index) if TYPE_CHECKING: contexts: str @@ -270,6 +269,9 @@ class BIMObjectGeometryProperties(PropertyGroup): representation_item_layer: str +GeometryMode = Literal["OBJECT", "ITEM", "EDIT"] + + class BIMGeometryProperties(PropertyGroup): # Revit workaround should_use_presentation_style_assignment: BoolProperty(name="Force Presentation Style Assignment", default=False) @@ -308,7 +310,7 @@ class BIMGeometryProperties(PropertyGroup): should_force_faceted_brep: bool should_force_triangulation: bool is_changing_mode: bool - mode: str + mode: GeometryMode representation_obj: Union[bpy.types.Object, None] item_objs: bpy.types.bpy_prop_collection_idprop[RepresentationItemObject] representation_from_object: Union[bpy.types.Object, None] diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index 01d0a8ae0e..b1fe7bea61 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -20,7 +20,6 @@ import bpy import bonsai.bim import bonsai.tool as tool from bpy.types import Panel, Menu, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import prop_with_search from bonsai.bim.module.geometry.data import ( RepresentationsData, @@ -53,9 +52,10 @@ def mode_menu(self, context): UIData.load() ifc_icon = f"{UIData.data['menu_icon_color_mode']}_ifc" row = self.layout.row(align=True) - if context.scene.BIMGeometryProperties.mode == "EDIT": + props = tool.Geometry.get_geometry_props() + if props.mode == "EDIT": row.operator("bim.override_mode_set_object", icon="CANCEL", text="Discard Changes").should_save = False - row.prop(context.scene.BIMGeometryProperties, "mode", text="", icon_value=bonsai.bim.icons[ifc_icon].icon_id) + row.prop(props, "mode", text="", icon_value=bonsai.bim.icons[ifc_icon].icon_id) def object_menu(self, context): @@ -334,18 +334,17 @@ class BIM_PT_connections(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - if not IfcStore.get_element(context.active_object.BIMObjectProperties.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(tool.Blender.get_ifc_definition_id(obj)): return False - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not ConnectionsData.is_loaded: ConnectionsData.load() layout = self.layout - props = context.active_object.BIMObjectProperties if not ConnectionsData.data["connections"] and not ConnectionsData.data["is_connection_realization"]: layout.label(text="No connections found") @@ -411,15 +410,18 @@ class BIM_PT_mesh(Panel): @classmethod def poll(cls, context): return ( - context.active_object is not None - and context.active_object.type == "MESH" - and hasattr(context.active_object.data, "BIMMeshProperties") - and context.active_object.data.BIMMeshProperties.ifc_definition_id + (obj := context.active_object) is not None + and (mesh := obj.data) + and isinstance(mesh, bpy.types.Mesh) + and tool.Geometry.get_mesh_props(mesh).ifc_definition_id ) def draw(self, context): - if not context.active_object.data: - return + obj = context.active_object + assert obj + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + row = self.layout.row() row.label(text="Advanced Users Only", icon="ERROR") @@ -427,7 +429,7 @@ class BIM_PT_mesh(Panel): row = layout.row() text = "Manually Save Representation" - if tool.Ifc.is_edited(context.active_object): + if tool.Ifc.is_edited(obj): text += "*" row.operator("bim.update_representation", text=text) @@ -454,8 +456,8 @@ class BIM_PT_mesh(Panel): op = row.operator("bim.update_representation", text="Convert To Arbitrary Extrusion With Voids") op.ifc_representation_class = "IfcExtrudedAreaSolid/IfcArbitraryProfileDefWithVoids" - if context.active_object and context.active_object.data: - mprops = context.active_object.data.BIMMeshProperties + if True: + mprops = tool.Geometry.get_mesh_props(mesh) row = layout.row() row.operator("bim.get_representation_ifc_parameters") for index, ifc_parameter in enumerate(mprops.ifc_parameters): @@ -477,12 +479,16 @@ class BIM_PT_placement(Panel): @classmethod def poll(cls, context): - return context.active_object and context.active_object.BIMObjectProperties.ifc_definition_id + return (obj := context.active_object) and tool.Blender.get_ifc_definition_id(obj) def draw(self, context): if not PlacementData.is_loaded: PlacementData.load() + obj = context.active_object + assert obj + props = tool.Blender.get_object_bim_props(obj) + if not PlacementData.data["has_placement"]: row = self.layout.row() row.label(text="No Object Placement Found") @@ -493,12 +499,12 @@ class BIM_PT_placement(Panel): row = self.layout.row() row.prop(context.active_object, "rotation_euler", text="Rotation") - if context.active_object.BIMObjectProperties.blender_offset_type != "NONE": + if props.blender_offset_type != "NONE": row = self.layout.row(align=True) row.label(text="Blender Offset", icon="TRACKING_REFINE_FORWARDS") - row.label(text=context.active_object.BIMObjectProperties.blender_offset_type) + row.label(text=props.blender_offset_type) - if context.active_object.BIMObjectProperties.blender_offset_type != "NOT_APPLICABLE": + if props.blender_offset_type != "NOT_APPLICABLE": row = self.layout.row(align=True) row.label(text=PlacementData.data["original_x"], icon="EMPTY_AXIS") row.label(text=PlacementData.data["original_y"]) @@ -571,10 +577,10 @@ class BIM_PT_workarounds(Panel): @classmethod def poll(cls, context): return ( - context.active_object is not None - and context.active_object.type == "MESH" - and hasattr(context.active_object.data, "BIMMeshProperties") - and context.active_object.data.BIMMeshProperties.ifc_definition_id + (obj := context.active_object) is not None + and (mesh := obj.data) + and isinstance(mesh, bpy.types.Mesh) + and tool.Geometry.get_mesh_props(mesh).ifc_definition_id ) def draw(self, context): @@ -588,7 +594,7 @@ class BIM_PT_workarounds(Panel): class BIM_UL_representation_items(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item(self, context, layout: bpy.types.UILayout, data, item, icon, active_data, active_propname): if item: icon = "MATERIAL" if item.surface_style else "MESH_UVSPHERE" row = layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/georeference/data.py b/src/bonsai/bonsai/bim/module/georeference/data.py index 314de226ff..b64ef55c39 100644 --- a/src/bonsai/bonsai/bim/module/georeference/data.py +++ b/src/bonsai/bonsai/bim/module/georeference/data.py @@ -167,7 +167,7 @@ class GeoreferenceData: result["rotation"] = str(round(ifcopenshell.util.geolocation.yaxis2angle(*wcs[:, 1][:2]), 3)) result["x"], result["y"], result["z"] = wcs[:, 3][:3] - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: blender_xyz = ifcopenshell.util.geolocation.enh2xyz( result["x"], @@ -193,7 +193,7 @@ class GeoreferenceData: @classmethod def local_origin(cls): - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if not props.has_blender_offset: return unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) diff --git a/src/bonsai/bonsai/bim/module/georeference/decorator.py b/src/bonsai/bonsai/bim/module/georeference/decorator.py index bd5a9b97e8..afd9dfba27 100644 --- a/src/bonsai/bonsai/bim/module/georeference/decorator.py +++ b/src/bonsai/bonsai/bim/module/georeference/decorator.py @@ -21,6 +21,7 @@ import blf import gpu import bmesh import ifcopenshell +import ifcopenshell.util.geolocation import bonsai.tool as tool from math import radians from bpy.types import SpaceView3D @@ -53,7 +54,8 @@ class GeoreferenceDecorator: cls.is_installed = False def draw_batch(self, shader_type, content_pos, color, indices=None): - self.scale = bpy.context.scene.BIMGeoreferenceProperties.visualization_scale + props = tool.Georeference.get_georeference_props() + self.scale = props.visualization_scale content_pos = [v * self.scale for v in content_pos] shader = self.line_shader if shader_type == "LINES" else self.shader batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) @@ -64,7 +66,7 @@ class GeoreferenceDecorator: if not GeoreferenceData.is_loaded: GeoreferenceData.load() - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if not props.model_origin: # If this is empty, no georeferencing data has been loaded. return @@ -177,7 +179,7 @@ class GeoreferenceDecorator: if not GeoreferenceData.is_loaded: GeoreferenceData.load() - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if not props.model_origin: # If this is empty, no georeferencing data has been loaded. return @@ -350,7 +352,7 @@ class GeoreferenceDecorator: self.gn_angle = float(GeoreferenceData.data["map_derived_angle"] or 0) self.tn_angle = float(GeoreferenceData.data["true_derived_angle"] or 0) - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: blender_angle = ifcopenshell.util.geolocation.xaxis2angle( float(props.blender_x_axis_abscissa), float(props.blender_x_axis_ordinate) diff --git a/src/bonsai/bonsai/bim/module/georeference/operator.py b/src/bonsai/bonsai/bim/module/georeference/operator.py index 94307113e0..7d8f98dd6c 100644 --- a/src/bonsai/bonsai/bim/module/georeference/operator.py +++ b/src/bonsai/bonsai/bim/module/georeference/operator.py @@ -20,6 +20,7 @@ import bpy import bonsai.tool as tool import bonsai.core.georeference as core +from bpy_extras.io_utils import ImportHelper from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator @@ -87,22 +88,18 @@ class GetCursorLocation(bpy.types.Operator, tool.Ifc.Operator): core.get_cursor_location(tool.Georeference) -class ImportPlot(bpy.types.Operator, tool.Ifc.Operator): +class ImportPlot(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_plot" bl_label = "Import Plot" bl_options = {"REGISTER", "UNDO"} - bl_description = "Import plot" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + bl_description = "Import plot from a csv file." filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) + filename_ext = ".csv" - def execute(self, context): + def _execute(self, context): core.import_plot(tool.Georeference, filepath=self.filepath) return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class EnableEditingWCS(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_wcs" diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index f0458b2045..58a3088b86 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -33,15 +33,18 @@ from bpy.props import ( ) from bonsai.bim.module.georeference.data import GeoreferenceData from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator +from typing import TYPE_CHECKING -def get_coordinate_operation_class(self, context): +def get_coordinate_operation_class( + self: "BIMGeoreferenceProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not GeoreferenceData.is_loaded: GeoreferenceData.load() return GeoreferenceData.data["coordinate_operation_class"] -def update_true_north_angle(self, context): +def update_true_north_angle(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: if self.is_changing_angle: return self.is_changing_angle = True @@ -54,7 +57,7 @@ def update_true_north_angle(self, context): self.is_changing_angle = False -def update_true_north_vector(self, context): +def update_true_north_vector(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: if self.is_changing_angle: return self.is_changing_angle = True @@ -67,7 +70,7 @@ def update_true_north_vector(self, context): self.is_changing_angle = False -def update_grid_north_angle(self, context): +def update_grid_north_angle(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: if self.is_changing_angle: return self.is_changing_angle = True @@ -81,7 +84,7 @@ def update_grid_north_angle(self, context): self.is_changing_angle = False -def update_grid_north_vector(self, context): +def update_grid_north_vector(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: if self.is_changing_angle: return self.is_changing_angle = True @@ -95,15 +98,15 @@ def update_grid_north_vector(self, context): self.is_changing_angle = False -def update_should_visualise(self, context): +def update_should_visualise(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: if self.should_visualise: GeoreferenceDecorator.install(bpy.context) else: GeoreferenceDecorator.uninstall() -def update_blender_coordinates(self, context): - props = bpy.context.scene.BIMGeoreferenceProperties +def update_blender_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: + props = self if props.is_updating_coordinates: return props.is_updating_coordinates = True @@ -123,8 +126,8 @@ def update_blender_coordinates(self, context): props.is_updating_coordinates = False -def update_local_coordinates(self, context): - props = bpy.context.scene.BIMGeoreferenceProperties +def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: + props = self if props.is_updating_coordinates: return props.is_updating_coordinates = True @@ -147,8 +150,8 @@ def update_local_coordinates(self, context): props.is_updating_coordinates = False -def update_map_coordinates(self, context): - props = bpy.context.scene.BIMGeoreferenceProperties +def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types.Context) -> None: + props = self if props.is_updating_coordinates: return props.is_updating_coordinates = True @@ -248,3 +251,45 @@ class BIMGeoreferenceProperties(PropertyGroup): wcs_y: StringProperty(name="WCS Y", default="0") wcs_z: StringProperty(name="WCS Z", default="0") wcs_rotation: StringProperty(name="WCS Rotation", default="0") + + if TYPE_CHECKING: + coordinate_operation_class: str + is_changing_angle: bool + is_editing: bool + is_editing_wcs: bool + is_editing_true_north: bool + coordinate_operation: bpy.types.bpy_prop_collection_idprop[Attribute] + projected_crs: bpy.types.bpy_prop_collection_idprop[Attribute] + is_updating_coordinates: bool + blender_coordinates: str + local_coordinates: str + map_coordinates: str + should_visualise: bool + visualization_scale: float + grid_north_angle: str + x_axis_abscissa: str + x_axis_ordinate: str + x_axis_is_null: bool + + host_model_origin: str + host_model_origin_si: str + host_model_project_north: str + + model_origin: str + model_origin_si: str + model_project_north: str + + has_blender_offset: bool + blender_offset_x: str + blender_offset_y: str + blender_offset_z: str + blender_x_axis_abscissa: str + blender_x_axis_ordinate: str + + true_north_angle: str + true_north_abscissa: str + true_north_ordinate: str + wcs_x: str + wcs_y: str + wcs_z: str + wcs_rotation: str diff --git a/src/bonsai/bonsai/bim/module/georeference/ui.py b/src/bonsai/bonsai/bim/module/georeference/ui.py index 8b2dfd43e0..3dfec3a9f5 100644 --- a/src/bonsai/bonsai/bim/module/georeference/ui.py +++ b/src/bonsai/bonsai/bim/module/georeference/ui.py @@ -32,7 +32,7 @@ class BIM_PT_gis(Panel): bl_parent_id = "BIM_PT_tab_geometry" def draw_header(self, context): - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() row = self.layout.row(align=True) icon = "HIDE_OFF" if props.should_visualise else "HIDE_ON" row.label(text="") # empty text occupies the left of the row @@ -43,7 +43,7 @@ class BIM_PT_gis(Panel): def draw(self, context): self.layout.use_property_split = True self.layout.use_property_decorate = False - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if not GeoreferenceData.is_loaded: GeoreferenceData.load() @@ -54,7 +54,7 @@ class BIM_PT_gis(Panel): self.draw_ui(context) def draw_editable_ui(self, context): - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() row = self.layout.row(align=True) row.label(text="Projected CRS", icon="WORLD") row.operator("bim.edit_georeferencing", icon="CHECKMARK", text="") @@ -87,7 +87,7 @@ class BIM_PT_gis(Panel): draw_attribute(attribute, self.layout.row()) def draw_ui(self, context): - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if tool.Ifc.get_schema() == "IFC2X3": row = self.layout.row() @@ -149,7 +149,7 @@ class BIM_PT_gis_true_north(Panel): if not GeoreferenceData.is_loaded: GeoreferenceData.load() - self.props = context.scene.BIMGeoreferenceProperties + self.props = tool.Georeference.get_georeference_props() if self.props.is_editing_true_north: self.draw_editable_ui(context) @@ -200,7 +200,7 @@ class BIM_PT_gis_blender(Panel): if not GeoreferenceData.is_loaded: GeoreferenceData.load() - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: row = self.layout.row() @@ -233,7 +233,7 @@ class BIM_PT_gis_wcs(Panel): if not GeoreferenceData.is_loaded: GeoreferenceData.load() - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.is_editing_wcs: self.draw_editable_ui(context) @@ -263,7 +263,7 @@ class BIM_PT_gis_wcs(Panel): row.operator("bim.enable_editing_wcs", icon="GREASEPENCIL", text="") def draw_editable_ui(self, context): - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() row = self.layout.row(align=True) row.label(text="World Coordinate System", icon="EMPTY_ARROWS") @@ -293,7 +293,7 @@ class BIM_PT_gis_calculator(Panel): if not GeoreferenceData.is_loaded: GeoreferenceData.load() - props = context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: row = self.layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index e991dcf4f1..6932ec6f78 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -22,7 +22,6 @@ import ifcopenshell.api.group import ifcopenshell.util.attribute import bonsai.bim.helper import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore import json @@ -36,9 +35,13 @@ class LoadGroups(bpy.types.Operator, tool.Ifc.Operator): self.expanded_groups = json.loads(context.scene.ExpandedGroups.json_string) self.props.groups.clear() - for group in tool.Ifc.get().by_type("IfcGroup", include_subtypes=False): - if not group.HasAssignments: - self.load_group(group) + groups = [ + group for group in tool.Ifc.get().by_type("IfcGroup", include_subtypes=False) if not group.HasAssignments + ] + sorted_groups = sorted(groups, key=lambda group: group.Name or "Unnamed") + + for group in sorted_groups: + self.load_group(group) self.props.is_editing = True bpy.ops.bim.disable_editing_group() @@ -52,14 +55,19 @@ class LoadGroups(bpy.types.Operator, tool.Ifc.Operator): new.has_children = False new.is_expanded = group.id() in self.expanded_groups - for rel in group.IsGroupedBy or []: - for related_object in rel.RelatedObjects: - if not related_object.is_a("IfcGroup"): - continue - new.has_children = True - if not new.is_expanded: - return - self.load_group(related_object, tree_depth=tree_depth + 1) + related_groups = [ + related_object + for rel in group.IsGroupedBy or [] + for related_object in rel.RelatedObjects + if related_object.is_a("IfcGroup") + ] + sorted_related_groups = sorted(related_groups, key=lambda group: group.Name or "Unnamed") + + for related_group in sorted_related_groups: + new.has_children = True + if not new.is_expanded: + return + self.load_group(related_group, tree_depth=tree_depth + 1) class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator): @@ -129,8 +137,7 @@ class RemoveGroup(bpy.types.Operator, tool.Ifc.Operator): group: bpy.props.IntProperty() def _execute(self, context): - props = context.scene.BIMGroupProperties - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run("group.remove_group", self.file, **{"group": self.file.by_id(self.group)}) bpy.ops.bim.load_groups() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/group/ui.py b/src/bonsai/bonsai/bim/module/group/ui.py index 88077f99eb..309c49d5d0 100644 --- a/src/bonsai/bonsai/bim/module/group/ui.py +++ b/src/bonsai/bonsai/bim/module/group/ui.py @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.module.group.data import GroupsData, ObjectGroupsData @@ -34,7 +34,7 @@ class BIM_PT_groups(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not GroupsData.is_loaded: @@ -91,9 +91,10 @@ class BIM_PT_object_groups(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - return IfcStore.get_file() and context.active_object.BIMObjectProperties.ifc_definition_id + props = tool.Blender.get_object_bim_props(obj) + return tool.Ifc.get() and props.ifc_definition_id def draw(self, context): if not ObjectGroupsData.is_loaded: diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index bbb7133122..de339c111c 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -374,16 +374,17 @@ class ObjectLog(bpy.types.Operator): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): cls.poll_message_set("No Active Object") - elif not context.active_object.BIMObjectProperties.ifc_definition_id: + elif not tool.Blender.get_ifc_definition_id(obj): cls.poll_message_set("Active Object doesn't have an IFC definition") else: return True def execute(self, context): - - step_id = context.active_object.BIMObjectProperties.ifc_definition_id + obj = context.active_object + assert obj + step_id = tool.Blender.get_ifc_definition_id(obj) core.entity_log(tool.IfcGit, tool.Ifc, step_id, self) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/layer/operator.py b/src/bonsai/bonsai/bim/module/layer/operator.py index 9c2f4a2249..5480c86034 100644 --- a/src/bonsai/bonsai/bim/module/layer/operator.py +++ b/src/bonsai/bonsai/bim/module/layer/operator.py @@ -24,7 +24,6 @@ import ifcopenshell.util.element import ifcopenshell.util.attribute import bonsai.bim.helper import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore class LoadLayers(bpy.types.Operator): @@ -33,7 +32,7 @@ class LoadLayers(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() props = context.scene.BIMLayerProperties props.layers.clear() for layer in tool.Ifc.get().by_type("IfcPresentationLayerAssignment"): @@ -126,7 +125,7 @@ class RemovePresentationLayer(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = context.scene.BIMLayerProperties - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run("layer.remove_layer", self.file, **{"layer": self.file.by_id(self.layer)}) bpy.ops.bim.load_layers() return {"FINISHED"} @@ -142,12 +141,12 @@ class AssignPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): item = bpy.data.meshes.get(self.item) if self.item else context.active_object.data - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "layer.assign_layer", self.file, **{ - "items": [self.file.by_id(item.BIMMeshProperties.ifc_definition_id)], + "items": [self.file.by_id(tool.Geometry.get_mesh_props(item).ifc_definition_id)], "layer": self.file.by_id(self.layer), }, ) @@ -164,15 +163,10 @@ class UnassignPresentationLayer(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): item = bpy.data.meshes.get(self.item) if self.item else context.active_object.data - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "layer.unassign_layer", - self.file, - **{ - "items": [self.file.by_id(item.BIMMeshProperties.ifc_definition_id)], - "layer": self.file.by_id(self.layer), - }, - ) + ifc_file = tool.Ifc.get() + representation = tool.Geometry.get_data_representation(item) + assert representation + ifcopenshell.api.layer.unassign_layer(ifc_file, items=[representation], layer=ifc_file.by_id(self.layer)) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/layer/ui.py b/src/bonsai/bonsai/bim/module/layer/ui.py index 8c3b77d30a..609cb62815 100644 --- a/src/bonsai/bonsai/bim/module/layer/ui.py +++ b/src/bonsai/bonsai/bim/module/layer/ui.py @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bpy.types import Panel, UIList, Mesh -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.module.layer.data import LayersData @@ -34,7 +34,7 @@ class BIM_PT_layers(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not LayersData.is_loaded: @@ -77,7 +77,6 @@ class BIM_UL_layers(UIList): row.label(text=item.name) if context.active_object and isinstance(context.active_object.data, Mesh): - mprops = context.active_object.data.BIMMeshProperties if item.ifc_definition_id in LayersData.data["active_layers"]: op = row.operator("bim.unassign_presentation_layer", text="", icon="KEYFRAME_HLT", emboss=False) op.layer = item.ifc_definition_id diff --git a/src/bonsai/bonsai/bim/module/library/data.py b/src/bonsai/bonsai/bim/module/library/data.py index 145dfbc83a..87ad7c23ae 100644 --- a/src/bonsai/bonsai/bim/module/library/data.py +++ b/src/bonsai/bonsai/bim/module/library/data.py @@ -47,7 +47,8 @@ class LibrariesData: @classmethod def library_attributes(cls): - library_id = bpy.context.scene.BIMLibraryProperties.active_library_id + props = tool.Library.get_library_props() + library_id = props.active_library_id if not library_id: return [] results = [] @@ -63,7 +64,7 @@ class LibrariesData: @classmethod def reference_attributes(cls): - props = bpy.context.scene.BIMLibraryProperties + props = tool.Library.get_library_props() try: reference_id = props.references[props.active_reference_index].ifc_definition_id except: diff --git a/src/bonsai/bonsai/bim/module/library/prop.py b/src/bonsai/bonsai/bim/module/library/prop.py index b7ddb74ed6..24defdf9ea 100644 --- a/src/bonsai/bonsai/bim/module/library/prop.py +++ b/src/bonsai/bonsai/bim/module/library/prop.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bonsai.bim.prop import StrProperty, Attribute from bonsai.bim.module.library.data import LibrariesData from bpy.types import PropertyGroup @@ -30,16 +31,31 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal def update_active_reference_index(self, context): LibrariesData.is_loaded = False +def update_library_element_name(self: "LibraryReference", context: bpy.types.Context): + ifc_file = tool.Ifc.get() + element = ifc_file.by_id(self.ifc_definition_id) + previous_name = element.Name + if self.name == previous_name: + return + element.Name = self.name + LibrariesData.is_loaded = False + + class LibraryReference(PropertyGroup): - name: StringProperty(name="Name") + name: StringProperty(name="Name", update=update_library_element_name) ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + name: str + ifc_definition_id: int + class BIMLibraryProperties(PropertyGroup): editing_mode: EnumProperty( @@ -58,3 +74,12 @@ class BIMLibraryProperties(PropertyGroup): active_reference_id: IntProperty(name="Active Reference Id") references: CollectionProperty(type=LibraryReference, name="References") active_reference_index: IntProperty(name="Active Reference Index", update=update_active_reference_index) + + if TYPE_CHECKING: + editing_mode: Literal["NONE", "LIBRARY", "REFERENCES", "REFERENCE"] + library_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + active_library_id: int + reference_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + active_reference_id: int + references: bpy.types.bpy_prop_collection_idprop[LibraryReference] + active_reference_index: int diff --git a/src/bonsai/bonsai/bim/module/library/ui.py b/src/bonsai/bonsai/bim/module/library/ui.py index 79797b9f54..bfc64e3020 100644 --- a/src/bonsai/bonsai/bim/module/library/ui.py +++ b/src/bonsai/bonsai/bim/module/library/ui.py @@ -16,10 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations +import bpy import bonsai.bim.helper import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.module.library.data import LibrariesData, LibraryReferencesData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.library.prop import BIMLibraryProperties, LibraryReference class BIM_PT_libraries(Panel): @@ -38,7 +44,7 @@ class BIM_PT_libraries(Panel): def draw(self, context): if not LibrariesData.is_loaded: LibrariesData.load() - self.props = context.scene.BIMLibraryProperties + self.props = tool.Library.get_library_props() if self.props.editing_mode == "LIBRARY": self.draw_editable_library_ui() @@ -110,7 +116,7 @@ class BIM_PT_library_references(Panel): def draw(self, context): if not LibraryReferencesData.is_loaded: LibraryReferencesData.load() - self.props = context.scene.BIMLibraryProperties + self.props = tool.Library.get_library_props() if self.props.editing_mode == "REFERENCES": self.layout.template_list( @@ -129,10 +135,12 @@ class BIM_PT_library_references(Panel): class BIM_UL_library_references(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, context, layout: bpy.types.UILayout, data, item: LibraryReference, icon, active_data, active_propname + ): if item: row = layout.row(align=True) - row.label(text=item.name) + row.prop(item, "name", text="", emboss=False) op = row.operator("bim.enable_editing_library_reference", text="", icon="GREASEPENCIL") op.reference = item.ifc_definition_id op = row.operator("bim.remove_library_reference", text="", icon="X") @@ -140,9 +148,11 @@ class BIM_UL_library_references(UIList): class BIM_UL_object_library_references(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, context, layout: bpy.types.UILayout, data, item: LibraryReference, icon, active_data, active_propname + ): if item: row = layout.row(align=True) - row.label(text=item.name) + row.prop(item, "name", text="", emboss=False) op = row.operator("bim.assign_library_reference", text="", icon="ADD") op.reference = item.ifc_definition_id diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index c8f249cefd..c7b243e367 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -71,8 +71,8 @@ def update_sun_path_size(self, context): update_sun_path(self) -def update_display_shadows(self, context): - if self.display_shadows: +def update_shadow_mode(self, context): + if self.shadow_mode == "SHADING": update_sun_path(self) context.scene.render.engine = "BLENDER_WORKBENCH" context.scene.display.shading.light = "FLAT" @@ -82,6 +82,20 @@ def update_display_shadows(self, context): context.scene.view_settings.view_transform = "Standard" # Preserve shading colours space = tool.Blender.get_view3d_space() space.shading.type = "RENDERED" + elif self.shadow_mode == "RENDERING": + if context.scene.sun_pos_properties.sun_object is None: + bpy.ops.object.light_add(type="SUN", radius=1, align="WORLD", location=(0, 0, 0), scale=(1, 1, 1)) + bpy.ops.object.move_to_collection(collection_index=0) + context.scene.sun_pos_properties.sun_object = bpy.context.active_object + update_sun_path(self) + context.scene.render.engine = "BLENDER_EEVEE_NEXT" + context.scene.display.shading.light = "FLAT" + context.scene.display.shading.show_shadows = True + context.scene.display.shading.show_object_outline = True + context.scene.display.shadow_focus = 1.0 + context.scene.view_settings.view_transform = "Standard" # Preserve shading colours + space = tool.Blender.get_view3d_space() + space.shading.type = "RENDERED" else: space = tool.Blender.get_view3d_space() space.shading.type = "SOLID" @@ -394,11 +408,27 @@ class BIMSolarProperties(PropertyGroup): azimuth: FloatProperty(name="Azimuth") elevation: FloatProperty(name="Elevation") UTC_zone: FloatProperty(name="UTC Zone") - display_shadows: BoolProperty( - name="Display Shadows", - default=False, - description="Enables a visual style to display shadows easily", - update=update_display_shadows, + shadow_mode: bpy.props.EnumProperty( + items=( + ("NONE", "No Shadows", "No shadows"), + ( + "SHADING", + "Shaded", + "Fast shadows sufficient for external shadow analysis based on shading styles", + "SHADING_SOLID", + 1, + ), + ( + "RENDERING", + "Rendered", + "Raycast (Eevee) shadows considering transparency based on rendering styles", + "SHADING_RENDERED", + 2, + ), + ), + name="Shadow Mode", + description="How to display shadows in the scene", + update=update_shadow_mode, ) display_sun_path: BoolProperty( name="Display Sun Path", diff --git a/src/bonsai/bonsai/bim/module/light/ui.py b/src/bonsai/bonsai/bim/module/light/ui.py index 37aa632372..8cbb637e06 100644 --- a/src/bonsai/bonsai/bim/module/light/ui.py +++ b/src/bonsai/bonsai/bim/module/light/ui.py @@ -236,8 +236,14 @@ class BIM_PT_solar(bpy.types.Panel): row.operator("bim.move_sun_path_to_3d_cursor") row = self.layout.row(align=True) - row.prop(props, "display_shadows", icon="SHADING_RENDERED") - row.prop(context.scene.display.shading, "shadow_intensity", text="Shadow Intensity") + row.prop(props, "shadow_mode", icon="SHADING_RENDERED", expand=True) + + if props.shadow_mode == "SHADING": + row = self.layout.row() + row.prop(context.scene.display.shading, "shadow_intensity", text="Shadow Intensity") + elif props.shadow_mode == "RENDERING": + row = self.layout.row() + row.prop(context.scene.sun_pos_properties.sun_object.data, "energy", text="Sun Intensity") row = self.layout.row(align=True) row.operator("bim.view_from_sun", icon="LIGHT_HEMI") diff --git a/src/bonsai/bonsai/bim/module/material/data.py b/src/bonsai/bonsai/bim/module/material/data.py index 95e68468c6..45c994dd6d 100644 --- a/src/bonsai/bonsai/bim/module/material/data.py +++ b/src/bonsai/bonsai/bim/module/material/data.py @@ -48,7 +48,8 @@ class MaterialsData: @classmethod def total_materials(cls): - return len(tool.Ifc.get().by_type(bpy.context.scene.BIMMaterialProperties.material_type)) + props = tool.Material.get_material_props() + return len(tool.Ifc.get().by_type(props.material_type)) @classmethod def material_types(cls): @@ -101,7 +102,7 @@ class MaterialsData: @classmethod def material_styles_data(cls) -> dict[int, list[dict[str, Any]]]: - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() material_styles_data: dict[int, list[dict[str, Any]]] = {} for material_item in props.materials: @@ -237,8 +238,10 @@ class ObjectMaterialData: return results @classmethod - def set_items(cls): + def set_items(cls) -> list[dict[str, Any]]: results = [] + if cls.material is None: + return results if cls.material: items = [] if cls.material.is_a("IfcMaterialLayerSetUsage"): @@ -273,6 +276,8 @@ class ObjectMaterialData: "icon": icon, "material_id": material_id, } + if item.is_a("IfcMaterialProfile"): + data["profile_id"] = item.Profile.id() if item.is_a("IfcMaterialProfile") and not item.Name: if item.Profile: data["name"] = item.Profile.ProfileName or "Unnamed" @@ -281,8 +286,9 @@ class ObjectMaterialData: if item.is_a("IfcMaterialLayer"): total_thickness = item.LayerThickness unit_system = bpy.context.scene.unit_settings.system + props = tool.Drawing.get_document_props() if unit_system == "IMPERIAL": - precision = bpy.context.scene.DocProperties.imperial_precision + precision = props.imperial_precision else: precision = None formatted_thickness = format_distance( @@ -294,6 +300,18 @@ class ObjectMaterialData: else: data["material"] = item.Material.Name or "Unnamed" results.append(data) + should_reverse = cls.material.is_a("IfcMaterialLayerSetUsage") and cls.material.DirectionSense == "POSITIVE" + last_i = len(results) - 1 + for i, result in enumerate(results): + result["index"] = i + if should_reverse: + result["index_up"] = i + 1 if i != last_i else None + result["index_down"] = i - 1 if i != 0 else None + else: + result["index_down"] = i + 1 if i != last_i else None + result["index_up"] = i - 1 if i != 0 else None + if should_reverse: + return list(reversed(results)) return results @classmethod @@ -304,7 +322,13 @@ class ObjectMaterialData: layers = cls.material.ForLayerSet.MaterialLayers elif cls.material.is_a("IfcMaterialLayerSet"): layers = cls.material.MaterialLayers - return sum([l.LayerThickness for l in layers or []]) + thickness = sum([l.LayerThickness for l in layers or []]) + props = tool.Drawing.get_document_props() + unit_system = bpy.context.scene.unit_settings.system + precision = None + if unit_system == "IMPERIAL": + precision = props.imperial_precision + return format_distance(thickness, precision=precision, suppress_zero_inches=True, in_unit_length=True) @classmethod def set_item_name(cls) -> Union[str, None]: diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index c6b92145b5..5562f4a3e3 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -19,6 +19,7 @@ import bpy import json import ifcopenshell.api +import ifcopenshell.api.material import ifcopenshell.util.element import ifcopenshell.util.attribute import ifcopenshell.util.representation @@ -28,7 +29,6 @@ import bonsai.tool as tool import bonsai.core.style import bonsai.core.material as core import bonsai.bim.module.model.profile as model_profile -from bonsai.bim.ifc import IfcStore from typing import Any, Union, TYPE_CHECKING from bonsai.bim.module.model import wall, slab @@ -40,7 +40,8 @@ class LoadMaterials(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - core.load_materials(tool.Material, context.scene.BIMMaterialProperties.material_type) + props = tool.Material.get_material_props() + core.load_materials(tool.Material, props.material_type) return {"FINISHED"} @@ -66,7 +67,10 @@ class SelectByMaterial(bpy.types.Operator): core.select_by_material(tool.Material, tool.Spatial, material=material) # copy selection query to clipboard - material_name = material.Name + if material.is_a("IfcMaterialLayerSet"): + material_name = material.LayerSetName + else: + material_name = material.Name result = f'material="{material_name}"' bpy.context.window_manager.clipboard = result self.report({"INFO"}, f"({result}) was copied to the clipboard.") @@ -116,7 +120,7 @@ class AssignParameterizedProfile(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() profile = ifcopenshell.api.run( "profile.add_parameterized_profile", self.file, @@ -258,7 +262,8 @@ class AssignMaterial(bpy.types.Operator, tool.Ifc.Operator): if not (material_type := properties.material_type): if not (obj := context.active_object): return "" - material_type = obj.BIMObjectMaterialProperties.material_type + omprops = tool.Material.get_object_material_props(obj) + material_type = omprops.material_type description = "Assign current IfcMaterial to the selected objects" if material_type != "IfcMaterial": @@ -290,14 +295,12 @@ class AddConstituent(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "material.add_constituent", + omprops = tool.Material.get_object_material_props(obj) + self.file = tool.Ifc.get() + ifcopenshell.api.material.add_constituent( self.file, - **{ - "constituent_set": self.file.by_id(self.constituent_set), - "material": self.file.by_id(int(obj.BIMObjectMaterialProperties.material)), - }, + constituent_set=self.file.by_id(self.constituent_set), + material=self.file.by_id(int(omprops.material)), ) @@ -326,13 +329,15 @@ class AddProfile(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "material.add_profile", + assert obj + self.file = tool.Ifc.get() + props = tool.Material.get_material_props() + omprops = tool.Material.get_object_material_props(obj) + ifcopenshell.api.material.add_profile( self.file, profile_set=self.file.by_id(self.profile_set), - material=self.file.by_id(int(obj.BIMObjectMaterialProperties.material)), - profile=self.file.by_id(int(context.scene.BIMMaterialProperties.profiles)), + material=self.file.by_id(int(omprops.material)), + profile=self.file.by_id(int(props.profiles)), ) @@ -362,11 +367,13 @@ class AddLayer(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + assert obj + omprops = tool.Material.get_object_material_props(obj) layer_set = tool.Ifc.get().by_id(self.layer_set) ifcopenshell.api.material.add_layer( tool.Ifc.get(), layer_set=layer_set, - material=tool.Ifc.get().by_id(int(obj.BIMObjectMaterialProperties.material)), + material=tool.Ifc.get().by_id(int(omprops.material)), ) slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set) wall.DumbWallPlaner().regenerate_from_layer_set(layer_set) @@ -375,7 +382,7 @@ class AddLayer(bpy.types.Operator, tool.Ifc.Operator): class ReorderMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.reorder_material_set_item" bl_label = "Reorder Material Set Item" - bl_description = "The List is Ordered From Origin Point" + bl_description = "Change the order of materials" bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() old_index: bpy.props.IntProperty() @@ -383,17 +390,12 @@ class ReorderMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): material_set: bpy.props.IntProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() - material_set = self.file.by_id(self.material_set) - ifcopenshell.api.run( - "material.reorder_set_item", + self.file = tool.Ifc.get() + ifcopenshell.api.material.reorder_set_item( self.file, - **{ - "material_set": material_set, - "old_index": self.old_index, - "new_index": self.new_index, - }, + material_set=self.file.by_id(self.material_set), + old_index=self.old_index, + new_index=self.new_index, ) @@ -440,12 +442,13 @@ class AddListItem(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "material.add_list_item", + assert obj + omprops = tool.Material.get_object_material_props(obj) + self.file = tool.Ifc.get() + ifcopenshell.api.material.add_list_item( self.file, material_list=self.file.by_id(self.list_item_set), - material=self.file.by_id(int(obj.BIMObjectMaterialProperties.material)), + material=self.file.by_id(int(omprops.material)), ) @@ -460,7 +463,7 @@ class RemoveListItem(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "material.remove_list_item", self.file, @@ -479,10 +482,12 @@ class EnableEditingAssignedMaterial(bpy.types.Operator): def execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - props = obj.BIMObjectMaterialProperties + assert obj + props = tool.Material.get_object_material_props(obj) props.is_editing = True element = tool.Ifc.get_entity(obj) material = ifcopenshell.util.element.get_material(element) + assert material if material.is_a("IfcMaterial"): props.material = str(material.id()) @@ -540,7 +545,8 @@ class DisableEditingAssignedMaterial(bpy.types.Operator): def execute(self, context): bpy.ops.bim.disable_editing_material_set_item() obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - props = obj.BIMObjectMaterialProperties + assert obj + props = tool.Material.get_object_material_props(obj) props.is_editing = False return {"FINISHED"} @@ -554,11 +560,14 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): material_set_usage: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() active_obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - props = active_obj.BIMObjectMaterialProperties + assert active_obj + props = tool.Material.get_object_material_props(active_obj) element = tool.Ifc.get_entity(active_obj) + assert element material = ifcopenshell.util.element.get_material(element) + assert material objects = tool.Blender.get_selected_objects() @@ -574,32 +583,40 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): material_set = self.file.by_id(self.material_set) attributes = bonsai.bim.helper.export_attributes(props.material_set_attributes) - ifcopenshell.api.run( - "material.edit_assigned_material", + ifcopenshell.api.material.edit_assigned_material( self.file, - **{"element": material_set, "attributes": attributes}, + element=material_set, + attributes=attributes, ) if self.material_set_usage: material_set_usage = self.file.by_id(self.material_set_usage) attributes = bonsai.bim.helper.export_attributes(props.material_set_usage_attributes) + if material_set_usage.is_a("IfcMaterialLayerSetUsage"): - ifcopenshell.api.run( - "material.edit_layer_usage", + ifcopenshell.api.material.edit_layer_usage( self.file, - **{"usage": material_set_usage, "attributes": attributes}, - ) - slab.DumbSlabPlaner().regenerate_from_layer_set(material_set_usage.ForLayerSet) - wall.DumbWallPlaner().regenerate_from_layer_set(material_set_usage.ForLayerSet) - elif material_set_usage.is_a("IfcMaterialProfileSetUsage"): - if attributes.get("CardinalPoint", None): - attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) - ifcopenshell.api.run( - "material.edit_profile_usage", - self.file, - **{"usage": material_set_usage, "attributes": attributes}, + usage=material_set_usage, + attributes=attributes, ) + layer_sets_to_regenerate = set() + + for obj in objects: + obj_element = tool.Ifc.get_entity(obj) + obj_material_usage = ifcopenshell.util.element.get_material(obj_element) + + if obj_material_usage and obj_material_usage.is_a("IfcMaterialLayerSetUsage"): + obj_material_usage.OffsetFromReferenceLine = material.OffsetFromReferenceLine + obj_material_usage.DirectionSense = material.DirectionSense + obj_material_usage.ReferenceExtent = material.ReferenceExtent + + layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet) + + for layer_set in layer_sets_to_regenerate: + wall.DumbWallPlaner().regenerate_from_layer_set(layer_set) + slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set) + bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name) @@ -612,7 +629,8 @@ class EnableEditingMaterialSetItemProfile(bpy.types.Operator): def execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.props = obj.BIMObjectMaterialProperties + assert obj + self.props = tool.Material.get_object_material_props(obj) self.props.active_material_set_item_id = self.material_set_item self.props.material_set_item_profile_attributes.clear() profile = tool.Ifc.get().by_id(self.material_set_item).Profile @@ -628,7 +646,8 @@ class DisableEditingMaterialSetItemProfile(bpy.types.Operator): def execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.props = obj.BIMObjectMaterialProperties + assert obj + self.props = tool.Material.get_object_material_props(obj) self.props.active_material_set_item_id = 0 self.props.material_set_item_profile_attributes.clear() return {"FINISHED"} @@ -643,7 +662,8 @@ class EditMaterialSetItemProfile(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.props = obj.BIMObjectMaterialProperties + assert obj + self.props = tool.Material.get_object_material_props(obj) attributes = bonsai.bim.helper.export_attributes(self.props.material_set_item_profile_attributes) profile = tool.Ifc.get().by_id(self.material_set_item).Profile ifcopenshell.api.run("profile.edit_profile", tool.Ifc.get(), profile=profile, attributes=attributes) @@ -660,13 +680,15 @@ class EnableEditingMaterialSetItem(bpy.types.Operator): material_set_item: bpy.props.IntProperty() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.mprops = context.scene.BIMMaterialProperties - self.props = obj.BIMObjectMaterialProperties + assert obj + self.mprops = tool.Material.get_material_props() + self.props = tool.Material.get_object_material_props(obj) self.props.active_material_set_item_id = self.material_set_item element = tool.Ifc.get_entity(obj) + assert element material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) material_set_item = self.file.by_id(self.material_set_item) @@ -690,7 +712,8 @@ class DisableEditingMaterialSetItem(bpy.types.Operator): def execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - props = obj.BIMObjectMaterialProperties + assert obj + props = tool.Material.get_object_material_props(obj) props.active_material_set_item_id = 0 return {"FINISHED"} @@ -704,11 +727,14 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - self.file = IfcStore.get_file() - props = obj.BIMObjectMaterialProperties - mprops = context.scene.BIMMaterialProperties + self.file = tool.Ifc.get() + assert obj + props = tool.Material.get_object_material_props(obj) + mprops = tool.Material.get_material_props() element = tool.Ifc.get_entity(obj) + assert element material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) + assert material attributes = bonsai.bim.helper.export_attributes(props.material_set_item_attributes) @@ -717,7 +743,7 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): self.file, constituent=self.file.by_id(self.material_set_item), attributes=attributes, - material=self.file.by_id(int(obj.BIMObjectMaterialProperties.material_set_item_material)), + material=self.file.by_id(int(props.material_set_item_material)), ) elif material.is_a("IfcMaterialLayerSet"): layer = self.file.by_id(self.material_set_item) @@ -725,7 +751,7 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): self.file, layer=layer, attributes=attributes, - material=self.file.by_id(int(obj.BIMObjectMaterialProperties.material_set_item_material)), + material=self.file.by_id(int(props.material_set_item_material)), ) slab.DumbSlabPlaner().regenerate_from_layer(layer) wall.DumbWallPlaner().regenerate_from_layer(layer) @@ -739,7 +765,7 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): profile=self.file.by_id(self.material_set_item), attributes=attributes, profile_def=profile_def, - material=self.file.by_id(int(obj.BIMObjectMaterialProperties.material_set_item_material)), + material=self.file.by_id(int(props.material_set_item_material)), ) else: pass @@ -763,7 +789,7 @@ class ExpandMaterialCategory(bpy.types.Operator): return self.execute(context) def execute(self, context): - props = context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() for index, category in ( (i, c) for i, c in enumerate(props.materials) @@ -792,7 +818,7 @@ class ContractMaterialCategory(bpy.types.Operator): return self.execute(context) def execute(self, context): - props = context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() for index, category in ( (i, c) for i, c in enumerate(props.materials) @@ -812,7 +838,7 @@ class EnableEditingMaterialStyle(bpy.types.Operator): material: bpy.props.IntProperty() def execute(self, context): - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() props.active_material_id = self.material props.editing_material_type = "STYLE" @@ -843,7 +869,7 @@ class EditMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() ifc_file = tool.Ifc.get() material = ifc_file.by_id(props.active_material_id) style = ifc_file.by_id(int(props.styles)) @@ -867,7 +893,7 @@ class UnassignMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): context: bpy.props.IntProperty() def _execute(self, context): - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() material = tool.Ifc.get().by_id(props.materials[props.active_material_index].ifc_definition_id) style = tool.Ifc.get().by_id(self.style) context = tool.Ifc.get().by_id(self.context) @@ -888,7 +914,7 @@ class SelectMaterialInMaterialsUI(bpy.types.Operator): material_id: int def execute(self, context): - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() ifc_file = tool.Ifc.get() material_id = self.material_id material = ifc_file.by_id(material_id) diff --git a/src/bonsai/bonsai/bim/module/material/prop.py b/src/bonsai/bonsai/bim/module/material/prop.py index aa680abe9b..04a596e73f 100644 --- a/src/bonsai/bonsai/bim/module/material/prop.py +++ b/src/bonsai/bonsai/bim/module/material/prop.py @@ -22,7 +22,6 @@ from ifcopenshell.util.doc import get_entity_doc import bonsai.tool as tool from bonsai.bim.module.material.data import MaterialsData, ObjectMaterialData from bonsai.bim.module.profile.data import ProfileData -from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup from bpy.props import ( @@ -121,7 +120,7 @@ def set_material_name(self: "Material", new_category_name: str) -> None: material.Category = new_category_name # Reload UI elements if necessary. - props: "BIMMaterialProperties" = self.id_data.BIMMaterialProperties + props = tool.Material.get_material_props() new_category_name_already_in_use = bool( next((m for m in props.materials if m.is_category and m.name == new_category_name), None) ) diff --git a/src/bonsai/bonsai/bim/module/material/ui.py b/src/bonsai/bonsai/bim/module/material/ui.py index c4c9eda9c4..1b49a4aeee 100644 --- a/src/bonsai/bonsai/bim/module/material/ui.py +++ b/src/bonsai/bonsai/bim/module/material/ui.py @@ -21,15 +21,13 @@ import bonsai.bim.helper import bonsai.tool as tool import bpy from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.helper import prop_with_search from bonsai.bim.module.material.data import MaterialsData, ObjectMaterialData -from bonsai.bim.module.drawing.helper import format_distance -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from bonsai.bim.module.material.prop import Material + from bonsai.bim.module.material.prop import Material, BIMMaterialProperties class BIM_PT_materials(Panel): @@ -43,13 +41,13 @@ class BIM_PT_materials(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not MaterialsData.is_loaded: MaterialsData.load() - self.props = context.scene.BIMMaterialProperties + self.props = tool.Material.get_material_props() material = tool.Material.get_active_material_item() material_id = material.ifc_definition_id if material else None @@ -138,14 +136,14 @@ class BIM_PT_object_material(Panel): def poll(cls, context): if not tool.Blender.is_tab(context, "GEOMETRY"): return False - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False - if not hasattr(IfcStore.get_file().by_id(props.ifc_definition_id), "HasAssociations"): + if not hasattr(tool.Ifc.get().by_id(ifc_id), "HasAssociations"): return False return True @@ -153,10 +151,12 @@ class BIM_PT_object_material(Panel): if not ObjectMaterialData.is_loaded: ObjectMaterialData.load() - self.file = IfcStore.get_file() - self.oprops = context.active_object.BIMObjectProperties - self.props = context.active_object.BIMObjectMaterialProperties - self.mprops = context.scene.BIMMaterialProperties + obj = context.active_object + assert obj + self.file = tool.Ifc.get() + self.oprops = tool.Blender.get_object_bim_props(obj) + self.props = tool.Material.get_object_material_props(obj) + self.mprops = tool.Material.get_material_props() if not ObjectMaterialData.data["materials"]: row = self.layout.row(align=True) @@ -253,7 +253,7 @@ class BIM_PT_object_material(Panel): active_object = bpy.context.active_object self.layerset_bounds(box, active_object, location="Top_Exterior") - for index, set_item in enumerate(ObjectMaterialData.data["set_items"]): + for set_item in ObjectMaterialData.data["set_items"]: if ( len(self.props.material_set_item_profile_attributes) and self.props.active_material_set_item_id == set_item["id"] @@ -262,9 +262,7 @@ class BIM_PT_object_material(Panel): elif self.props.active_material_set_item_id == set_item["id"]: self.draw_editable_set_item_ui(box, set_item) else: - self.draw_read_only_set_item_ui( - box, set_item, index, is_first=index == 0, is_last=index == total_items - 1 - ) + self.draw_read_only_set_item_ui(box, set_item) self.layerset_bounds(box, active_object, location="Bottom_Interior") @@ -291,7 +289,7 @@ class BIM_PT_object_material(Panel): row = box.row() prop_with_search(row, self.mprops, "profiles", icon="ITALIC", text="Profile") - def draw_read_only_set_item_ui(self, box, set_item, index, is_first=False, is_last=False): + def draw_read_only_set_item_ui(self, box: bpy.types.UILayout, set_item: dict[str, Any]) -> None: if ObjectMaterialData.data["material_class"] == "IfcMaterialList": row = box.row(align=True) row.label(text="IfcMaterial", icon="LAYER_ACTIVE") @@ -301,15 +299,15 @@ class BIM_PT_object_material(Panel): row.label(text=set_item["name"], icon=set_item["icon"]) row.label(text=set_item["material"], icon="MATERIAL") - if not is_first: - op = row.operator(f"bim.reorder_material_set_item", icon="TRIA_UP", text="") - op.old_index = index - op.new_index = index - 1 + if set_item["index_up"] is not None: + op = row.operator("bim.reorder_material_set_item", icon="TRIA_UP", text="") + op.old_index = set_item["index"] + op.new_index = set_item["index_up"] setattr(op, "material_set", ObjectMaterialData.data["set"]["id"]) - if not is_last: - op = row.operator(f"bim.reorder_material_set_item", icon="TRIA_DOWN", text="") - op.old_index = index - op.new_index = index + 1 + if set_item["index_down"] is not None: + op = row.operator("bim.reorder_material_set_item", icon="TRIA_DOWN", text="") + op.old_index = set_item["index"] + op.new_index = set_item["index_down"] setattr(op, "material_set", ObjectMaterialData.data["set"]["id"]) if ( not self.props.active_material_set_item_id @@ -317,7 +315,7 @@ class BIM_PT_object_material(Panel): ): if "Profile" in ObjectMaterialData.data["material_class"]: op = row.operator("bim.profiles_ui_select", icon="ZOOM_SELECTED", text="") - op.profile_id = set_item["id"] + op.profile_id = set_item["profile_id"] op = row.operator("bim.enable_editing_material_set_item_profile", icon="ITALIC", text="") op.material_set_item = set_item["id"] op = row.operator("bim.enable_editing_material_set_item", icon="GREASEPENCIL", text="") @@ -329,43 +327,34 @@ class BIM_PT_object_material(Panel): setattr(op, "list_item_set", ObjectMaterialData.data["set"]["id"]) setattr(op, ObjectMaterialData.data["set_item_name"], set_item["id"]) if hasattr(op, f"{ObjectMaterialData.data['set_item_name']}_index"): - setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", index) + setattr(op, f"{ObjectMaterialData.data['set_item_name']}_index", set_item["index"]) def draw_read_only_set_ui(self): if ObjectMaterialData.data["material_class"] != "IfcMaterialList": row = self.layout.row(align=True) set_name = ObjectMaterialData.data["set"]["name"] - row.label(text=f" Name: {set_name}") + row.label(text="Name") + row.label(text=set_name) - if ObjectMaterialData.data["set"]["description"]: - set_description = ObjectMaterialData.data["set"]["description"] + if value := ObjectMaterialData.data["set"]["description"]: row = self.layout.row(align=True) - row.label(text=f" Description: {set_description}") + row.label(text="Description") + row.label(text=value) if ObjectMaterialData.data["material_class"] == "IfcMaterialProfileSetUsage": - if ObjectMaterialData.data["set_usage"].get("cardinal_point"): - cardinal_point = ObjectMaterialData.data["set_usage"]["cardinal_point"] + if value := ObjectMaterialData.data["set_usage"].get("cardinal_point"): row = self.layout.row(align=True) - row.label(text=f" Cardinal Point: {cardinal_point}") + row.label(text="Cardinal Point") + row.label(text=value) if ObjectMaterialData.data["total_thickness"]: - total_thickness = ObjectMaterialData.data["total_thickness"] - unit_system = bpy.context.scene.unit_settings.system - - if unit_system == "IMPERIAL": - precision = bpy.context.scene.DocProperties.imperial_precision - else: - precision = None - formatted_thickness = format_distance( - total_thickness, precision=precision, suppress_zero_inches=True, in_unit_length=True - ) row = self.layout.row(align=True) - row.label(text=f" Total Thickness: {formatted_thickness}") + row.label(text="Total Thickness*") + row.label(text=ObjectMaterialData.data["total_thickness"]) - layout = self.layout - box = layout.box() + box = self.layout.box() active_object = bpy.context.active_object - self.layerset_bounds(box, active_object, location="Top_Exterior") + self.layerset_bounds(box, active_object, location="Top_Interior") for set_item in ObjectMaterialData.data["set_items"]: material_name = set_item["material"] @@ -381,30 +370,39 @@ class BIM_PT_object_material(Panel): op = row.operator("bim.select_by_material", text=material_name, emboss=False) op.material = material_id - self.layerset_bounds(box, active_object, location="Bottom_Interior") + self.layerset_bounds(box, active_object, location="Bottom_Exterior") - def layerset_bounds(self, box, obj, location="Top_Exterior"): + def layerset_bounds(self, layout, obj, location="Top_Interior"): set_usage = ObjectMaterialData.data.get("set_usage", {}) layer_set_direction = set_usage.get("layer_set_direction") if layer_set_direction: - if location == "Top_Exterior": + row = layout.row() + row.alignment = "CENTER" + row.enabled = False + if location == "Top_Interior": if layer_set_direction == "AXIS3": - box.label(text="----- Top -----") + row.label(text="Top") else: - box.label(text="----- Exterior -----") - if location == "Bottom_Interior": + row.label(text="Interior") + elif location == "Bottom_Exterior": if layer_set_direction == "AXIS3": - box.label(text="----- Bottom -----") + row.label(text="Bottom") else: - box.label(text="----- Interior -----") + row.label(text="Exterior") class BIM_UL_materials(UIList): def draw_item( - self, context, layout: bpy.types.UILayout, data, item: Material, icon, active_data, active_propname + self, + context, + layout: bpy.types.UILayout, + data: BIMMaterialProperties, + item: Material, + icon, + active_data, + active_propname, ) -> None: - mprops = context.scene.BIMMaterialProperties - material_type = mprops.material_type + material_type = data.material_type if item: row = layout.row(align=True) @@ -425,6 +423,8 @@ class BIM_UL_materials(UIList): row.prop(item, "name", text="", emboss=False) else: row.label(text="", icon="BLANK1") + if item.ifc_definition_id == data.active_material_id: + row.label(text="", icon="GREASEPENCIL") if material_type == "IfcMaterialList": row.label(text=item.name, icon="MATERIAL") else: diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py index 198408eeb5..5f4ddfa650 100644 --- a/src/bonsai/bonsai/bim/module/misc/operator.py +++ b/src/bonsai/bonsai/bim/module/misc/operator.py @@ -125,15 +125,25 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator): "Will unassign element from a type if type has a representation." ) bl_options = {"REGISTER", "UNDO"} + mode: bpy.props.StringProperty(default="BOOLEAN") @classmethod def poll(cls, context): - return context.selected_objects and tool.Ifc.get() + return context.selected_objects def _execute(self, context): cutter = context.active_object objs = [o for o in context.selected_objects if o != cutter] + if not tool.Ifc.get(): + if self.mode == "BOOLEAN": + tool.Misc.boolean_objects_with_cutter(objs, cutter) + elif self.mode == "BISECT": + tool.Misc.bisect_objects_with_cutter(objs, cutter) + for obj in objs: + bpy.data.objects.remove(obj) + return + objs_to_cut = [] # Splitting only works on meshes for obj in objs: @@ -169,7 +179,11 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator): objs_to_cut.append(obj) - new_objs = tool.Misc.split_objects_with_cutter(objs_to_cut, cutter) + if self.mode == "BOOLEAN": + new_objs = tool.Misc.boolean_objects_with_cutter(objs_to_cut, cutter) + elif self.mode == "BISECT": + new_objs = tool.Misc.bisect_objects_with_cutter(objs_to_cut, cutter) + for obj in new_objs: bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj) bpy.ops.bim.update_representation(obj=obj.name) @@ -257,7 +271,7 @@ class DrawSystemArrows(bpy.types.Operator, tool.Ifc.Operator): sources = [] for obj in bpy.context.selected_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + if not tool.Blender.get_ifc_definition_id(obj): continue element = tool.Ifc.get_entity(obj) @@ -299,7 +313,7 @@ class DrawSystemArrows(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.select_and_activate_single_object(context, curve) def get_absolute_matrix(self, matrix): - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: matrix = np.array( ifcopenshell.util.geolocation.global2local( diff --git a/src/bonsai/bonsai/bim/module/misc/ui.py b/src/bonsai/bonsai/bim/module/misc/ui.py index 7e33017619..c009b720e4 100644 --- a/src/bonsai/bonsai/bim/module/misc/ui.py +++ b/src/bonsai/bonsai/bim/module/misc/ui.py @@ -39,8 +39,9 @@ class BIM_PT_misc_utilities(bpy.types.Panel): row = layout.split(factor=0.2, align=True) row.prop(props, "total_storeys", text="") row.operator("bim.resize_to_storey").total_storeys = props.total_storeys - row = layout.row() - row.operator("bim.split_along_edge") + row = layout.row(align=True) + row.operator("bim.split_along_edge", text="Split Along Edge").mode = "BOOLEAN" + row.operator("bim.split_along_edge", text="Bisect At Faces").mode = "BISECT" row = layout.row() row.operator("bim.get_connected_system_elements") row = layout.row() diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index afd360d937..e3299578e2 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -55,23 +55,25 @@ classes = ( array.Input3DCursorXArray, array.Input3DCursorYArray, array.Input3DCursorZArray, - product.AddConstrTypeInstance, product.AddDefaultType, product.AddEmptyType, product.AddOccurrence, product.AlignProduct, product.ChangeTypePage, + product.DrawOccurrence, product.LoadTypeThumbnails, product.MirrorElements, product.SetActiveType, workspace.Hotkey, workspace.BIM_MT_add_representation_item, + wall.AddWallsFromSlab, wall.AlignWall, wall.ChangeExtrusionDepth, wall.ChangeExtrusionXAngle, wall.ChangeLayerLength, - wall.AddWallsFromSlab, wall.DrawPolylineWall, + wall.ExtendWallsToUnderside, + wall.ExtendWallsToWall, wall.FlipWall, wall.MergeWall, wall.RecalculateWall, diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 7e91db2804..0ec49d5561 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -72,7 +72,9 @@ class DisableEditingArray(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - context.active_object.BIMArrayProperties.is_editing = -1 + obj = context.active_object + assert obj + tool.Model.get_array_props(obj).is_editing = -1 return {"FINISHED"} @@ -84,8 +86,9 @@ class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMArrayProperties + props = tool.Model.get_array_props(obj) relating_obj = props.relating_array_object @@ -119,7 +122,7 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) - props = obj.BIMArrayProperties + props = tool.Model.get_array_props(obj) si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") @@ -182,7 +185,7 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) - props = obj.BIMArrayProperties + props = tool.Model.get_array_props(obj) pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") data = json.loads(pset["Data"]) @@ -302,7 +305,8 @@ class Input3DCursorXArray(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMArrayProperties + assert obj + props = tool.Model.get_array_props(obj) cursor = context.scene.cursor if props.use_local_space: props.x = (Matrix.inverted(obj.matrix_world) @ cursor.matrix.translation).x @@ -318,7 +322,8 @@ class Input3DCursorYArray(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMArrayProperties + assert obj + props = tool.Model.get_array_props(obj) cursor = context.scene.cursor if props.use_local_space: props.y = (Matrix.inverted(obj.matrix_world) @ cursor.matrix.translation).y @@ -334,7 +339,8 @@ class Input3DCursorZArray(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMArrayProperties + assert obj + props = tool.Model.get_array_props(obj) cursor = context.scene.cursor if props.use_local_space: props.z = (Matrix.inverted(obj.matrix_world) @ cursor.matrix.translation).z diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index cc713e5bd7..3af67e4990 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -26,7 +26,7 @@ from ifcopenshell.util.doc import get_entity_doc, get_predefined_type_doc import bonsai.tool as tool from math import degrees from natsort import natsorted -from typing import Union +from typing import Union, Optional, Any def refresh(): @@ -43,31 +43,25 @@ def refresh(): class AuthoringData: data = {} - type_thumbnails = {} + type_thumbnails: dict[int, int] = {} types_per_page = 9 - ifc_element_type = None is_loaded = False @classmethod - def load(cls, ifc_element_type=None): + def load(cls, ifc_element_type: Optional[str] = None): cls.is_loaded = True cls.props = tool.Model.get_model_props() - if ifc_element_type: - cls.ifc_element_type = None if ifc_element_type == "all" else ifc_element_type cls.data["default_container"] = cls.default_container() - cls.data["ifc_element_type"] = cls.ifc_element_type + cls.data["ifc_element_type"] = ifc_element_type cls.data["ifc_classes"] = cls.ifc_classes() cls.data["ifc_class_current"] = cls.ifc_class_current() # Make sure .ifc_classes() was run before next lines cls.data["type_elements"] = cls.type_elements() cls.data["type_elements_filtered"] = cls.type_elements_filtered() + # After .type_elements(). cls.data["relating_type_id"] = cls.relating_type_id() # Make sure .relating_type_id() was run before next lines - cls.data["relating_type_id_current"] = cls.relating_type_id_current() - cls.data["relating_type_name"] = cls.relating_type_name() - cls.data["relating_type_description"] = cls.relating_type_description() - cls.data["relating_type_material_usage"] = cls.relating_type_material_usage() - cls.data["predefined_type"] = cls.predefined_type() + cls.data["relating_type_data"] = cls.relating_type_data() # Make sure .type_elements_filtered() was run before next lines cls.data["total_types"] = cls.total_types() cls.data["total_pages"] = cls.total_pages() # Only after .total_types() @@ -75,7 +69,6 @@ class AuthoringData: cls.data["prev_page"] = cls.prev_page() cls.data["paginated_relating_types"] = cls.paginated_relating_types() - cls.data["type_thumbnail"] = cls.type_thumbnail() # Only after .relating_type_id_current() cls.data["materials"] = cls.materials() cls.data["is_voidable_element"] = cls.is_voidable_element() cls.data["has_visible_openings"] = cls.has_visible_openings() @@ -97,7 +90,7 @@ class AuthoringData: @classmethod def default_container(cls) -> str | None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() if props.default_container: try: return tool.Ifc.get().by_id(props.default_container).Name @@ -112,10 +105,6 @@ class AuthoringData: version = tool.Ifc.get_schema() return [(c, c, get_entity_doc(version, c).get("description", "")) for c in sorted(names)] - @classmethod - def type_thumbnail(cls): - return cls.type_thumbnails.get(int(cls.data["relating_type_id_current"] or 0), 0) - @classmethod def materials(cls): results = [("0", "None", "No material")] @@ -153,7 +142,7 @@ class AuthoringData: def type_elements_filtered(cls): search_query = cls.props.search_name.lower() - def filter_element(element): + def filter_element(element: ifcopenshell.entity_instance) -> bool: if search_query in (element.Name or "Unnamed").lower(): return True if search_query in (element.Description or "").lower(): @@ -173,21 +162,24 @@ class AuthoringData: elements = cls.data["type_elements_filtered"] elements = elements[(cls.props.type_page - 1) * cls.types_per_page : cls.props.type_page * cls.types_per_page] for element in elements: - predefined_type = ifcopenshell.util.element.get_predefined_type(element) - if predefined_type == "NOTDEFINED": - predefined_type = None - results.append( - { - "id": element.id(), - "ifc_class": element.is_a(), - "name": element.Name or "Unnamed", - "description": element.Description or "No Description", - "predefined_type": predefined_type, - "icon_id": cls.type_thumbnails.get(element.id(), None) or 0, - } - ) + results.append(cls.get_type_data(element)) return results + @classmethod + def get_type_data(cls, element: ifcopenshell.entity_instance) -> dict[str, Any]: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type == "NOTDEFINED": + predefined_type = None + data = { + "id": element.id(), + "ifc_class": element.is_a(), + "name": element.Name or "Unnamed", + "description": element.Description or "No Description", + "predefined_type": predefined_type, + "icon_id": cls.type_thumbnails.get(element.id(), 0), + } + return data + @classmethod def is_voidable_element(cls): if active_object := tool.Blender.get_active_object(): @@ -316,38 +308,16 @@ class AuthoringData: return [(str(e.id()), e.Name or "Unnamed", e.Description or "") for e in elements] @classmethod - def relating_type_id_current(cls): + def relating_type_data(cls) -> dict[str, Any]: relating_type_id = tool.Blender.get_enum_safe(cls.props, "relating_type_id") relating_type_id_data = cls.data["relating_type_id"] - if not relating_type_id and relating_type_id_data: - relating_type_id = relating_type_id_data[0][0] - return relating_type_id - - @classmethod - def relating_type_name(cls): - if relating_type_id := cls.data["relating_type_id_current"]: - return tool.Ifc.get().by_id(int(relating_type_id)).Name or "Unnamed" - - @classmethod - def relating_type_description(cls): - if relating_type_id := cls.data["relating_type_id_current"]: - return tool.Ifc.get().by_id(int(relating_type_id)).Description or "No description" - - @classmethod - def relating_type_material_usage(cls): - if relating_type_id := cls.data["relating_type_id_current"]: - return tool.Model.get_usage_type(tool.Ifc.get().by_id(int(relating_type_id))) - - @classmethod - def predefined_type(cls): - relating_type_id = tool.Blender.get_enum_safe(cls.props, "relating_type_id") if relating_type_id is None: - return - relating_type = tool.Ifc.get().by_id(int(relating_type_id)) - if not hasattr(relating_type, "PredefinedType"): - return - predefined_type = relating_type.PredefinedType - return predefined_type + if not relating_type_id_data: + return {} + relating_type_id = relating_type_id_data[0][0] + ifc_file = tool.Ifc.get() + relating_type = ifc_file.by_id(int(relating_type_id)) + return cls.get_type_data(relating_type) @classmethod def selected_material_usages(cls): @@ -415,7 +385,9 @@ class StairData: @classmethod def general_params(cls): - props = bpy.context.active_object.BIMStairProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_stair_props(obj) data = cls.data["pset_data"]["data_dict"] general_params = {} general_props = props.get_props_kwargs(stair_type=data["stair_type"]) @@ -481,7 +453,9 @@ class WindowData: @classmethod def general_params(cls): - props = bpy.context.active_object.BIMWindowProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_window_props(obj) data = cls.data["pset_data"]["data_dict"] general_params = {} general_props = props.get_general_kwargs() @@ -492,7 +466,9 @@ class WindowData: @classmethod def lining_params(cls): - props = bpy.context.active_object.BIMWindowProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_window_props(obj) data = cls.data["pset_data"]["data_dict"] lining_data = data["lining_properties"] lining_params = {} @@ -504,7 +480,9 @@ class WindowData: @classmethod def panel_params(cls): - props = bpy.context.active_object.BIMWindowProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_window_props(obj) panel_data = cls.data["pset_data"]["data_dict"]["panel_properties"] panel_params = {} panel_props = props.get_panel_kwargs() @@ -595,7 +573,9 @@ class RailingData: @classmethod def general_params(cls): - props = bpy.context.active_object.BIMRailingProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_railing_props(obj) data = cls.data["pset_data"]["data_dict"] general_params = {} general_props = props.get_general_kwargs(railing_type=data["railing_type"]) @@ -629,7 +609,9 @@ class RoofData: @classmethod def general_params(cls): - props = bpy.context.active_object.BIMRoofProperties + obj = bpy.context.active_object + assert obj + props = tool.Model.get_roof_props(obj) data = cls.data["pset_data"]["data_dict"] general_params = {} general_props = props.get_general_kwargs(generation_method=data["generation_method"]) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 126dad57ad..020b6e11ad 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -33,6 +33,7 @@ from gpu_extras.batch import batch_for_shader from gpu_extras.presets import draw_circle_2d from typing import Union from bonsai.bim.module.drawing.helper import format_distance +from itertools import chain def transparent_color(color, alpha=0.1): @@ -320,14 +321,19 @@ class PolylineDecorator: relating_type = None @classmethod - def install(cls, context): + def install(cls, context, ui_only=False): if cls.is_installed: cls.uninstall() handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_snap_point, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_measurements, (context,), "WINDOW", "POST_PIXEL")) cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_input_ui, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")) + if not ui_only: + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_snap_point, (context,), "WINDOW", "POST_PIXEL") + ) + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_measurements, (context,), "WINDOW", "POST_PIXEL") + ) + cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True @classmethod @@ -376,6 +382,7 @@ class PolylineDecorator: return (x_axis, y_axis, z_axis), (x_middle, y_middle, z_middle) + @classmethod def calculate_polygon(self, points): bm = bmesh.new() @@ -389,8 +396,8 @@ class PolylineDecorator: bm.verts.index_update() bm.edges.index_update() - verts = bm.verts - edges = bm.edges + verts = [v.co for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] bm.free() @@ -410,6 +417,7 @@ class PolylineDecorator: "X": "X coord: ", "Y": "Y coord: ", "Z": "Z coord:", + "AREA": "Area:", } try: mouse_pos = self.event.mouse_region_x, self.event.mouse_region_y @@ -425,11 +433,14 @@ class PolylineDecorator: color = self.addon_prefs.decorations_colour color_highlight = self.addon_prefs.decorator_color_special offset = 20 - new_line = 20 + new_line = 0 for i, (key, field_name) in enumerate(texts.items()): - formatted_value = None if self.input_ui: + # Controls which options are displayed in the UI + if key not in self.input_ui.input_options: + continue + new_line += 20 if self.tool_state and key != self.tool_state.input_type: formatted_value = self.input_ui.get_formatted_value(key) else: @@ -441,7 +452,7 @@ class PolylineDecorator: blf.color(self.font_id, *color_highlight) else: blf.color(self.font_id, *color) - blf.position(self.font_id, mouse_pos[0] + offset, mouse_pos[1] - (new_line * i), 0) + blf.position(self.font_id, mouse_pos[0] + offset, mouse_pos[1] - (new_line), 0) blf.draw(self.font_id, field_name + formatted_value) blf.disable(self.font_id, blf.SHADOW) @@ -637,7 +648,10 @@ class PolylineDecorator: mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))] # Plane Method or Default Container - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + if tool.Ifc.get(): + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + default_container_elevation = 0.0 projection_point = [] if not self.tool_state: pass @@ -695,6 +709,8 @@ class PolylineDecorator: self.draw_batch("LINES", mouse_point + projection_point, decorator_color_unselected, edges) if axis1 and axis2: + axis1 = [tuple(tool.Polyline.use_transform_orientations(Vector(v))) for v in axis1] + axis2 = [tuple(tool.Polyline.use_transform_orientations(Vector(v))) for v in axis2] self.line_shader.uniform_float("lineWidth", 1.5) self.draw_batch("LINES", axis1, highlight_color(axis_color1), [(0, 1)]) self.draw_batch("LINES", axis2, highlight_color(axis_color2), [(0, 1)]) @@ -961,3 +977,58 @@ class SlabDirectionDecorator: base = [obj.matrix_world @ Vector(d) for d in base] self.draw_batch("LINES", dir, selected_elements_color, [(0, 1), (1, 2), (1, 3)]) self.draw_batch("LINES", base, selected_elements_color, [(0, 1)]) + + +class FaceAreaDecorator: + is_installed = False + handlers = [] + + @classmethod + def install(cls, context): + if cls.is_installed: + cls.uninstall() + handler = cls() + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_face_area, (context,), "WINDOW", "POST_VIEW")) + cls.is_installed = True + + @classmethod + def uninstall(cls): + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.is_installed = False + + def draw_batch(self, shader_type, content_pos, color, indices=None): + shader = self.line_shader if shader_type == "LINES" else self.shader + batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + def draw_face_area(self, context): + def transparent_color(color, alpha=0.1): + color = [i for i in color] + color[3] = alpha + return color + + self.addon_prefs = tool.Blender.get_addon_preferences() + self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() # required to be able to change uniforms of the shader + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + self.line_shader.uniform_float("lineWidth", 2.0) + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + decorator_color = self.addon_prefs.decorator_color_special + + polyline_data = context.scene.BIMPolylineProperties.insertion_polyline + for i, polyline in enumerate(polyline_data): + vertices = [] + for point in polyline.polyline_points: + vertices.append((point.x, point.y, point.z)) + data = PolylineDecorator.calculate_polygon(vertices) + if data: + self.draw_batch("POINTS", data["verts"], decorator_color) + self.draw_batch("LINES", data["verts"], decorator_color, data["edges"]) + self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color, alpha=0.5), data["tris"]) diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index dbeb5f5dca..3021d47f92 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -21,6 +21,7 @@ import bpy import bmesh import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.material import ifcopenshell.util.element import ifcopenshell.util.representation import ifcopenshell.util.schema @@ -73,7 +74,7 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None: }, } - previously_active_context = tool.Geometry.get_active_representation_context(obj) + active_context = tool.Geometry.get_active_representation_context(obj) # ELEVATION_VIEW representation profile = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Profile", "ELEVATION_VIEW") @@ -91,47 +92,22 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None: representation_data["part_of_product"] = ifcopenshell.util.representation.get_part_of_product(element, body) model_representation = ifcopenshell.api.run("geometry.add_door_representation", ifc_file, **representation_data) representation_data["part_of_product"] = None - if fallback_material := (int(props.lining_material) or int(props.panel_material)): - lining_material = tool.Ifc.get().by_id(int(props.lining_material) or fallback_material) - panel_material = tool.Ifc.get().by_id(int(props.panel_material) or fallback_material) - should_create_new_material_set = False - if material := ifcopenshell.util.element.get_material(element): - if ( - material.is_a("IfcMaterialConstituent") - and len(names := [c.Name for c in material.MaterialConstituents]) == 2 - and set(names) == {"Lining", "Framing"} - ): - should_create_new_material_set = False - else: - should_create_new_material_set = True - ifcopenshell.api.material.unassign_material(ifc_file, products=[element]) - if not material.is_a("IfcMaterial") and not ifc_file.get_total_inverses(material): - ifcopenshell.api.material.remove_material_set(ifc_file, material=material) - else: - should_create_new_material_set = True - if should_create_new_material_set: - material_set = ifcopenshell.api.material.add_material_set(ifc_file, set_type="IfcMaterialConstituentSet") - ifcopenshell.api.material.add_constituent( - ifc_file, constituent_set=material_set, material=lining_material, name="Lining" - ) - ifcopenshell.api.material.add_constituent( - ifc_file, constituent_set=material_set, material=panel_material, name="Framing" - ) - ifcopenshell.api.material.assign_material(ifc_file, products=[element], material=material_set) - - styles = { - "Lining": ifcopenshell.util.representation.get_material_style(lining_material, body), - "Framing": ifcopenshell.util.representation.get_material_style(panel_material, body), - } - for item in model_representation.Items: - if aspect := ifcopenshell.util.representation.get_item_shape_aspect(model_representation, item): - if style := styles.get(aspect.Name, None): - ifcopenshell.api.style.assign_item_style(ifc_file, item=item, style=style) + tool.Model.replace_object_ifc_representation(body, obj, model_representation) + if fallback_material := (int(props.lining_material) or int(props.framing_material) or int(props.glazing_material)): + ifcopenshell.api.material.set_shape_aspect_constituents( + ifc_file, + element=element, + context=body, + materials={ + "Lining": tool.Ifc.get().by_id(int(props.lining_material) or fallback_material), + "Framing": tool.Ifc.get().by_id(int(props.framing_material) or fallback_material), + "Glazing": tool.Ifc.get().by_id(int(props.glazing_material) or fallback_material), + }, + ) elif material := ifcopenshell.util.element.get_material(element): ifcopenshell.api.material.unassign_material(ifc_file, products=[element]) if not material.is_a("IfcMaterial") and not ifc_file.get_total_inverses(material): ifcopenshell.api.material.remove_material_set(ifc_file, material=material) - tool.Model.replace_object_ifc_representation(body, obj, model_representation) # Body/PLAN_VIEW representation plan_body = ifcopenshell.util.representation.get_context(ifc_file, "Plan", "Body", "PLAN_VIEW") @@ -163,38 +139,15 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None: ) tool.Model.replace_object_ifc_representation(plan_annotation, obj, plan_representation) - # adding switch representation at the end instead of changing order of representations - # to prevent #2744 - if tool.Geometry.get_active_representation_context(obj) != previously_active_context: - previously_active_representation = ifcopenshell.util.representation.get_representation( - element, - previously_active_context.ContextType, - previously_active_context.ContextIdentifier, - previously_active_context.TargetView, - ) - - if not previously_active_representation: - # we assume there is no representation because it was - # Plan/Annotation/PLAN_VIEW - previously_active_context = ifcopenshell.util.representation.get_context( - ifc_file, "Plan", "Body", "PLAN_VIEW" - ) - previously_active_representation = ifcopenshell.util.representation.get_representation( - element, - previously_active_context.ContextType, - previously_active_context.ContextIdentifier, - previously_active_context.TargetView, - ) - - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=previously_active_representation, - should_reload=True, - is_global=True, - should_sync_changes_first=True, - ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=ifcopenshell.util.representation.get_representation(element, active_context), + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) # type attributes if tool.Ifc.get_schema() != "IFC2X3": @@ -688,6 +641,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data")) data.update(data.pop("lining_properties")) data.update(data.pop("panel_properties")) + data.update(tool.Model.get_constituents_props_data(element)) # required since we could load pset from .ifc and BIMDoorProperties won't be set props.set_props_kwargs_from_ifc_data(data) diff --git a/src/bonsai/bonsai/bim/module/model/grid.py b/src/bonsai/bonsai/bim/module/model/grid.py index 2d4cdfa501..0411f2875d 100644 --- a/src/bonsai/bonsai/bim/module/model/grid.py +++ b/src/bonsai/bonsai/bim/module/model/grid.py @@ -50,7 +50,7 @@ def add_object(self, context): "grid.create_grid_axis", tool.Ifc.get(), axis_tag=tag, uvw_axes="UAxes", grid=grid ) tool.Ifc.link(result, obj) - ifcopenshell.api.run("grid.create_axis_curve", tool.Ifc.get(), axis_curve=obj, grid_axis=result) + tool.Model.create_axis_curve(obj, result) tool.Collector.assign(obj) for i in range(0, self.total_v): @@ -69,7 +69,7 @@ def add_object(self, context): "grid.create_grid_axis", tool.Ifc.get(), axis_tag=tag, uvw_axes="VAxes", grid=grid ) tool.Ifc.link(result, obj) - ifcopenshell.api.run("grid.create_axis_curve", tool.Ifc.get(), axis_curve=obj, grid_axis=result) + tool.Model.create_axis_curve(obj, result) tool.Collector.assign(obj) tool.Root.reload_grid_decorator() diff --git a/src/bonsai/bonsai/bim/module/model/handler.py b/src/bonsai/bonsai/bim/module/model/handler.py index 2b30a1238c..b69b595c34 100644 --- a/src/bonsai/bonsai/bim/module/model/handler.py +++ b/src/bonsai/bonsai/bim/module/model/handler.py @@ -20,7 +20,6 @@ import bpy import ifcopenshell import ifcopenshell.api from bonsai.bim.module.model import product, wall, slab, profile, opening, task -from bonsai.bim.ifc import IfcStore from bpy.app.handlers import persistent diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index f4c00fb53b..bbbf11a021 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -580,7 +580,7 @@ class MEPGenerator: profile_joiner = DumbProfileJoiner() # create obstruction occurrence and setup it's length and port # NOTE: at this point we loose current blender objects selection - bpy.ops.bim.add_constr_type_instance(relating_type_id=obstruction_type.id()) + bpy.ops.bim.add_occurrence(relating_type_id=obstruction_type.id()) obstruction_obj = bpy.context.active_object obstruction_obj.matrix_world = segment_matrix @@ -859,7 +859,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): # NOTE: at this point we loose current blender objects selection # create transition element - bpy.ops.bim.add_constr_type_instance(relating_type_id=transition_type.id()) + bpy.ops.bim.add_occurrence(relating_type_id=transition_type.id()) transition_obj = bpy.context.active_object # adjust transition segment rotation and location @@ -921,11 +921,11 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): start_element = tool.Ifc.get_entity(start_object) end_element = tool.Ifc.get_entity(end_object) if not start_element or not end_element: - self.report({"ERROR"}, f"Two IFC elements should be selected for the bend.") + self.report({"ERROR"}, "Two IFC elements should be selected for the bend.") return {"CANCELLED"} else: - self.report({"ERROR"}, f"Two IFC elements should be provided for the bend.") + self.report({"ERROR"}, "Two IFC elements should be provided for the bend.") return {"CANCELLED"} # check rotation difference @@ -1210,7 +1210,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): # NOTE: at this point we loose current blender objects selection # create transition element - bpy.ops.bim.add_constr_type_instance(relating_type_id=bend_type.id()) + bpy.ops.bim.add_occurrence(relating_type_id=bend_type.id()) fitting_obj = bpy.context.active_object # adjust fitting object rotation and location @@ -1248,10 +1248,14 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): return matrix fitting_obj.matrix_world = get_fitting_matrix() + tool.Model.sync_object_ifc_position(fitting_obj) # add ports and connect them ports = tool.System.get_ports(tool.Ifc.get_entity(fitting_obj)) - if not start_port_match: + start_co = ifcopenshell.util.placement.get_local_placement(start_port.ObjectPlacement)[:, 3] + port0_co = ifcopenshell.util.placement.get_local_placement(ports[0].ObjectPlacement)[:, 3] + # We cannot use start_port_match because tool.System.get_ports is unordered + if not np.allclose(start_co, port0_co): start_port, end_port = end_port, start_port tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED") tool.Ifc.run("system.connect_port", port1=ports[1], port2=end_port, direction="NOTDEFINED") diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 0e73e71a91..5cceda3bce 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -38,7 +38,6 @@ import bonsai.tool as tool import bonsai.core.geometry import bonsai.bim.import_ifc as import_ifc from collections import defaultdict -from bonsai.bim.ifc import IfcStore from math import pi, radians from mathutils import Vector, Matrix from bpy.types import Operator @@ -236,9 +235,9 @@ class FilledOpeningGenerator: voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element] for voided_element in voided_elements: voided_obj = tool.Ifc.get_object(voided_element) - if not voided_obj.data: + representation = tool.Geometry.get_active_representation(voided_obj) + if not representation: continue - representation = tool.Ifc.get().by_id(voided_obj.data.BIMMeshProperties.ifc_definition_id) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -465,10 +464,13 @@ class AddBoolean(Operator, tool.Ifc.Operator): self.report({"INFO"}, "At least two representation items must be selected to add a boolean.") return {"CANCELLED"} - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() - first_item = tool.Ifc.get().by_id(first_obj.data.BIMMeshProperties.ifc_definition_id) - second_items = [tool.Ifc.get().by_id(o.data.BIMMeshProperties.ifc_definition_id) for o in second_objs] + first_item = tool.Geometry.get_active_representation(first_obj) + assert first_item + second_items = [ + representation for o in second_objs if (representation := tool.Geometry.get_active_representation(o)) + ] booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator) rep_obj = tool.Geometry.get_geometry_props().representation_obj @@ -664,6 +666,7 @@ class EditOpenings(Operator, tool.Ifc.Operator): def edit_openings( self, building_objs: set[bpy.types.Object], opening_elements: set[ifcopenshell.entity_instance] ) -> None: + props = tool.Geometry.get_geometry_props() objects_to_remove: set[bpy.types.Object] = set() for opening_element in opening_elements: opening_obj = tool.Ifc.get_object(opening_element) @@ -690,8 +693,8 @@ class EditOpenings(Operator, tool.Ifc.Operator): self.get_all_building_objects_of_similar_openings(opening_element) ) # NB this has nothing to do with clone similar_opening tool.Ifc.unlink(element=opening_element) - if bpy.context.scene.BIMGeometryProperties.representation_obj == opening_obj: - bpy.context.scene.BIMGeometryProperties.representation_obj = None + if props.representation_obj == opening_obj: + props.representation_obj = None objects_to_remove.add(opening_obj) tool.Blender.remove_data_blocks(objects_to_remove, remove_unused_data=True) @@ -776,11 +779,15 @@ class PurgeUnusedOpenings(Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - return any( - [tool.Geometry.has_openings(element)] + poll = any( + tool.Geometry.has_openings(element) for element in [tool.Ifc.get_entity(obj) for obj in context.selected_objects] if element ) + if not poll: + cls.poll_message_set("No objects with openings selected.") + return False + return True def _execute(self, context): bpy.ops.bim.show_openings() @@ -817,11 +824,11 @@ class RemoveBoolean(Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() return props.active_boolean def _execute(self, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() ifcopenshell.api.geometry.remove_boolean( tool.Ifc.get(), tool.Ifc.get().by_id(props.active_boolean.ifc_definition_id) ) @@ -841,7 +848,7 @@ class SelectBoolean(Operator): @classmethod def poll(cls, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() return props.active_boolean def invoke(self, context, event): @@ -850,9 +857,9 @@ class SelectBoolean(Operator): return self.execute(context) def execute(self, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() queue = [tool.Ifc.get().by_id(props.active_boolean.ifc_definition_id)] - items = {i.ifc_definition_id: i.obj for i in context.scene.BIMGeometryProperties.item_objs} + items = {i.ifc_definition_id: i.obj for i in tool.Geometry.get_geometry_props().item_objs} while queue: item = queue.pop() if item.is_a("IfcBooleanResult"): @@ -911,11 +918,12 @@ class DecorationsHandler: gpu.state.point_size_set(6) gpu.state.blend_set("ALPHA") + gprops = tool.Geometry.get_geometry_props() for opening in props.openings: obj = opening.obj - if context.scene.BIMGeometryProperties.representation_obj == obj: + if gprops.representation_obj == obj: # We are editing the representation of the opening : - for item in context.scene.BIMGeometryProperties.item_objs: + for item in gprops.item_objs: if item.obj.mode == "EDIT": obj = item.obj break diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index 435e05659a..a4cd9cc22f 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -35,7 +35,6 @@ import bonsai.core.root import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from math import pi, sin, cos, degrees, tan, radians from mathutils import Vector, Matrix, Quaternion from bonsai.bim.module.model.opening import FilledOpeningGenerator @@ -86,6 +85,7 @@ def get_wall_preview_data(context, relating_type): if x_angle > radians(90) or x_angle < radians(-90): height *= -1 angle_distance = height * tan(x_angle) + thickness *= 1 / cos(x_angle) data = {} data["verts"] = [] @@ -201,12 +201,12 @@ def get_slab_preview_data(context, relating_type): layers = tool.Model.get_material_layer_parameters(relating_type) if not layers["thickness"]: return - thickness = layers["thickness"] + thickness = layers["thickness"] * abs(1 / cos(x_angle)) thickness *= direction offset_type = model_props.offset_type_horizontal unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - offset = model_props.offset * unit_scale + offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale data = {} data["verts"] = [] @@ -238,7 +238,10 @@ def get_slab_preview_data(context, relating_type): bm = create_bmesh_from_vertices(polyline_vertices, is_closed) bm.verts.ensure_lookup_table() if x_angle: - bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=Matrix.Rotation(x_angle, 3, "X")) + rot_mat = Matrix.Rotation(x_angle, 3, "X") + if abs(x_angle) > (pi / 2): + rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0)) + bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat) new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] @@ -430,71 +433,90 @@ def get_horizontal_profile_preview_data(context, relating_type): case "9": grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - # Create profile curve - scale_mat = Matrix.Scale(-1, 4, (1.0, 0.0, 0.0)) - grouped_verts = [scale_mat @ Vector(v) for v in grouped_verts] - profile_curve = bpy.data.curves.new("Profile", type="CURVE") - profile_curve.dimensions = "2D" - profile_curve.splines.new("POLY") - profile_curve.splines[0].points.add(len(grouped_verts)) - - for i, point in enumerate(profile_curve.splines[0].points): - if i == len(grouped_verts): # Close curve - point.co = Vector((*grouped_verts[0], 0)) - continue - point.co = Vector((*grouped_verts[i], 0)) - profile_obj = bpy.data.objects.new("Profile", profile_curve) - - # Create path curve with profile object as bevel - path_curve = bpy.data.curves.new("Polyline", type="CURVE") - path_curve.dimensions = "2D" - path_curve.splines.new("POLY") - path_curve.splines[0].points.add(len(polyline_verts) - 1) - for i, point in enumerate(path_curve.splines[0].points): - point.co = Vector((*polyline_verts[i], 0)) - path_curve.splines[0].use_smooth = False - path_curve.bevel_mode = "OBJECT" - path_curve.bevel_object = profile_obj - - # Convert path curve to mesh - # This operation throws a warning when done during gpu drawing, so it was removed from the decorator file to be handled here - path_obj = bpy.data.objects.new("Preview", path_curve) - context.scene.collection.objects.link(path_obj) - bpy.context.view_layer.objects.active = path_obj - dg = context.evaluated_depsgraph_get() - path_obj = path_obj.evaluated_get(dg) - me = path_obj.to_mesh() - - # Create bmesh from path mesh - bm = bmesh.new() - new_verts = [bm.verts.new(v.co) for v in me.vertices] - index = [[v for v in edge.vertices] for edge in me.edges] - new_edges = [bm.edges.new((new_verts[i[0]], new_verts[i[1]])) for i in index] - for face in me.polygons: - verts = [new_verts[i] for i in face.vertices] - bm.faces.new(verts) - bm.verts.index_update() - bm.edges.index_update() - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - - bpy.data.objects.remove(bpy.data.objects[path_obj.name], do_unlink=True) - bpy.data.objects.remove(bpy.data.objects[profile_obj.name], do_unlink=True) - try: - bpy.data.curves.remove(profile_obj.data, do_unlink=True) - except: - pass - try: - bpy.data.curves.remove(path_obj.data, do_unlink=True) - except: - pass - data = {} - data["verts"] = [tuple(v.co) for v in bm.verts] - data["edges"] = [(edge.verts[0].index, edge.verts[1].index) for edge in bm.edges] + data["verts"] = [] + data["edges"] = [] + data["tris"] = [] + + grouped_verts = [(v) for v in grouped_verts] + + all_bm = bmesh.new() + for i in range(len(polyline_verts) - 1): + mesh = bpy.data.meshes.new("TempMesh") + # Create the initial mesh from the profile verts + bm = create_bmesh_from_vertices(grouped_verts, is_closed=True) + bm.verts.ensure_lookup_table() + # Creates the clipping plane formed by two segments. + # The first one is for the profile start, based on the current and previous segment of the polyline. + # The second is for the profile end, based on the current and the next segment. + if i == 0: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_start = d + else: + d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized() + d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized() + clip_start = (d1 - d2).normalized() + + if i == len(polyline_verts) - 2: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_end = d + else: + d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized() + clip_end = (d1 - d2).normalized() + + # Rotates the profile face to the right direction + direction = polyline_verts[i + 1] - polyline_verts[i] + position = polyline_verts[i] + rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4() + bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix) + bmesh.ops.translate(bm, verts=bm.verts, vec=position) + bmesh.ops.translate(bm, verts=bm.verts, vec=-direction) + + # Extrude and move the new face + last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) + new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3) + # Apply the cutting planes + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i], + plane_no=clip_start, + clear_inner=True, + ) + bm.verts.index_update() + bm.edges.index_update() + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i + 1], + plane_no=clip_end, + clear_outer=True, + ) + + bm.to_mesh(mesh) + bm.free() + mesh.update() + all_bm.from_mesh(mesh) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh"]) + + # It's necessary to add the mesh to an object to get the expected result. + mesh = bpy.data.meshes.new("TempMesh2") + all_bm.to_mesh(mesh) + all_bm.free() + obj = bpy.data.objects.new("TempObj", mesh) + bm = bmesh.new() + bm.from_mesh(obj.data) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"]) + + verts = [tuple(v.co) for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + data["verts"] = verts + data["edges"] = edges data["tris"] = tris - bm.free() - return data @@ -528,15 +550,11 @@ class PolylineOperator: # TODO Fill doc strings """ """ - number_input: list[str] - input_type: tool.Polyline.InputType - @classmethod def poll(cls, context: bpy.types.Context) -> bool: return context.space_data.type == "VIEW_3D" def __init__(self): - self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) self.mousemove_count = 0 self.action_count = 0 self.visible_objs = [] @@ -568,7 +586,7 @@ class PolylineOperator: self.input_options = ["D", "A", "X", "Y"] self.input_type = None self.input_value_xy = [None, None] - self.input_ui = tool.Polyline.create_input_ui() + self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options) self.is_typing = False self.snap_angle = None self.snapping_points = [] @@ -598,7 +616,7 @@ class PolylineOperator: self.report({"WARNING"}, "The number typed is not valid.") return is_valid else: - if self.input_type in {"X", "Y"}: + if self.input_type in {"X", "Y", "Z"}: tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) elif self.input_type in {"D", "A"}: tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state) @@ -609,60 +627,55 @@ class PolylineOperator: return is_valid def choose_axis(self, event: bpy.types.Event, x: bool = True, y: bool = True, z: bool = False) -> None: - if x: - if not event.shift and event.value == "PRESS" and event.type == "X": - self.tool_state.axis_method = "X" if self.tool_state.axis_method != event.type else None - self.tool_state.lock_axis = False if self.tool_state.lock_axis else True - PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) - tool.Blender.update_viewport() - - if y: - if not event.shift and event.value == "PRESS" and event.type == "Y": - self.tool_state.axis_method = "Y" if self.tool_state.axis_method != event.type else None - self.tool_state.lock_axis = False if self.tool_state.lock_axis else True - PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) - tool.Blender.update_viewport() + options = {"X", "Y"} if z: - if not event.shift and event.value == "PRESS" and event.type == "Z": - self.tool_state.axis_method = "Z" if self.tool_state.axis_method != event.type else None - self.tool_state.lock_axis = False if self.tool_state.lock_axis else True - PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) - tool.Blender.update_viewport() + options = {"X", "Y", "Z"} + if not event.shift and event.value == "PRESS" and event.type in options: + self.tool_state.axis_method = event.type if self.tool_state.axis_method != event.type else None + if self.tool_state.axis_method is not None: + self.tool_state.lock_axis = True + else: + self.tool_state.lock_axis = False + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() def choose_plane(self, event: bpy.types.Event, x: bool = True, y: bool = True, z: bool = True) -> None: if x: if event.shift and event.value == "PRESS" and event.type == "X": self.tool_state.use_default_container = False - self.tool_state.plane_method = "YZ" + self.tool_state.plane_method = "YZ" if self.tool_state.plane_method != "YZ" else None self.tool_state.axis_method = None tool.Blender.update_viewport() if y: if event.shift and event.value == "PRESS" and event.type == "Y": self.tool_state.use_default_container = False - self.tool_state.plane_method = "XZ" + self.tool_state.plane_method = "XZ" if self.tool_state.plane_method != "XZ" else None self.tool_state.axis_method = None tool.Blender.update_viewport() if z: if event.shift and event.value == "PRESS" and event.type == "Z": self.tool_state.use_default_container = False - self.tool_state.plane_method = "XY" + self.tool_state.plane_method = "XY" if self.tool_state.plane_method != "XY" else None self.tool_state.axis_method = None tool.Blender.update_viewport() def handle_instructions( - self, context: bpy.types.Context, custom_instructions: dict = {}, custom_info: str = "" + self, context: bpy.types.Context, custom_instructions: dict = {}, custom_info: str = "", overwrite: bool = False ) -> None: self.info = [ f"Axis: {self.tool_state.axis_method}", f"Plane: {self.tool_state.plane_method}", - f"Snap: {self.snapping_points[0][1]}", + f"Snap: {self.snapping_points[0]['type']}", ] instructions = self.instructions | custom_instructions if custom_instructions else self.instructions - infos = self.info + custom_info if custom_info else self.info + if overwrite: + instructions = custom_instructions + infos = custom_info + def draw_instructions(self: bpy.types.Header, context: bpy.types.Context) -> None: for action, settings in instructions.items(): if settings["icons"]: @@ -673,10 +686,10 @@ class PolylineOperator: key = settings["keys"][0] self.layout.label(text=key + action) - self.layout.label(text="|") - - for info in infos: - self.layout.label(text=info) + if infos: + self.layout.label(text="|") + for info in infos: + self.layout.label(text=info) context.workspace.status_text_set(draw_instructions) @@ -790,7 +803,25 @@ class PolylineOperator: tool.Blender.update_viewport() if event.value == "PRESS" and event.type == "C": - tool.Polyline.close_polyline() + # Get the first point coordinates to close the polyline + polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) > 2: + first_point = polyline_points[0] + last_point = polyline_points[-1] + if not ( + first_point.x == last_point.x and first_point.y == last_point.y and first_point.z == last_point.z + ): + self.input_ui.set_value("X", first_point.x) + self.input_ui.set_value("Y", first_point.y) + if self.input_ui.get_number_value("Z") is not None: + self.input_ui.set_value("Z", first_point.z) + else: + self.input_ui.set_value("Z", 0) + result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state) + if result: + self.report({"WARNING"}, result) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) tool.Blender.update_viewport() @@ -814,6 +845,11 @@ class PolylineOperator: PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) tool.Blender.update_viewport() + if not self.tool_state.is_input_on: + if event.value == "RELEASE" and event.type == "BACK_SPACE": + tool.Polyline.remove_last_polyline_point() + tool.Blender.update_viewport() + def handle_snap_selection(self, context: bpy.types.Context, event: bpy.types.Event) -> None: if not self.tool_state.is_input_on and event.value == "PRESS" and event.type == "M": self.snapping_points = tool.Snap.modify_snapping_point_selection( @@ -869,7 +905,7 @@ class PolylineOperator: detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) - if self.snapping_points[0][1] not in {"Plane", "Axis"}: + if self.snapping_points[0]["type"] not in {"Plane", "Axis"}: should_round = False tool.Polyline.calculate_distance_and_angle( @@ -881,15 +917,12 @@ class PolylineOperator: tool.Blender.update_viewport() return {"RUNNING_MODAL"} - if event.value == "RELEASE" and event.type == "BACK_SPACE": - tool.Polyline.remove_last_polyline_point() - tool.Blender.update_viewport() - def get_product_preview_data(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_isntance): - if tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type.is_a() not in {"IfcColumnType"}: - data = get_horizontal_profile_preview_data(context, relating_type) - elif tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type.is_a() in {"IfcColumnType"}: - data = get_vertical_profile_preview_data(context, relating_type) + if tool.Model.get_usage_type(relating_type) == "PROFILE": + if relating_type.is_a() in {"IfcColumnType", "IfcPileType"}: + data = get_vertical_profile_preview_data(context, relating_type) + else: + data = get_horizontal_profile_preview_data(context, relating_type) elif tool.Model.get_usage_type(relating_type) == "LAYER2": data = get_wall_preview_data(context, relating_type) elif tool.Model.get_usage_type(relating_type) == "LAYER3": @@ -898,12 +931,12 @@ class PolylineOperator: data = get_generic_product_preview_data(context, relating_type) # Update properties so it can be used by the decorator - if not data: - return props = context.scene.BIMProductPreviewProperties props.verts.clear() props.edges.clear() props.tris.clear() + if not data: + return for vert in data["verts"]: v = props.verts.add() @@ -935,6 +968,7 @@ class PolylineOperator: elif getattr(props, offset_type) in {"INTERIOR", "TOP"}: self.offset = -thickness * direction + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) props.offset = self.offset / self.unit_scale tool.Blender.update_viewport() diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 7edf9323f7..c53af0665b 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -58,7 +58,8 @@ class AddEmptyType(bpy.types.Operator, AddObjectHelper): def execute(self, context): obj = bpy.data.objects.new("TYPEX", None) context.scene.collection.objects.link(obj) - context.scene.BIMRootProperties.ifc_product = "IfcElementType" + rprops = tool.Root.get_root_props() + rprops.ifc_product = "IfcElementType" tool.Blender.select_and_activate_single_object(context, obj) return {"FINISHED"} @@ -71,7 +72,7 @@ class AddDefaultType(bpy.types.Operator, tool.Ifc.Operator): ifc_element_type: bpy.props.StringProperty() def _execute(self, context): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() props.ifc_product = "IfcElementType" props.ifc_class = self.ifc_element_type if self.ifc_element_type == "IfcWallType": @@ -154,17 +155,18 @@ class AddDefaultType(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.add_element() -class AddOccurrence(bpy.types.Operator, PolylineOperator): - bl_idname = "bim.add_occurrence" - bl_label = "Add Occurrence" +class DrawOccurrence(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): + bl_idname = "bim.draw_occurrence" + bl_label = "Draw Occurrence" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) def create_occurrence(self, context, event): if not self.relating_type: @@ -193,12 +195,15 @@ class AddOccurrence(bpy.types.Operator, PolylineOperator): context.scene.cursor.location = Vector((point.x, point.y, point.z)) tool.Polyline.clear_polyline() - bpy.ops.bim.add_constr_type_instance("INVOKE_DEFAULT") + bpy.ops.bim.add_occurrence("INVOKE_DEFAULT") if snap_obj: snap_obj.select_set(False) def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): # Ensure state of BIM tool props is valid props = tool.Model.get_model_props() relating_type_id = tool.Blender.get_enum_safe(props, "relating_type_id") @@ -251,6 +256,9 @@ class AddOccurrence(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): super().invoke(context, event) ProductDecorator.install(context) self.tool_state.use_default_container = True @@ -258,8 +266,8 @@ class AddOccurrence(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} -class AddConstrTypeInstance(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.add_constr_type_instance" +class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_occurrence" bl_label = "Add Type Occurrence" bl_options = {"REGISTER", "UNDO"} bl_description = "Add Type Instance" @@ -357,7 +365,7 @@ class AddConstrTypeInstance(bpy.types.Operator, tool.Ifc.Operator): ) bonsai.core.type.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) - rprops = context.scene.BIMRootProperties + rprops = tool.Root.get_root_props() ifc_context = None if get_enum_items(rprops, "contexts", context): ifc_context = int(rprops.contexts or "0") or None @@ -488,15 +496,16 @@ class AddConstrTypeInstance(bpy.types.Operator, tool.Ifc.Operator): elif props.rl_mode == "CURSOR": pass + tool.Model.sync_object_ifc_position(obj) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) for port in ifcopenshell.util.system.get_ports(relating_type): mat = Matrix(ifcopenshell.util.placement.get_local_placement(port.ObjectPlacement)) mat.translation *= unit_scale mat = obj.matrix_world @ mat - new_port = tool.Ifc.run("root.create_entity", ifc_class="IfcDistributionPort") + new_port = tool.Ifc.run("system.add_port", element=element) new_port.PredefinedType = port.PredefinedType new_port.SystemType = port.SystemType - tool.Ifc.run("system.assign_port", element=element, port=new_port) tool.Ifc.run("geometry.edit_object_placement", product=new_port, matrix=mat, is_si=True) if ifc_class == "IfcDoorType" and len(context.selected_objects) >= 1: @@ -537,8 +546,8 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = tool.Model.get_model_props() - bpy.ops.bim.load_type_thumbnails(ifc_class=props.ifc_class, offset=9 * (self.page - 1), limit=9) props.type_page = self.page + bpy.ops.bim.load_type_thumbnails() return {"FINISHED"} @@ -612,35 +621,24 @@ class AlignProduct(bpy.types.Operator): return results -class LoadTypeThumbnails(bpy.types.Operator, tool.Ifc.Operator): +class LoadTypeThumbnails(bpy.types.Operator): bl_idname = "bim.load_type_thumbnails" bl_label = "Load Type Thumbnails" bl_options = {"REGISTER", "UNDO"} - ifc_class: bpy.props.StringProperty() - limit: bpy.props.IntProperty() - offset: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): if bpy.app.background: - return + return {"FINISHED"} - props = tool.Model.get_model_props() # Only process at most one paginated class at a time. # Large projects have hundreds of types which can lead to unnecessary lag. if not AuthoringData.is_loaded: AuthoringData.load() - queue = AuthoringData.data["type_elements_filtered"] - if self.limit: - queue = queue[self.offset : self.offset + self.limit] - else: - offset = 9 * (props.type_page - 1) - if offset < 0: - offset = 0 - queue = queue[offset : offset + 9] + queue = [tool.Ifc.get().by_id(t["id"]) for t in AuthoringData.data["paginated_relating_types"]] - # The active type may be in another page than the active one : - if relating_type_id_current := AuthoringData.data["relating_type_id_current"]: - active_element = tool.Ifc.get_entity_by_id(int(relating_type_id_current)) + # The active type may be in another page than the active one: + if relating_type_id_current := AuthoringData.data["relating_type_data"].get("id"): + active_element = tool.Ifc.get_entity_by_id(relating_type_id_current) if active_element and active_element not in queue: queue.append(active_element) @@ -711,14 +709,15 @@ class MirrorElements(bpy.types.Operator, tool.Ifc.Operator): obj.matrix_world = newmat -def generate_box(usecase_path, ifc_file, settings): +def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: box_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Box", "MODEL_VIEW") if not box_context: return obj = settings["blender_object"] if 0 in list(obj.dimensions): return - product = ifc_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + product = tool.Ifc.get_entity(obj) + assert product old_box = ifcopenshell.util.representation.get_representation(product, "Model", "Box", "MODEL_VIEW") if settings["context"].ContextType == "Model" and getattr(settings["context"], "ContextIdentifier") == "Body": if old_box: @@ -751,7 +750,7 @@ def regenerate_profile_usage(usecase_path, ifc_file, settings): elements.append(element) for element in elements: - obj = IfcStore.get_element(element.id()) + obj = tool.Ifc.get_object_by_identifier(element.id()) if not obj: continue representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index bbc2671314..40e3ab9992 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -22,6 +22,7 @@ import bmesh import mathutils.geometry import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.geometry import ifcopenshell.util.type import ifcopenshell.util.unit import ifcopenshell.util.element @@ -32,9 +33,9 @@ import bonsai.core.type import bonsai.core.geometry import bonsai.core.material import bonsai.core.root -from math import pi, degrees, inf, atan2 -from mathutils import Vector, Matrix, Quaternion -from bonsai.bim.module.geometry.helper import Helper +from bonsai.bim.ifc import IfcStore +from math import pi, degrees, atan2 +from mathutils import Vector, Matrix from bonsai.bim.module.model.wall import DumbWallRecalculator from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -47,6 +48,7 @@ class DumbProfileGenerator: self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) def generate(self, insertion_type="CURSOR"): + self.insertion_type = insertion_type self.file = tool.Ifc.get() self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) material = ifcopenshell.util.element.get_material(self.relating_type) @@ -69,9 +71,9 @@ class DumbProfileGenerator: self.rotation = 0 self.location = Vector((0, 0, 0)) self.cardinal_point = int(props.cardinal_point) - if insertion_type == "POLYLINE": + if self.insertion_type == "POLYLINE": return self.derive_from_polyline() - elif insertion_type == "CURSOR": + elif self.insertion_type == "CURSOR": return self.derive_from_cursor() def derive_from_polyline(self) -> tuple[list[Union[dict[str, Any], None]], bool]: @@ -105,15 +107,17 @@ class DumbProfileGenerator: obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(self.relating_type, ifc_class), mesh) matrix_world = Matrix() - if not self.relating_type.is_a("IfcColumnType"): - matrix_world = Matrix.Rotation(pi / 2, 4, "Z") @ Matrix.Rotation(pi / 2, 4, "X") @ matrix_world - - matrix_world = Matrix.Rotation(self.rotation, 4, "Z") @ matrix_world + if self.relating_type.is_a() not in ("IfcColumnType", "IfcPileType"): + if self.insertion_type not in {"POLYLINE"}: + matrix_world = Matrix.Rotation(pi / 2, 4, "Z") @ Matrix.Rotation(pi / 2, 4, "X") @ matrix_world + matrix_world = Matrix.Rotation(self.rotation, 4, "Z") @ matrix_world + else: + rotation_matrix = self.direction.to_track_quat("Z", "Y") + matrix_world = rotation_matrix.to_matrix().to_4x4() @ matrix_world matrix_world.translation = self.location - if self.container_obj: + if self.insertion_type not in {"POLYLINE"} and self.container_obj: matrix_world.translation.z = self.container_obj.location.z - element = bonsai.core.root.assign_class( tool.Ifc, tool.Collector, @@ -171,19 +175,19 @@ class DumbProfileGenerator: return obj def create_profile_from_2_points(self, coords, should_round=False) -> Union[dict[str, Any], None]: - direction = coords[1] - coords[0] - length = direction.length + self.direction = coords[1] - coords[0] + length = self.direction.length if round(length, 4) < 0.1: return data = {"coords": coords} self.depth = length - self.rotation = atan2(direction[1], direction[0]) + self.rotation = atan2(self.direction[1], self.direction[0]) if should_round: # Round to nearest 50mm (yes, metric for now) self.length = 0.05 * round(length / 0.05) # Round to nearest 5 degrees - nearest_degree = (math.pi / 180) * 5 + nearest_degree = (pi / 180) * 5 self.rotation = nearest_degree * round(self.rotation / nearest_degree) self.location = coords[0] data["obj"] = self.create_profile() @@ -266,7 +270,7 @@ class DumbProfileRegenerator: def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None: obj = tool.Ifc.get_object(related_object) - if not obj or not obj.data or not obj.data.BIMMeshProperties.ifc_definition_id: + if not obj or not tool.Geometry.get_active_representation(obj): return DumbProfileRecalculator().recalculate([obj]) @@ -502,7 +506,9 @@ class DumbProfileJoiner: "geometry.assign_representation", tool.Ifc.get(), product=element, representation=new_axis ) - def get_placement_axes(body_representation): + def get_placement_axes( + body_representation: Union[ifcopenshell.entity_instance, None], + ) -> Union[tuple[tuple[float, float, float], tuple[float, float, float]], tuple[None, None]]: if not body_representation: return None, None extrusion = tool.Model.get_extrusion(body_representation) @@ -514,8 +520,7 @@ class DumbProfileJoiner: return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0)) old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - new_body = ifcopenshell.api.run( - "geometry.add_profile_representation", + new_body = ifcopenshell.api.geometry.add_profile_representation( tool.Ifc.get(), context=self.body_context, profile=self.profile, @@ -528,8 +533,9 @@ class DumbProfileJoiner: if old_body: for inverse in tool.Ifc.get().get_inverse(old_body): ifcopenshell.util.element.replace_attribute(inverse, old_body, new_body) - obj.data.BIMMeshProperties.ifc_definition_id = int(new_body.id()) - obj.data.name = f"{self.body_context.id()}/{new_body.id()}" + assert isinstance(mesh := obj.data, bpy.types.Mesh) + tool.Ifc.link(new_body, mesh) + mesh.name = tool.Loader.get_mesh_name(new_body) bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_body) else: ifcopenshell.api.run( @@ -905,7 +911,7 @@ class ChangeProfileDepth(bpy.types.Operator, tool.Ifc.Operator): class ChangeCardinalPoint(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_cardinal_point" bl_label = "Update" - bl_description = "Update Cardinal Point" + bl_description = "Update Cardinal Point for all selected objects." bl_options = {"REGISTER", "UNDO"} cardinal_point: bpy.props.IntProperty() @@ -920,6 +926,8 @@ class ChangeCardinalPoint(bpy.types.Operator, tool.Ifc.Operator): if not element: continue material = ifcopenshell.util.element.get_material(element, should_skip_usage=False) + if not material: + continue if material.is_a("IfcMaterialProfileSetUsage"): material.CardinalPoint = self.cardinal_point objs.append(obj) @@ -1011,7 +1019,7 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator): position = Matrix() direction = Vector(extrusion.ExtrudedDirection.DirectionRatios).normalized() - tool.Model.import_axis([Vector((0, 0, 0)), direction * extrusion.Depth], obj=obj, position=position) + tool.Model.import_axis((Vector((0, 0, 0)), direction * extrusion.Depth), obj=obj, position=position) bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_axis(context)) @@ -1109,7 +1117,7 @@ class EditExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): +class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): bl_idname = "bim.draw_polyline_profile" bl_label = "Draw Polyline Profile" bl_options = {"REGISTER", "UNDO"} @@ -1118,8 +1126,11 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + self.input_options = ["D", "A", "X", "Y", "Z"] + self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options) self.relating_type = None props = tool.Model.get_model_props() relating_type_id = props.relating_type_id @@ -1138,10 +1149,22 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): for profile1, profile2 in zip(profiles, profiles[1:] + [profiles[0]]): DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) else: - for profile1, profile2 in zip(profiles[:-1], profiles[1:]): - DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) + if len(profiles) == 1: + profile1 = profiles[0] + element1 = tool.Ifc.get_entity(profile1["obj"]) + if element1.is_a("IfcFlowSegment") or element1.is_a("IfcFlowFitting"): + # lazy import to avoid circular import errors + from bonsai.bim.module.model.mep import MEPGenerator + + MEPGenerator().setup_ports(profile1["obj"]) + else: + for profile1, profile2 in zip(profiles[:-1], profiles[1:]): + DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): if not self.relating_type: self.report({"WARNING"}, "You need to select a profile type.") PolylineDecorator.uninstall() @@ -1161,7 +1184,9 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): self.handle_mouse_move(context, event, should_round=True) - self.choose_axis(event) + self.choose_axis(event, z=True) + + self.choose_plane(event) self.handle_snap_selection(context, event) @@ -1192,8 +1217,11 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): super().invoke(context, event) ProductDecorator.install(context) - self.tool_state.use_default_container = True - self.tool_state.plane_method = "XY" + self.tool_state.use_default_container = False + self.tool_state.plane_method = None return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 86efd9a026..e5e56115fd 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -26,40 +26,44 @@ from bonsai.bim.module.model.data import AuthoringData from bpy.types import PropertyGroup, NodeTree from math import pi, radians from bonsai.bim.module.model.decorator import WallAxisDecorator, SlabDirectionDecorator -from typing import TYPE_CHECKING, Literal, get_args +from bonsai.bim.module.model.door import update_door_modifier_bmesh +from bonsai.bim.module.model.window import update_window_modifier_bmesh +from typing import TYPE_CHECKING, Literal, get_args, Union, get_args, Any, Optional -def get_ifc_class(self, context): +def get_ifc_class(self: "BIMModelProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not AuthoringData.is_loaded: AuthoringData.load() return AuthoringData.data["ifc_classes"] -def get_boundary_class(self, context): +def get_boundary_class(self: "BIMModelProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not AuthoringData.is_loaded: AuthoringData.load() return AuthoringData.data["boundary_class"] -def get_relating_type_id(self, context): +def get_relating_type_id(self: "BIMModelProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not AuthoringData.is_loaded: AuthoringData.load() - else: - AuthoringData.data["type_elements"] = AuthoringData.type_elements() - AuthoringData.data["relating_type_id"] = AuthoringData.relating_type_id() return AuthoringData.data["relating_type_id"] -def get_materials(self, context): +def get_materials( + self: Union["BIMWindowProperties", "BIMDoorProperties"], context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not AuthoringData.is_loaded: AuthoringData.load() return AuthoringData.data["materials"] -def update_ifc_class(self, context): - bpy.ops.bim.load_type_thumbnails(ifc_class=self.ifc_class) +def update_ifc_class(self: "BIMModelProperties", context: bpy.types.Context) -> None: + bpy.ops.bim.load_type_thumbnails() + AuthoringData.data["ifc_class_current"] = self.ifc_class + AuthoringData.data["type_elements"] = AuthoringData.type_elements() + AuthoringData.data["type_elements_filtered"] = AuthoringData.type_elements_filtered() AuthoringData.data["relating_type_id"] = AuthoringData.relating_type_id() - AuthoringData.data["type_thumbnail"] = AuthoringData.type_thumbnail() + AuthoringData.data["relating_type_data"] = AuthoringData.relating_type_data() if tool.Blender.get_enum_safe(self, "relating_type_id") is None: self["relating_type_id"] = 0 @@ -70,61 +74,67 @@ def update_ifc_class(self, context): AuthoringData.data["paginated_relating_types"] = AuthoringData.paginated_relating_types() -def update_relating_type_id(self, context): +def update_relating_type_id(self: "BIMModelProperties", context: bpy.types.Context) -> None: AuthoringData.data["relating_type_id"] = AuthoringData.relating_type_id() - AuthoringData.data["relating_type_name"] = AuthoringData.relating_type_name() - AuthoringData.data["type_thumbnail"] = AuthoringData.type_thumbnail() - AuthoringData.data["predefined_type"] = AuthoringData.predefined_type() + AuthoringData.data["relating_type_data"] = AuthoringData.relating_type_data() self.type_page = [e[0] for e in AuthoringData.data["relating_type_id"]].index(self.relating_type_id) // 9 + 1 -def update_type_page(self, context): +def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) -> None: AuthoringData.data["paginated_relating_types"] = AuthoringData.paginated_relating_types() - bpy.ops.bim.load_type_thumbnails(ifc_class=self.ifc_class, offset=9 * (self.type_page - 1), limit=9) + bpy.ops.bim.load_type_thumbnails() self["type_page"] = min(self["type_page"], AuthoringData.data["total_pages"]) self["type_page"] = max(self["type_page"], 1) -def update_relating_array_from_object(self, context): +def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None: bpy.ops.bim.enable_editing_array(item=self.is_editing) return -def is_object_array_applicable(self, obj): +def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool: element = tool.Ifc.get_entity(obj) if not element: return False return ifcopenshell.util.element.get_pset(element, "BBIM_Array") -def update_wall_axis_decorator(self, context): +def update_wall_axis_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None: if self.show_wall_axis: WallAxisDecorator.install(bpy.context) else: WallAxisDecorator.uninstall() -def update_slab_direction_decorator(self, context): +def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None: if self.show_slab_direction: SlabDirectionDecorator.install(bpy.context) else: SlabDirectionDecorator.uninstall() -def update_search_name(self, context): +def update_search_name(self: "BIMModelProperties", context: bpy.types.Context) -> None: AuthoringData.load() # Total number of pages may decrease when using the search bar : if self.type_page > AuthoringData.data["total_pages"]: self.type_page = max(1, AuthoringData.data["total_pages"]) - bpy.ops.bim.load_type_thumbnails(ifc_class=self.ifc_class) + bpy.ops.bim.load_type_thumbnails() -def update_x_angle(self, context): +def update_x_angle(self: "BIMModelProperties", context: bpy.types.Context) -> None: angle_deg = math.degrees(self.x_angle) if tool.Cad.is_x(angle_deg, -90, 0.5) or tool.Cad.is_x(angle_deg, 90, 0.5): self.x_angle = 0 +def update_door(self: "BIMDoorProperties", context: bpy.types.Context) -> None: + update_door_modifier_bmesh(context) + + +def update_window(self: "BIMWindowProperties", context: bpy.types.Context) -> None: + update_window_modifier_bmesh(context) + + class BIMModelProperties(PropertyGroup): ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class) relating_type_id: bpy.props.EnumProperty( @@ -295,36 +305,45 @@ class BIMArrayProperties(PropertyGroup): poll=is_object_array_applicable, ) + if TYPE_CHECKING: + is_editing: int + count: int + x: float + y: float + z: float + use_local_space: bool + method: Literal["OFFSET", "DISTRIBUTE"] + sync_children: bool + relating_array_object: Union[bpy.types.Object, None] -def update_total_length_target(self, context): + +def update_total_length_target(self: "BIMStairProperties", context: bpy.types.Context) -> None: self["tread_run"] = self.total_length_target / (self.number_of_treads + 1) -def update_tread_run(self, context): +def update_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None: if self.total_length_lock: self["number_of_treads"] = int((self.total_length_target / self.tread_run) - 1) else: self["total_length_target"] = (self.number_of_treads + 1) * self.tread_run -def update_number_of_treads(self, context): +def update_number_of_treads(self: "BIMStairProperties", context: bpy.types.Context) -> None: if self.total_length_lock: self["tread_run"] = self.total_length_target / (self.number_of_treads + 1) else: self["total_length_target"] = (self.number_of_treads + 1) * self.tread_run +StairType = Literal["CONCRETE", "WOOD/STEEL", "GENERIC"] + + class BIMStairProperties(PropertyGroup): - def validate_nosing_value(self, context): + def validate_nosing_value(self, context: bpy.types.Context) -> None: if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0: self["nosing_length"] = 0 non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type") - stair_types = ( - ("CONCRETE", "Concrete", ""), - ("WOOD/STEEL", "Wood / Steel", ""), - ("GENERIC", "Generic", ""), - ) is_editing: bpy.props.BoolProperty(default=False) width: bpy.props.FloatProperty(name="Width", default=1.2, soft_min=0.01, subtype="DISTANCE") @@ -353,7 +372,10 @@ class BIMStairProperties(PropertyGroup): top_slab_depth: bpy.props.FloatProperty(name="Top Slab Depth", default=0.25, soft_min=0, subtype="DISTANCE") has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True) stair_type: bpy.props.EnumProperty( - name="Stair Type", items=stair_types, default="CONCRETE", update=validate_nosing_value + name="Stair Type", + items=[(i, i.replace("/", " / ").title(), "") for i in get_args(StairType)], + default="CONCRETE", + update=validate_nosing_value, ) custom_first_last_tread_run: bpy.props.FloatVectorProperty( name="Custom First / Last Treads Widths", @@ -377,6 +399,23 @@ class BIMStairProperties(PropertyGroup): name="Nosing Depth", description="Depth of the tread's nosing", min=0, default=0, unit="LENGTH" ) + if TYPE_CHECKING: + is_editing: bool + width: float + height: float + number_of_treads: int + total_length_target: float + total_length_lock: bool + tread_depth: float + tread_run: float + base_slab_depth: float + top_slab_depth: float + has_top_nib: bool + stair_type: str + custom_first_last_tread_run: tuple[float, float] + nosing_length: float + nosing_depth: float + def get_props_kwargs(self, convert_to_project_units=False, stair_type=None): if not stair_type: stair_type = self.stair_type @@ -434,21 +473,30 @@ def window_type_prop_update(self, context): number_of_panels, panels_data = self.window_types_panels[self.window_type] self.first_mullion_offset, self.second_mullion_offset = panels_data[0] self.first_transom_offset, self.second_transom_offset = panels_data[1] + update_window(self, context) + + +WindowType = Literal[ + "SINGLE_PANEL", + "DOUBLE_PANEL_HORIZONTAL", + "DOUBLE_PANEL_VERTICAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_TOP", + "TRIPLE_PANEL_LEFT", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_HORIZONTAL", + "TRIPLE_PANEL_VERTICAL", +] # default prop values are in mm and converted later class BIMWindowProperties(PropertyGroup): - non_si_units_props = ("is_editing", "window_type") - window_types = ( - ("SINGLE_PANEL", "SINGLE_PANEL", ""), - ("DOUBLE_PANEL_HORIZONTAL", "DOUBLE_PANEL_HORIZONTAL", ""), - ("DOUBLE_PANEL_VERTICAL", "DOUBLE_PANEL_VERTICAL", ""), - ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_BOTTOM", ""), - ("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_TOP", ""), - ("TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_LEFT", ""), - ("TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_RIGHT", ""), - ("TRIPLE_PANEL_HORIZONTAL", "TRIPLE_PANEL_HORIZONTAL", ""), - ("TRIPLE_PANEL_VERTICAL", "TRIPLE_PANEL_VERTICAL", ""), + non_si_units_props = ( + "is_editing", + "window_type", + "lining_material", + "framing_material", + "glazing_material", ) # number of panels and default mullion/transom values @@ -468,55 +516,104 @@ class BIMWindowProperties(PropertyGroup): is_editing: bpy.props.BoolProperty(default=False) window_type: bpy.props.EnumProperty( - name="Window Type", items=window_types, default="SINGLE_PANEL", update=window_type_prop_update + name="Window Type", + items=[(i, i, "") for i in get_args(WindowType)], + default="SINGLE_PANEL", + update=window_type_prop_update, ) - overall_height: bpy.props.FloatProperty(name="Overall Height", default=0.9, subtype="DISTANCE") - overall_width: bpy.props.FloatProperty(name="Overall Width", default=0.6, subtype="DISTANCE") + overall_height: bpy.props.FloatProperty( + name="Overall Height", default=0.9, subtype="DISTANCE", update=update_window + ) + overall_width: bpy.props.FloatProperty(name="Overall Width", default=0.6, subtype="DISTANCE", update=update_window) # lining properties - lining_depth: bpy.props.FloatProperty(name="Lining Depth", default=0.050, subtype="DISTANCE") - lining_thickness: bpy.props.FloatProperty(name="Lining Thickness", default=0.050, subtype="DISTANCE") - lining_offset: bpy.props.FloatProperty(name="Lining Offset", default=0.050, subtype="DISTANCE") + lining_depth: bpy.props.FloatProperty(name="Lining Depth", default=0.050, subtype="DISTANCE", update=update_window) + lining_thickness: bpy.props.FloatProperty( + name="Lining Thickness", default=0.050, subtype="DISTANCE", update=update_window + ) + lining_offset: bpy.props.FloatProperty( + name="Lining Offset", default=0.050, subtype="DISTANCE", update=update_window + ) lining_to_panel_offset_x: bpy.props.FloatProperty( - name="Lining to Panel Offset X", default=0.025, subtype="DISTANCE" + name="Lining to Panel Offset X", default=0.025, subtype="DISTANCE", update=update_window ) lining_to_panel_offset_y: bpy.props.FloatProperty( - name="Lining to Panel Offset Y", default=0.025, subtype="DISTANCE" + name="Lining to Panel Offset Y", default=0.025, subtype="DISTANCE", update=update_window + ) + mullion_thickness: bpy.props.FloatProperty( + name="Mullion Thickness", default=0.050, subtype="DISTANCE", update=update_window ) - mullion_thickness: bpy.props.FloatProperty(name="Mullion Thickness", default=0.050, subtype="DISTANCE") first_mullion_offset: bpy.props.FloatProperty( name="First Mullion Offset", description="Distance from the first lining to the first mullion center", default=0.3, subtype="DISTANCE", + update=update_window, ) second_mullion_offset: bpy.props.FloatProperty( name="Second Mullion Offset", description="Distance from the first lining to the second mullion center", default=0.45, subtype="DISTANCE", + update=update_window, + ) + transom_thickness: bpy.props.FloatProperty( + name="Transom Thickness", default=0.050, subtype="DISTANCE", update=update_window ) - transom_thickness: bpy.props.FloatProperty(name="Transom Thickness", default=0.050, subtype="DISTANCE") first_transom_offset: bpy.props.FloatProperty( name="First Transom Offset", description="Distance from the first lining to the first transom center", default=0.3, subtype="DISTANCE", + update=update_window, ) second_transom_offset: bpy.props.FloatProperty( name="Second Transom Offset", description="Distance from the first lining to the second transom center", default=0.6, subtype="DISTANCE", + update=update_window, ) # panel properties - frame_depth: bpy.props.FloatVectorProperty(name="Frame Depth", size=3, default=[0.035] * 3, subtype="TRANSLATION") + frame_depth: bpy.props.FloatVectorProperty( + name="Frame Depth", size=3, default=[0.035] * 3, subtype="TRANSLATION", update=update_window + ) frame_thickness: bpy.props.FloatVectorProperty( - name="Frame Thickness", size=3, default=[0.035] * 3, subtype="TRANSLATION" + name="Frame Thickness", size=3, default=[0.035] * 3, subtype="TRANSLATION", update=update_window ) - def get_general_kwargs(self, convert_to_project_units=False): + # Material properties + lining_material: bpy.props.EnumProperty(name="Lining Material", items=get_materials, options=set()) + framing_material: bpy.props.EnumProperty(name="Framing Material", items=get_materials, options=set()) + glazing_material: bpy.props.EnumProperty(name="Glazing Material", items=get_materials, options=set()) + + if TYPE_CHECKING: + is_editing: bool + window_type: WindowType + overall_height: float + overall_width: float + lining_depth: float + lining_thickness: float + lining_offset: float + lining_to_panel_offset_x: float + lining_to_panel_offset_y: float + mullion_thickness: float + first_mullion_offset: float + second_mullion_offset: float + first_transom_offset: float + second_transom_offset: float + + # Panel properties. + frame_depth: tuple[float, float, float] + frame_thickness: tuple[float, float, float] + + # Material properties. + lining_material: str + framing_material: str + glazing_material: str + + def get_general_kwargs(self, convert_to_project_units: bool = False) -> dict[str, Any]: kwargs = { "window_type": self.window_type, "overall_height": self.overall_height, @@ -526,7 +623,9 @@ class BIMWindowProperties(PropertyGroup): return kwargs return tool.Model.convert_data_to_project_units(kwargs, ["window_type"]) - def get_lining_kwargs(self, window_type=None, convert_to_project_units=False): + def get_lining_kwargs( + self, window_type: Optional[WindowType] = None, convert_to_project_units: bool = False + ) -> dict[str, Any]: if not window_type: window_type = self.window_type kwargs = { @@ -569,7 +668,7 @@ class BIMWindowProperties(PropertyGroup): return kwargs return tool.Model.convert_data_to_project_units(kwargs) - def get_panel_kwargs(self, convert_to_project_units=False): + def get_panel_kwargs(self, convert_to_project_units: bool = False) -> dict[str, Any]: kwargs = { "frame_depth": self.frame_depth, "frame_thickness": self.frame_thickness, @@ -578,7 +677,7 @@ class BIMWindowProperties(PropertyGroup): return kwargs return tool.Model.convert_data_to_project_units(kwargs) - def set_props_kwargs_from_ifc_data(self, kwargs): + def set_props_kwargs_from_ifc_data(self, kwargs: dict[str, Any]): kwargs = tool.Model.convert_data_to_si_units(kwargs, self.non_si_units_props) for prop_name in kwargs: setattr(self, prop_name, kwargs[prop_name]) @@ -597,17 +696,29 @@ DoorType = Literal[ class BIMDoorProperties(PropertyGroup): - non_si_units_props = ("is_editing", "door_type", "panel_width_ratio") + non_si_units_props = ( + "is_editing", + "door_type", + "panel_width_ratio", + "lining_material", + "framing_material", + "glazing_material", + ) is_editing: bpy.props.BoolProperty(default=False) door_type: bpy.props.EnumProperty( - name="Door Operation Type", items=tuple((i, i, "") for i in get_args(DoorType)), default="SINGLE_SWING_LEFT" + name="Door Operation Type", + items=tuple((i, i, "") for i in get_args(DoorType)), + default="SINGLE_SWING_LEFT", + update=update_door, ) - overall_height: bpy.props.FloatProperty(name="Overall Height", default=2.0, subtype="DISTANCE") - overall_width: bpy.props.FloatProperty(name="Overall Width", default=0.9, subtype="DISTANCE") + overall_height: bpy.props.FloatProperty(name="Overall Height", default=2.0, subtype="DISTANCE", update=update_door) + overall_width: bpy.props.FloatProperty(name="Overall Width", default=0.9, subtype="DISTANCE", update=update_door) # lining properties - lining_depth: bpy.props.FloatProperty(name="Lining Depth", default=0.050, subtype="DISTANCE") - lining_thickness: bpy.props.FloatProperty(name="Lining Thickness", default=0.050, subtype="DISTANCE") + lining_depth: bpy.props.FloatProperty(name="Lining Depth", default=0.050, subtype="DISTANCE", update=update_door) + lining_thickness: bpy.props.FloatProperty( + name="Lining Thickness", default=0.050, subtype="DISTANCE", update=update_door + ) lining_offset: bpy.props.FloatProperty( name="Lining Offset", description="Offset from the outer side of the wall (by Y-axis). " @@ -615,12 +726,13 @@ class BIMDoorProperties(PropertyGroup): "`0.025 mm` is good as default value", default=0.0, subtype="DISTANCE", + update=update_door, ) lining_to_panel_offset_x: bpy.props.FloatProperty( - name="Lining to Panel Offset X", default=0.025, subtype="DISTANCE" + name="Lining to Panel Offset X", default=0.025, subtype="DISTANCE", update=update_door ) lining_to_panel_offset_y: bpy.props.FloatProperty( - name="Lining to Panel Offset Y", default=0.025, subtype="DISTANCE" + name="Lining to Panel Offset Y", default=0.025, subtype="DISTANCE", update=update_door ) transom_thickness: bpy.props.FloatProperty( @@ -628,12 +740,14 @@ class BIMDoorProperties(PropertyGroup): description="Set values > 0 to add a transom.\n" "`0.050 mm` is good as default value", default=0.000, subtype="DISTANCE", + update=update_door, ) transom_offset: bpy.props.FloatProperty( name="Transom Offset", description="Distance from the bottom door opening to the beginning of the transom (unlike windows)", default=1.525, subtype="DISTANCE", + update=update_door, ) casing_thickness: bpy.props.FloatProperty( @@ -641,32 +755,49 @@ class BIMDoorProperties(PropertyGroup): description="Set values > 0 and LiningOffset = 0 to add a casing.", default=0.075, subtype="DISTANCE", + update=update_door, ) - casing_depth: bpy.props.FloatProperty(name="Casing Depth", default=0.005, subtype="DISTANCE") + casing_depth: bpy.props.FloatProperty(name="Casing Depth", default=0.005, subtype="DISTANCE", update=update_door) threshold_thickness: bpy.props.FloatProperty( - name="Threshold Thickness", description="Set values > 0 to add a threshold.", default=0.025, subtype="DISTANCE" + name="Threshold Thickness", + description="Set values > 0 to add a threshold.", + default=0.025, + subtype="DISTANCE", + update=update_door, + ) + threshold_depth: bpy.props.FloatProperty( + name="Threshold Depth", default=0.1, subtype="DISTANCE", update=update_door ) - threshold_depth: bpy.props.FloatProperty(name="Threshold Depth", default=0.1, subtype="DISTANCE") threshold_offset: bpy.props.FloatProperty( - name="Threshold Offset", description="`0.025 mm` is good as default value", default=0.000, subtype="DISTANCE" + name="Threshold Offset", + description="`0.025 mm` is good as default value", + default=0.000, + subtype="DISTANCE", + update=update_door, ) # panel properties - panel_depth: bpy.props.FloatProperty(name="Panel Depth", default=0.035, subtype="DISTANCE") + panel_depth: bpy.props.FloatProperty(name="Panel Depth", default=0.035, subtype="DISTANCE", update=update_door) panel_width_ratio: bpy.props.FloatProperty( name="Panel Width Ratio", description="Width of this panel, given as ratio " "relative to the total clear opening width of the door", default=1.0, soft_min=0, soft_max=1, + update=update_door, + ) + frame_thickness: bpy.props.FloatProperty( + name="Window Frame Thickness", default=0.035, subtype="DISTANCE", update=update_door + ) + frame_depth: bpy.props.FloatProperty( + name="Window Frame Depth", default=0.035, subtype="DISTANCE", update=update_door ) - frame_thickness: bpy.props.FloatProperty(name="Window Frame Thickness", default=0.035, subtype="DISTANCE") - frame_depth: bpy.props.FloatProperty(name="Window Frame Depth", default=0.035, subtype="DISTANCE") # Material properties - panel_material: bpy.props.EnumProperty(name="Panel Material", items=get_materials, options=set()) lining_material: bpy.props.EnumProperty(name="Lining Material", items=get_materials, options=set()) + framing_material: bpy.props.EnumProperty(name="Framing Material", items=get_materials, options=set()) + glazing_material: bpy.props.EnumProperty(name="Glazing Material", items=get_materials, options=set()) if TYPE_CHECKING: is_editing: bool @@ -703,6 +834,7 @@ class BIMDoorProperties(PropertyGroup): # Material. panel_material: str lining_material: str + glazing_material: str def get_general_kwargs(self, convert_to_project_units=False): kwargs = { @@ -768,6 +900,10 @@ class BIMDoorProperties(PropertyGroup): setattr(self, prop_name, kwargs[prop_name]) +RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"] +CapType = Literal["TO_END_POST_AND_FLOOR", "TO_END_POST", "TO_FLOOR", "TO_WALL", "180", "NONE"] + + class BIMRailingProperties(PropertyGroup): non_si_units_props = ( "is_editing", @@ -777,23 +913,12 @@ class BIMRailingProperties(PropertyGroup): "path_data", ) - railing_types = ( - ("FRAMELESS_PANEL", "FRAMELESS_PANEL", ""), - ("WALL_MOUNTED_HANDRAIL", "WALL_MOUNTED_HANDRAIL", ""), - ) - cap_types = ( - ("TO_END_POST_AND_FLOOR", "TO_END_POST_AND_FLOOR", ""), - ("TO_END_POST", "TO_END_POST", ""), - ("TO_FLOOR", "TO_FLOOR", ""), - ("TO_WALL", "TO_WALL", ""), - ("180", "180", ""), - ("NONE", "NONE", ""), - ) - is_editing: bpy.props.BoolProperty(default=False) is_editing_path: bpy.props.BoolProperty(default=False) - railing_type: bpy.props.EnumProperty(name="Railing Type", items=railing_types, default="FRAMELESS_PANEL") + railing_type: bpy.props.EnumProperty( + name="Railing Type", items=[(i, i, "") for i in get_args(RailingType)], default="FRAMELESS_PANEL" + ) height: bpy.props.FloatProperty(name="Height", default=1.0, subtype="DISTANCE") thickness: bpy.props.FloatProperty(name="Thickness", default=0.050, subtype="DISTANCE") spacing: bpy.props.FloatProperty(name="Spacing", default=0.050, subtype="DISTANCE") @@ -819,7 +944,24 @@ class BIMRailingProperties(PropertyGroup): description="Clear width between the railing and the wall", subtype="DISTANCE", ) - terminal_type: bpy.props.EnumProperty(name="Terminal Type", items=cap_types, default="180") + terminal_type: bpy.props.EnumProperty( + name="Terminal Type", items=[(i, i, "") for i in get_args(CapType)], default="180" + ) + + if TYPE_CHECKING: + is_editing: bool + is_editing_path: bool + + railing_type: RailingType + height: float + thickness: float + spacing: float + + use_manual_supports: bool + support_spacing: float + railing_diameter: float + clear_width: float + terminal_type: CapType def get_general_kwargs(self, railing_type=None, convert_to_project_units=False): if railing_type is None: @@ -865,11 +1007,15 @@ def to_percentage(angle: float) -> float: return math.tan(angle) * 100 +RoofType = Literal["HIP/GABLE ROOF"] +RoofGenerationMethod = Literal["HEIGHT", "ANGLE"] + + class BIMRoofProperties(PropertyGroup): - def update_angle(self, context) -> None: + def update_angle(self, context: bpy.types.Context) -> None: self["angle"] = to_angle(self.percentage) - def update_percentage(self, context) -> None: + def update_percentage(self, context: bpy.types.Context) -> None: self["percentage"] = to_percentage(self.angle) non_si_units_props = ( @@ -881,18 +1027,15 @@ class BIMRoofProperties(PropertyGroup): "percentage", "rafter_edge_angle", ) - roof_types = (("HIP/GABLE ROOF", "HIP/GABLE ROOF", ""),) - roof_generation_methods = ( - ("HEIGHT", "HEIGHT", ""), - ("ANGLE", "ANGLE", ""), - ) is_editing: bpy.props.BoolProperty(default=False) is_editing_path: bpy.props.BoolProperty(default=False) - roof_type: bpy.props.EnumProperty(name="Roof Type", items=roof_types, default="HIP/GABLE ROOF") + roof_type: bpy.props.EnumProperty( + name="Roof Type", items=[(i, i, "") for i in get_args(RoofType)], default="HIP/GABLE ROOF" + ) generation_method: bpy.props.EnumProperty( - name="Roof Generation Method", items=roof_generation_methods, default="ANGLE" + name="Roof Generation Method", items=[(i, i, "") for i in get_args(RoofGenerationMethod)], default="ANGLE" ) height: bpy.props.FloatProperty( name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE" @@ -922,6 +1065,17 @@ class BIMRoofProperties(PropertyGroup): name="Rafter Edge Angle", min=0, max=pi / 2, default=pi / 2, subtype="ANGLE" ) + if TYPE_CHECKING: + is_editing: bool + is_editing_path: bool + roof_type: Literal["HIP/GABLE ROOF"] + generation_method: Literal["HEIGHT", "ANGLE"] + height: float + angle: float + percentage: float + roof_thickness: float + rafter_edge_angle: float + def get_general_kwargs(self, generation_method=None, convert_to_project_units=False): if generation_method is None: generation_method = self.generation_method @@ -965,6 +1119,7 @@ class PolylinePoint(PropertyGroup): class Polyline(PropertyGroup): + id: bpy.props.StringProperty(name="Id") polyline_points: bpy.props.CollectionProperty(type=PolylinePoint) measurement_type: bpy.props.StringProperty(name="Measurement Type") area: bpy.props.StringProperty(name="Measured Area") diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 78c94f4ed3..632e1748be 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -55,8 +55,10 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None: since it's going to update ifc representation """ obj = context.active_object - props = obj.BIMRailingProperties + assert obj + props = tool.Model.get_railing_props(obj) element = tool.Ifc.get_entity(obj) + assert element ifc_file = tool.Ifc.get() # type attributes @@ -122,7 +124,8 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: If BBIM Pset just changed should call refresh() before updating bmesh """ obj = context.active_object - props = obj.BIMRailingProperties + assert obj + props = tool.Model.get_railing_props(obj) V_ = tool.Blender.V_ # NOTE: using Data since bmesh update will hapen very often @@ -327,8 +330,10 @@ class AddRailing(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMRailingProperties + assert element + props = tool.Model.get_railing_props(obj) si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) railing_data = props.get_general_kwargs(convert_to_project_units=True) @@ -367,7 +372,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): source_obj = context.active_object - source_props = source_obj.BIMRailingProperties + assert source_obj + source_props = tool.Model.get_railing_props(source_obj) railing_data = source_props.get_general_kwargs(convert_to_project_units=True) for target_obj in context.selected_objects: @@ -379,7 +385,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator): continue railing_data["path_data"] = RailingData.data["path_data"] target_element = tool.Ifc.get_entity(target_obj) - target_props = target_obj.BIMRailingProperties + assert target_element + target_props = tool.Model.get_railing_props(target_obj) target_props.set_props_kwargs_from_ifc_data(railing_data) update_bbim_railing_pset(target_element, railing_data) @@ -398,7 +405,8 @@ class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMRailingProperties + assert obj + props = tool.Model.get_railing_props(obj) data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] data["path_data"] = json.dumps(data["path_data"]) @@ -416,8 +424,9 @@ class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] - props = obj.BIMRailingProperties + props = tool.Model.get_railing_props(obj) # restore previous settings since editing was canceled props.set_props_kwargs_from_ifc_data(data) @@ -434,8 +443,10 @@ class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMRailingProperties + assert element + props = tool.Model.get_railing_props(obj) pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing") path_data = pset_data["data_dict"]["path_data"] @@ -457,8 +468,10 @@ class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMRailingProperties + assert element + props = tool.Model.get_railing_props(obj) pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing") path_data = pset_data["data_dict"]["path_data"] @@ -488,7 +501,8 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object [o.select_set(False) for o in context.selected_objects if o != obj] - props = obj.BIMRailingProperties + assert obj + props = tool.Model.get_railing_props(obj) data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"] # required since we could load pset from .ifc and BIMRoofProperties won't be set props.set_props_kwargs_from_ifc_data(data) @@ -505,7 +519,8 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): def cancel_editing_railing_path(context: bpy.types.Context) -> set[str]: obj = context.active_object - props = obj.BIMRailingProperties + assert obj + props = tool.Model.get_railing_props(obj) ProfileDecorator.uninstall() props.is_editing_path = False @@ -517,6 +532,7 @@ def cancel_editing_railing_path(context: bpy.types.Context) -> set[str]: update_railing_modifier_bmesh(context) else: element = tool.Ifc.get_entity(obj) + assert element body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") bonsai.core.geometry.switch_representation( tool.Ifc, @@ -547,8 +563,9 @@ class FinishEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMRailingProperties + props = tool.Model.get_railing_props(obj) railing_data = props.get_general_kwargs(convert_to_project_units=True) path_data = get_path_data(obj) @@ -574,8 +591,11 @@ class RemoveRailing(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - obj.BIMRailingProperties.is_editing = False + assert element + props = tool.Model.get_railing_props(obj) + props.is_editing = False pset = tool.Pset.get_element_pset(element, "BBIM_Railing") ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=pset) diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index 8afd41bbee..5f19800362 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -409,7 +409,8 @@ def update_roof_modifier_ifc_data(context: bpy.types.Context) -> None: since it's going to update ifc representation """ obj = context.active_object - props = obj.BIMRoofProperties + assert obj + props = tool.Model.get_roof_props(obj) element = tool.Ifc.get_entity(obj) def roof_is_gabled() -> bool: @@ -424,8 +425,14 @@ def update_roof_modifier_ifc_data(context: bpy.types.Context) -> None: return False # type attributes - if props.roof_type == "HIP/GABLE ROOF": - element.PredefinedType = "GABLE_ROOF" if roof_is_gabled() else "HIP_ROOF" + ifc_class = element.is_a() + if ifc_class in ("IfcRoof", "IfcRoofType"): + if props.roof_type == "HIP/GABLE ROOF": + element.PredefinedType = "GABLE_ROOF" if roof_is_gabled() else "HIP_ROOF" + elif ifc_class in ("IfcSlab", "IfcSlabType"): + element.PredefinedType = "ROOF" + elif ifc_class in ("IfcCoveringType", "IfcCoveringType"): + element.PredefinedType = "ROOFING" tool.Model.add_body_representation(obj) @@ -442,7 +449,7 @@ def update_roof_modifier_bmesh(obj: bpy.types.Object) -> None: """before using should make sure that Data contains up-to-date information. If BBIM Pset just changed should call refresh() before updating bmesh """ - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) assert isinstance(obj.data, bpy.types.Mesh) # NOTE: using Data since bmesh update will hapen very often @@ -559,7 +566,7 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) # rejecting original roof shape to be safe @@ -607,7 +614,8 @@ class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMRoofProperties + assert obj + props = tool.Model.get_roof_props(obj) data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"] # required since we could load pset from .ifc and BIMRoofProperties won't be set props.set_props_kwargs_from_ifc_data(data) @@ -624,7 +632,7 @@ class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object assert obj data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"] - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) # restore previous settings since editing was canceled props.set_props_kwargs_from_ifc_data(data) @@ -642,7 +650,7 @@ class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof") path_data = pset_data["data_dict"]["path_data"] @@ -666,7 +674,7 @@ class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object assert obj [o.select_set(False) for o in context.selected_objects if o != obj] - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"] # required since we could load pset from .ifc and BIMRoofProperties won't be set props.set_props_kwargs_from_ifc_data(data) @@ -720,7 +728,7 @@ class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): def cancel_editing_roof_path(context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) ProfileDecorator.uninstall() props.is_editing_path = False @@ -751,7 +759,8 @@ class CopyRoofParameters(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): source_obj = context.active_object - source_props = source_obj.BIMRoofProperties + assert source_obj + source_props = tool.Model.get_roof_props(source_obj) data = source_props.get_general_kwargs(convert_to_project_units=True) for target_obj in context.selected_objects: @@ -763,7 +772,7 @@ class CopyRoofParameters(bpy.types.Operator, tool.Ifc.Operator): continue data["path_data"] = RoofData.data["path_data"] target_element = tool.Ifc.get_entity(target_obj) - target_props = target_obj.BIMRoofProperties + target_props = tool.Model.get_roof_props(target_obj) target_props.set_props_kwargs_from_ifc_data(data) update_bbim_roof_pset(target_element, data) @@ -783,7 +792,7 @@ class FinishEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) bm = tool.Blender.get_bmesh_for_mesh(obj.data) op_status, error_message = is_valid_roof_footprint(bm) @@ -815,9 +824,12 @@ class RemoveRoof(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - obj.BIMRoofProperties.is_editing = False + props = tool.Model.get_roof_props(obj) + props.is_editing = False + assert element pset = tool.Pset.get_element_pset(element, "BBIM_Roof") ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=pset) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index b362f1e736..1f89f27c00 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -21,6 +21,7 @@ import json import bmesh import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.geometry import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.representation @@ -31,9 +32,8 @@ import bonsai.core.geometry import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import cos, radians +from math import cos, pi from mathutils import Vector, Matrix -from bonsai.bim.module.geometry.helper import Helper from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.wall import DumbWallRecalculator @@ -136,7 +136,7 @@ class DumbSlabGenerator: matrix_world.translation.z = self.container_obj.location.z else: matrix_world.translation.z - obj.matrix_world = matrix_world @ Matrix.Rotation(self.x_angle, 4, "X") + obj.matrix_world = matrix_world bpy.context.view_layer.update() element = bonsai.core.root.assign_class( @@ -171,6 +171,7 @@ class DumbSlabGenerator: is_global=True, should_sync_changes_first=False, ) + obj.matrix_world = obj.matrix_world @ Matrix.Rotation(self.x_angle, 4, "X") if self.footprint_context: extrusion = tool.Model.get_extrusion(representation) @@ -260,7 +261,7 @@ class DumbSlabPlaner: self, related_object: ifcopenshell.entity_instance, layer_set_direction: Optional[str], new_thickness: float ) -> None: obj = tool.Ifc.get_object(related_object) - if not obj or not obj.data or not obj.data.BIMMeshProperties.ifc_definition_id: + if not obj or not tool.Geometry.get_active_representation(obj): return material = ifcopenshell.util.element.get_material(related_object) @@ -283,6 +284,7 @@ class DumbSlabPlaner: if tool.Model.get_usage_type(element) != "LAYER3": return layer_params = tool.Model.get_material_layer_parameters(element) + ifc_file = tool.Ifc.get() body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") obj = tool.Ifc.get_object(element) if not obj: @@ -295,34 +297,55 @@ class DumbSlabPlaner: if representation: extrusion = tool.Model.get_extrusion(representation) if extrusion: - x, y, z = extrusion.ExtrudedDirection.DirectionRatios - existing_x_angle = tool.Model.get_existing_x_angle(extrusion) - perpendicular_depth = thickness * (1 / cos(existing_x_angle)) - perpendicular_offset = layer_params["offset"] * (1 / cos(existing_x_angle)) - offset_vector = Vector((0.0, 0.0, perpendicular_offset / self.unit_scale)) - if layer_params["direction_sense"] == "POSITIVE": - y = abs(y) if existing_x_angle > 0 else -abs(y) - z = abs(z) - elif layer_params["direction_sense"] == "NEGATIVE": - y = -abs(y) if existing_x_angle > 0 else abs(y) - z = -abs(z) - extrusion.ExtrudedDirection.DirectionRatios = (x, y, z) + # TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction. + # For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a + # -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation. + # This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach. + existing_x_angle = obj.rotation_euler.x + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle + direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) + offset_direction = direction_ratios.copy() + perpendicular_depth = thickness * abs(1 / cos(existing_x_angle)) + perpendicular_offset = layer_params["offset"] * abs(1 / cos(existing_x_angle)) / self.unit_scale + + # Check angle and z direction to determine whether the extrusion direction is positive or negative + if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is positive. If the layer_parameter is set to negative, + # then the we change the extrusion direction. + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is negative. If the layer_parameter is set to positive, + # then the we change the extrusion direction. And the offset direction should remain positive + # for either direction sense, so we change it. + offset_direction *= -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 + + extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) extrusion.Depth = perpendicular_depth - if perpendicular_offset != 0.0 and not extrusion.Position: - tool.Model.add_extrusion_position(extrusion, perpendicular_offset) - - # Update the extrusion's location based on its current rotation angle and offset - if extrusion.Position: - rot_matrix = Matrix.Rotation(existing_x_angle, 4, "X") - rot_offset = offset_vector @ rot_matrix - extrusion.Position.Location.Coordinates = tuple(rot_offset) + ifc_position = extrusion.Position + if perpendicular_offset == 0.0: + # Clean up possible previous offset. + tool.Model.reset_extrusion_position(extrusion) + else: + position = offset_direction * perpendicular_offset + if ifc_position: + ifc_position.Location.Coordinates = position + else: + tool.Model.add_extrusion_position(extrusion, position) else: props = tool.Model.get_model_props() x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle - new_rep = ifcopenshell.api.run( - "geometry.add_slab_representation", + new_rep = ifcopenshell.api.geometry.add_slab_representation( tool.Ifc.get(), context=body_context, depth=thickness * self.unit_scale, @@ -346,15 +369,14 @@ class DumbSlabPlaner: else: props = tool.Model.get_model_props() x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle - representation = ifcopenshell.api.run( - "geometry.add_slab_representation", + representation = ifcopenshell.api.geometry.add_slab_representation( tool.Ifc.get(), context=body_context, depth=thickness * self.unit_scale, x_angle=x_angle, ) - ifcopenshell.api.run( - "geometry.assign_representation", tool.Ifc.get(), product=element, representation=representation + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=element, representation=representation ) bonsai.core.geometry.switch_representation( @@ -842,7 +864,8 @@ class AddSlabFromWall(bpy.types.Operator, tool.Ifc.Operator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.relating_type = None props = tool.Model.get_model_props() relating_type_id = props.relating_type_id @@ -864,7 +887,7 @@ class AddSlabFromWall(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class DrawPolylineSlab(bpy.types.Operator, PolylineOperator): +class DrawPolylineSlab(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): bl_idname = "bim.draw_polyline_slab" bl_label = "Draw Polyline Slab" bl_options = {"REGISTER", "UNDO"} @@ -873,8 +896,9 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) self.relating_type = None props = tool.Model.get_model_props() relating_type_id = props.relating_type_id @@ -886,11 +910,13 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator): return {"FINISHED"} slab = DumbSlabGenerator(self.relating_type).generate("POLYLINE") + if not slab: + return model_props = tool.Model.get_model_props() direction_sense = model_props.direction_sense offset = model_props.offset - model = IfcStore.get_file() + model = tool.Ifc.get() element = tool.Ifc.get_entity(slab) material = ifcopenshell.util.element.get_material(element) material_set_usage = model.by_id(material.id()) @@ -905,6 +931,9 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator): DumbSlabPlaner().regenerate_from_occurence(element, material_set_usage) def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): if not self.relating_type: self.report({"WARNING"}, "You need to select a slab type.") PolylineDecorator.uninstall() @@ -975,6 +1004,9 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): super().invoke(context, event) ProductDecorator.install(context) self.tool_state.use_default_container = True diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 8abd07b838..e325497525 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -34,7 +34,8 @@ from bpy_extras.object_utils import AddObjectHelper, object_data_add def regenerate_stair_mesh(obj: bpy.types.Object) -> None: - props_kwargs = obj.BIMStairProperties.get_props_kwargs() + props = tool.Model.get_stair_props(obj) + props_kwargs = props.get_props_kwargs() vertices, edges, faces = tool.Model.generate_stair_2d_profile(**props_kwargs) bm = bmesh.new() @@ -69,7 +70,8 @@ def update_ifc_stair_props(obj: bpy.types.Object) -> None: since it's going to update ifc representation """ element = tool.Ifc.get_entity(obj) - props = obj.BIMStairProperties + assert element + props = tool.Model.get_stair_props(obj) ifc_file = tool.Ifc.get() if tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcStairFlight"): @@ -183,7 +185,8 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMStairProperties + assert element + props = tool.Model.get_stair_props(obj) ifc_file = tool.Ifc.get() stair_data = props.get_props_kwargs(convert_to_project_units=True) @@ -214,9 +217,11 @@ class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) + assert element data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Stair", "Data")) - props = obj.BIMStairProperties + props = tool.Model.get_stair_props(obj) # restore previous settings since editing was canceled props.set_props_kwargs_from_ifc_data(data) regenerate_stair_mesh(obj) @@ -233,8 +238,10 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMStairProperties + assert element + props = tool.Model.get_stair_props(obj) data = props.get_props_kwargs(convert_to_project_units=True) props.is_editing = False @@ -257,7 +264,8 @@ class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMStairProperties + assert obj + props = tool.Model.get_stair_props(obj) element = tool.Ifc.get_entity(obj) data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Stair", "Data")) # required since we could load pset from .ifc and BIMStairProperties won't be set @@ -273,9 +281,11 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMStairProperties + assert obj + props = tool.Model.get_stair_props(obj) element = tool.Ifc.get_entity(obj) - obj.BIMStairProperties.is_editing = False + assert element + props.is_editing = False pset = tool.Pset.get_element_pset(element, "BBIM_Stair") ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=pset) diff --git a/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py b/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py index 5624797752..315b16a98c 100644 --- a/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py +++ b/src/bonsai/bonsai/bim/module/model/sverchok_modifier.py @@ -25,6 +25,7 @@ import bonsai.tool as tool import json import zipfile import os.path +from bpy_extras.io_utils import ImportHelper, ExportHelper def update_sverchok_modifier(context): @@ -181,16 +182,14 @@ class UpdateDataFromSverchok(bpy.types.Operator, tool.Ifc.Operator): # used code from Sverchok's SvNodeTreeImporter licensed under GPL v3 # the code was changed to work with ifc sverchok modifier # removed the part that was relying on node graph to be opened at execution -class ImportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator): +class ImportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_sverchok_graph" bl_label = "Import Sverchok Graph" + bl_description = "Import Sverchok graph from a json file." bl_options = {"REGISTER"} - filepath: bpy.props.StringProperty( - name="File Path", description="Filepath used to import from", maxlen=1024, default="", subtype="FILE_PATH" - ) - filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) + filename_ext = ".json" def _execute(self, context): import sverchok @@ -209,10 +208,6 @@ class ImportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator): props.node_group = node_group return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - def draw(self, context): col = self.layout.column() col.label(text="Destination tree to import JSON:") @@ -223,16 +218,14 @@ class ImportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator): # used code from Sverchok's SvNodeTreeExporter licensed under GPL v3 # the code was changed to work with ifc sverchok modifier # removed the part that was relying on node graph to be opened at execution -class ExportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator): +class ExportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator, ExportHelper): bl_idname = "bim.export_sverchok_graph" bl_label = "Export Sverchok Graph" + bl_description = "Export Sverchok graph to a json file." bl_options = {"REGISTER"} - filepath: bpy.props.StringProperty( - name="File Path", description="Filepath used for exporting to", maxlen=1024, default="", subtype="FILE_PATH" - ) - filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) + filename_ext = ".json" compact: bpy.props.BoolProperty(default=True, description="Compact representation of the JSON file") compress: bpy.props.BoolProperty() @@ -279,10 +272,6 @@ class ExportSverchokGraph(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - def draw(self, context): graph_name = context.active_object.BIMSverchokProperties.node_group.name self.layout.label(text=f'Save node tree "{graph_name}" into json:') diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index bb5899113a..dec2f1f91a 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -17,9 +17,11 @@ # along with Bonsai. If not, see . import bpy +import bl_ui_utils.layout import bonsai.bim import bonsai.tool as tool from bpy.types import Panel, Menu +from bonsai.bim.helper import prop_with_search from bonsai.bim.module.model.data import ( AuthoringData, ArrayData, @@ -30,13 +32,9 @@ from bonsai.bim.module.model.data import ( RailingData, RoofData, ) -from bonsai.bim.module.model.prop import get_ifc_class from bonsai.bim.module.model.stair import regenerate_stair_mesh -from bonsai.bim.module.model.window import update_window_modifier_bmesh -from bonsai.bim.module.model.door import update_door_modifier_bmesh from bonsai.bim.module.model.railing import update_railing_modifier_bmesh from bonsai.bim.module.model.roof import update_roof_modifier_bmesh -from bonsai.bim.helper import prop_with_search from collections.abc import Iterable @@ -57,8 +55,9 @@ class BIM_MT_type_menu(bpy.types.Menu): def draw(self, context): props = tool.Model.get_model_props() layout = self.layout - op = layout.operator("bim.launch_rename_type", icon="GREASEPENCIL", text="Rename Type") - op.element = props.menu_relating_type_id + with bl_ui_utils.layout.operator_context(layout, "INVOKE_REGION_WIN"): + op = layout.operator("bim.rename_type", icon="GREASEPENCIL", text="Rename Type") + op.element = props.menu_relating_type_id op = layout.operator("bim.select_type", icon="OBJECT_DATA") op.relating_type = props.menu_relating_type_id op = layout.operator("bim.duplicate_type", icon="DUPLICATE") @@ -92,21 +91,14 @@ class LaunchTypeManager(bpy.types.Operator): def invoke(self, context, event): props = tool.Model.get_model_props() props.type_page = 1 - if get_ifc_class(None, context): - ifc_class = AuthoringData.data["ifc_class_current"] or AuthoringData.data["ifc_element_type"] - else: - ifc_class = AuthoringData.data["ifc_element_type"] - - # will be None if project has no types - if ifc_class is not None: - bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class, offset=0, limit=9) + bpy.ops.bim.load_type_thumbnails() return context.window_manager.invoke_props_dialog(self, width=550, title="Type Manager", confirm_text="Close") def draw(self, context): props = tool.Model.get_model_props() row = self.layout.row(align=True) text = f"{AuthoringData.data['total_types']} {AuthoringData.data['ifc_element_type'] or 'Types'}" - if AuthoringData.data["total_types"] > 1: + if AuthoringData.data["ifc_element_type"] and AuthoringData.data["total_types"] > 1: text += "s" row.label(text=text, icon="FILE_VOLUME") row.menu("BIM_MT_type_manager_menu", text="", icon="PREFERENCES") @@ -119,9 +111,8 @@ class LaunchTypeManager(bpy.types.Operator): op.ifc_product = "IfcElementType" op.ifc_class = AuthoringData.data["ifc_element_type"] or props.ifc_class or "" - if AuthoringData.data["total_types"]: - row = self.layout.row(align=True) - row.prop(props, "search_name", icon="FILTER", text="") + row = self.layout.row(align=True) + row.prop(props, "search_name", icon="FILTER", text="") columns = self.layout.column_flow(columns=3) if AuthoringData.data["total_pages"] > 0: @@ -160,18 +151,16 @@ class LaunchTypeManager(bpy.types.Operator): op = row.operator("bim.set_active_type", text=relating_type["description"], emboss=False) op.relating_type = relating_type["id"] - if relating_type["icon_id"]: + if icon_id := AuthoringData.type_thumbnails.get(relating_type["id"], 0): # Yep, that's EXACTLY how it's done. And I'm proud of it. row1 = box.row() row1.ui_units_y = 0.01 - row1.template_icon(icon_value=relating_type["icon_id"], scale=4) + row1.template_icon(icon_value=icon_id, scale=4) row2 = box.column(align=True) row2.operator("bim.set_active_type", text="", emboss=False).relating_type = relating_type["id"] row2.operator("bim.set_active_type", text="", emboss=False).relating_type = relating_type["id"] row2.operator("bim.set_active_type", text="", emboss=False).relating_type = relating_type["id"] - is_current_relating_type = str(relating_type["id"]) == str( - AuthoringData.data["relating_type_id_current"] - ) + is_current_relating_type = relating_type["id"] == AuthoringData.data["relating_type_data"].get("id") if is_current_relating_type: active_row = row2.row() active_row.alignment = "CENTER" @@ -180,8 +169,7 @@ class LaunchTypeManager(bpy.types.Operator): row2.operator("bim.set_active_type", text="", emboss=False).relating_type = relating_type["id"] else: row = box.row() - op = box.operator("bim.load_type_thumbnails", text="", icon="FILE_REFRESH", emboss=False) - op.ifc_class = AuthoringData.data["ifc_class_current"] + box.operator("bim.load_type_thumbnails", text="", icon="FILE_REFRESH") row = box.row() row.alignment = "CENTER" @@ -222,7 +210,9 @@ class BIM_PT_array(bpy.types.Panel): if not ArrayData.is_loaded: ArrayData.load() - props = context.active_object.BIMArrayProperties + obj = context.active_object + assert obj + props = tool.Model.get_array_props(obj) if ArrayData.data["parameters"]: row = self.layout.row(align=True) @@ -296,7 +286,7 @@ class BIM_PT_stair(bpy.types.Panel): obj = context.active_object assert obj - props = obj.BIMStairProperties + props = tool.Model.get_stair_props(obj) if StairData.data["pset_data"]: row = self.layout.row(align=True) @@ -409,7 +399,9 @@ class BIM_PT_window(bpy.types.Panel): if not WindowData.is_loaded: WindowData.load() - props = context.active_object.BIMWindowProperties + obj = context.active_object + assert obj + props = tool.Model.get_window_props(obj) if WindowData.data["pset_data"]: row = self.layout.row(align=True) @@ -450,8 +442,14 @@ class BIM_PT_window(bpy.types.Panel): for panel_i in range(number_of_panels): cols[panel_i + 1].prop(props, prop, index=panel_i, text="") - update_window_modifier_bmesh(context) - + self.layout.use_property_split = True + self.layout.label(text="Material Properties") + row = prop_with_search(self.layout, props, "lining_material") + tool.Model.draw_material_ui_select(row, props.lining_material) + row = prop_with_search(self.layout, props, "framing_material", text="Panel Material") + tool.Model.draw_material_ui_select(row, props.framing_material) + row = prop_with_search(self.layout, props, "glazing_material") + tool.Model.draw_material_ui_select(row, props.glazing_material) else: row.operator("bim.enable_editing_window", icon="GREASEPENCIL", text="") row.operator("bim.remove_window", icon="X", text="") @@ -494,7 +492,6 @@ class BIM_PT_window(bpy.types.Panel): prop_value = panel_props[prop_name][panel_i] r.label(text=str(prop_value)) r = cols[panel_i + 1].row() - else: row = self.layout.row() row.label(text="No Window Found") @@ -547,11 +544,14 @@ class BIM_PT_door(bpy.types.Panel): self.layout.use_property_split = True self.layout.label(text="Material Properties") - self.layout.prop(props, "lining_material") - self.layout.prop(props, "panel_material") - - update_door_modifier_bmesh(context) + row = prop_with_search(self.layout, props, "lining_material") + tool.Model.draw_material_ui_select(row, props.lining_material) + row = prop_with_search(self.layout, props, "framing_material", text="Panel Material") + tool.Model.draw_material_ui_select(row, props.framing_material) + if props.transom_thickness: + row = prop_with_search(self.layout, props, "glazing_material") + tool.Model.draw_material_ui_select(row, props.framing_material) else: row.operator("bim.enable_editing_door", icon="GREASEPENCIL", text="") row.operator("bim.remove_door", icon="X", text="") @@ -598,7 +598,9 @@ class BIM_PT_railing(bpy.types.Panel): if not RailingData.is_loaded: RailingData.load() - props = context.active_object.BIMRailingProperties + obj = context.active_object + assert obj + props = tool.Model.get_railing_props(obj) if RailingData.data["pset_data"]: row = self.layout.row(align=True) @@ -663,7 +665,7 @@ class BIM_PT_roof(bpy.types.Panel): obj = context.active_object assert obj - props = obj.BIMRoofProperties + props = tool.Model.get_roof_props(obj) if RoofData.data["pset_data"]: row = self.layout.row(align=True) @@ -699,6 +701,7 @@ class BIM_PT_roof(bpy.types.Panel): row.operator("bim.add_roof", icon="ADD", text="") -def add_menu(self, context): - self.layout.operator("bim.launch_add_element", icon_value=bonsai.bim.icons["IFC"].icon_id, text="IFC Element") +def add_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None: + self.layout.operator_context = "INVOKE_REGION_WIN" + self.layout.operator("bim.add_element", icon_value=bonsai.bim.icons["IFC"].icon_id, text="IFC Element") self.layout.separator() diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 73df5e8d95..7363844d9a 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -21,6 +21,7 @@ import bpy import copy import math +import numpy as np import ifcopenshell import ifcopenshell.api import ifcopenshell.util.unit @@ -35,7 +36,7 @@ import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees, radians +from math import pi, sin, cos, degrees from mathutils import Vector, Matrix from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator @@ -57,6 +58,66 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) +class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_walls_to_underside" + bl_label = "Extend Walls To Underside" + bl_description = "Extend and clip selected walls at the bottom faces of an object" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + slab = None + walls = [] + if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)): + slab = obj + for obj in tool.Blender.get_selected_objects(include_active=False): + if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2": + walls.append(obj) + if slab and walls: + core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls) + else: + self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element") + + +class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.extend_walls_to_wall" + bl_label = "Extend Walls To Wall" + bl_description = "Extend and trim selected walls to another wall" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + target_obj = None + objs = [] + if ( + (obj := tool.Blender.get_active_object(is_selected=True)) + and (element := tool.Ifc.get_entity(obj)) + and tool.Model.get_usage_type(element) == "LAYER2" + ): + target_obj = obj + for obj in tool.Blender.get_selected_objects(include_active=False): + if ( + obj != target_obj + and (element := tool.Ifc.get_entity(obj)) + and tool.Model.get_usage_type(element) == "LAYER2" + ): + objs.append(obj) + if target_obj and objs: + if tool.Ifc.is_moved(target_obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=target_obj) + joiner = DumbWallJoiner() + target_element = tool.Ifc.get_entity(target_obj) + for obj in objs: + if tool.Ifc.is_moved(obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + element = tool.Ifc.get_entity(obj) + ifcopenshell.api.geometry.connect_wall( + tool.Ifc.get(), wall1=element, wall2=target_element, is_atpath=True + ) + joiner.recreate_wall(element, obj) + joiner.recreate_wall(target_element, target_obj) + else: + self.report({"ERROR"}, "Please select at least one LAYER2 element and one active LAYER2 element") + + class AlignWall(bpy.types.Operator): bl_idname = "bim.align_wall" bl_label = "Align Wall" @@ -220,7 +281,8 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): layer2_objs = [] other_objs = [] - x_angle = self.x_angle + x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle + x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) @@ -232,50 +294,67 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): extrusion = tool.Model.get_extrusion(representation) if not extrusion: return - x, y, z = extrusion.ExtrudedDirection.DirectionRatios existing_x_angle = tool.Model.get_existing_x_angle(extrusion) - perpendicular_depth = extrusion.Depth / (1 / cos(existing_x_angle)) - if tool.Model.get_usage_type(element) == "LAYER2": - extrusion.Depth = abs(perpendicular_depth * (1 / cos(x_angle))) - if tool.Model.get_usage_type(element) == "LAYER3": - # TODO support angles between 91 and 179 - if x_angle > radians(90) or x_angle < -radians(90): - return - extrusion.Depth = perpendicular_depth * (1 / cos(x_angle)) - extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle if tool.Model.get_usage_type(element) == "LAYER2": + x, y, z = extrusion.ExtrudedDirection.DirectionRatios + depth = extrusion.Depth / abs(1 / cos(existing_x_angle)) + perpendicular_depth = depth * abs(1 / cos(x_angle)) + print(extrusion.Depth, perpendicular_depth) + extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle)) layer2_objs.append(obj) + extrusion.Depth = perpendicular_depth else: if tool.Model.get_usage_type(element) == "LAYER3": + existing_x_angle = obj.rotation_euler.x + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle # Reset the transformation and returns to the original points with 0 degrees extrusion.SweptArea.OuterCurve.Points.CoordList = [ - (p[0], p[1] * (cos(existing_x_angle))) for p in extrusion.SweptArea.OuterCurve.Points.CoordList + (p[0], p[1] * abs(cos(existing_x_angle))) + for p in extrusion.SweptArea.OuterCurve.Points.CoordList ] # Apply the transformation for the new x_angle extrusion.SweptArea.OuterCurve.Points.CoordList = [ - (p[0], p[1] * (1 / cos(x_angle))) for p in extrusion.SweptArea.OuterCurve.Points.CoordList + (p[0], p[1] * abs(1 / cos(x_angle))) for p in extrusion.SweptArea.OuterCurve.Points.CoordList ] # The extrusion direction calculated previously default to the positive direction # Here we set the extrusion direction to negative if that's the case - x, y, z = extrusion.ExtrudedDirection.DirectionRatios + direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle))) + # direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios) layer_params = tool.Model.get_material_layer_parameters(element) - perpendicular_offset = layer_params["offset"] * (1 / cos(x_angle)) - offset_vector = Vector((0.0, 0.0, perpendicular_offset / unit_scale)) - if layer_params["direction_sense"] == "NEGATIVE": - y = -abs(y) if x_angle > 0 else abs(y) - z = -abs(z) - extrusion.ExtrudedDirection.DirectionRatios = (x, y, z) + perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale + perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale + offset_direction = direction_ratios.copy() - if perpendicular_offset != 0.0 and not extrusion.Position: - tool.Model.add_extrusion_position(extrusion, perpendicular_offset) + # Check angle and z direction to determine whether the extrusion direction is positive or negative + if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or ( + abs(x_angle) > (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is positive. If the layer_parameter is set to negative, + # then the we change the extrusion direction. + if layer_params["direction_sense"] == "NEGATIVE": + direction_ratios *= -1 + elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or ( + (x_angle) < (pi / 2) and direction_ratios.z < 0 + ): + # The extrusion direction is negative. If the layer_parameter is set to positive, + # then the we change the extrusion direction. + # then the we change the extrusion direction. And the offset direction should remain positive + # for either direction sense, so we change it. + offset_direction *= -1 + if layer_params["direction_sense"] == "POSITIVE": + direction_ratios *= -1 - # Update the extrusion's location based on its current rotation angle - if extrusion.Position: - rot_matrix = Matrix.Rotation(x_angle, 4, "X") - rot_offset = offset_vector @ rot_matrix - extrusion.Position.Location.Coordinates = tuple(rot_offset) + extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios) + extrusion.Depth = perpendicular_depth + + if extrusion.Position or perpendicular_offset != 0: + position = offset_direction * perpendicular_offset + tool.Model.add_extrusion_position(extrusion, position) bonsai.core.geometry.switch_representation( tool.Ifc, @@ -288,11 +367,10 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): ) # Object rotation - local_rot_mat = obj.rotation_euler.to_matrix() - rot_mat = mathutils.Matrix.Rotation(x_angle - existing_x_angle, 4, "X") - new_rot_mat = local_rot_mat.to_4x4() @ rot_mat - new_rot_euler = new_rot_mat.to_euler() - obj.rotation_euler = new_rot_euler + current_z_rot = obj.rotation_euler.z + rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X") + obj.rotation_euler = rot_mat.to_euler() + obj.rotation_euler.z = current_z_rot if layer2_objs: DumbWallRecalculator().recalculate(layer2_objs) @@ -326,7 +404,8 @@ class AddWallsFromSlab(bpy.types.Operator, tool.Ifc.Operator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.relating_type = None props = tool.Model.get_model_props() relating_type_id = props.relating_type_id @@ -347,10 +426,10 @@ class AddWallsFromSlab(bpy.types.Operator, tool.Ifc.Operator): if walls: for wall1, wall2 in zip(walls, walls[1:] + [walls[0]]): - DumbWallJoiner().join_V(wall2["obj"], wall1["obj"]) + DumbWallJoiner().connect(wall2["obj"], wall1["obj"]) -class DrawPolylineWall(bpy.types.Operator, PolylineOperator): +class DrawPolylineWall(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): bl_idname = "bim.draw_polyline_wall" bl_label = "Draw Polyline Wall" bl_options = {"REGISTER", "UNDO"} @@ -359,8 +438,9 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): - super().__init__() + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) self.relating_type = None props = tool.Model.get_model_props() relating_type_id = props.relating_type_id @@ -377,7 +457,7 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator): walls, is_polyline_closed = DumbWallGenerator(self.relating_type).generate("POLYLINE") for wall in walls: - model = IfcStore.get_file() + model = tool.Ifc.get() element = tool.Ifc.get_entity(wall["obj"]) material = ifcopenshell.util.element.get_material(element) material_set_usage = model.by_id(material.id()) @@ -393,12 +473,15 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator): if walls: if is_polyline_closed: for wall1, wall2 in zip(walls, walls[1:] + [walls[0]]): - DumbWallJoiner().join_V(wall2["obj"], wall1["obj"]) + DumbWallJoiner().connect(wall2["obj"], wall1["obj"]) else: for wall1, wall2 in zip(walls[:-1], walls[1:]): - DumbWallJoiner().join_V(wall2["obj"], wall1["obj"]) + DumbWallJoiner().connect(wall2["obj"], wall1["obj"]) def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): if not self.relating_type: self.report({"WARNING"}, "You need to select a wall type.") PolylineDecorator.uninstall() @@ -471,6 +554,9 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): super().invoke(context, event) ProductDecorator.install(context) self.tool_state.use_default_container = True @@ -575,11 +661,19 @@ class DumbWallRecalculator: queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set() for wall in walls: element = tool.Ifc.get_entity(wall) + if tool.Ifc.is_moved(wall): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall) queue.add((element, wall)) for rel in getattr(element, "ConnectedTo", []): - queue.add((rel.RelatedElement, tool.Ifc.get_object(rel.RelatedElement))) + obj = tool.Ifc.get_object(rel.RelatedElement) + if tool.Ifc.is_moved(obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + queue.add((rel.RelatedElement, obj)) for rel in getattr(element, "ConnectedFrom", []): - queue.add((rel.RelatingElement, tool.Ifc.get_object(rel.RelatingElement))) + obj = tool.Ifc.get_object(rel.RelatingElement) + if tool.Ifc.is_moved(obj): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + queue.add((rel.RelatingElement, obj)) joiner = DumbWallJoiner() for element, wall in queue: if tool.Model.get_usage_type(element) == "LAYER2" and wall: @@ -592,7 +686,7 @@ class DumbWallGenerator: self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) def generate(self, insertion_type="CURSOR"): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.layers = tool.Model.get_material_layer_parameters(self.relating_type) if not self.layers["thickness"]: return @@ -622,14 +716,6 @@ class DumbWallGenerator: elif insertion_type == "CURSOR": return self.derive_from_cursor() - def has_sketch(self): - return ( - bpy.context.scene.grease_pencil - and len(bpy.context.scene.grease_pencil.layers) == 1 - and bpy.context.scene.grease_pencil.layers[0].info == "Note" - and bpy.context.scene.grease_pencil.layers[0].active_frame.strokes - ) - def derive_from_polyline(self) -> tuple[list[Union[dict[str, Any], None]], bool]: polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline polyline_points = polyline_data[0].polyline_points if polyline_data else [] @@ -661,7 +747,6 @@ class DumbWallGenerator: polyline_points = [slab_obj.matrix_world @ Vector((p[0], p[1], elevation)) for p in polyline_points] if not tool.Cad.is_counter_clockwise_order(polyline_points[0], polyline_points[1], polyline_points[2]): polyline_points = polyline_points[::-1] - is_polyline_closed = True walls = [] for i in range(len(polyline_points) - 1): vec1 = polyline_points[i] @@ -670,44 +755,9 @@ class DumbWallGenerator: walls.append(self.create_wall_from_2_points(coords)) return walls - def derive_from_sketch(self): - objs = [] - strokes = [] - layer = bpy.context.scene.grease_pencil.layers[0] - - for stroke in layer.active_frame.strokes: - if len(stroke.points) == 1: - continue - data = self.create_wall_from_2_points((stroke.points[0].co, stroke.points[-1].co), round=True) - if data: - strokes.append(data) - objs.append(data["obj"]) - - if len(objs) < 2: - return objs - - l_joins = set() - for stroke in strokes: - if not stroke["obj"]: - continue - for stroke2 in strokes: - if stroke2 == stroke or not stroke2["obj"]: - continue - if self.has_nearby_ends(stroke, stroke2): - wall_join = "-JOIN-".join(sorted([stroke["obj"].name, stroke2["obj"].name])) - if wall_join not in l_joins: - l_joins.add(wall_join) - DumbWallJoiner(stroke["obj"], stroke2["obj"]).join_L() - elif self.has_end_near_stroke(stroke, stroke2): - DumbWallJoiner(stroke["obj"], stroke2["obj"]).join_T() - bpy.context.scene.grease_pencil.layers.remove(layer) - return objs - def create_wall_from_2_points(self, coords, should_round=False) -> Union[dict[str, Any], None]: direction = coords[1] - coords[0] length = direction.length - if round(length, 4) < 0.1: - return data = {"coords": coords} self.length = length @@ -722,25 +772,6 @@ class DumbWallGenerator: data["obj"] = self.create_wall() return data - def has_end_near_stroke(self, stroke, stroke2): - point, distance = mathutils.geometry.intersect_point_line(stroke["coords"][0], *stroke2["coords"]) - if distance > 0 and distance < 1 and self.is_near(point, stroke["coords"][0]): - return True - point, distance = mathutils.geometry.intersect_point_line(stroke["coords"][1], *stroke2["coords"]) - if distance > 0 and distance < 1 and self.is_near(point, stroke["coords"][1]): - return True - - def has_nearby_ends(self, stroke, stroke2): - return ( - self.is_near(stroke["coords"][0], stroke2["coords"][0]) - or self.is_near(stroke["coords"][0], stroke2["coords"][1]) - or self.is_near(stroke["coords"][1], stroke2["coords"][0]) - or self.is_near(stroke["coords"][1], stroke2["coords"][1]) - ) - - def is_near(self, point1, point2): - return (point1 - point2).length < 0.1 - def derive_from_cursor(self) -> bpy.types.Object: RAYCAST_PRECISION = 0.01 self.location = bpy.context.scene.cursor.location @@ -902,7 +933,7 @@ class DumbWallPlaner: self, related_object: ifcopenshell.entity_instance, layer_set_direction: Optional[str] ) -> None: obj = tool.Ifc.get_object(related_object) - if not obj or not obj.data or not obj.data.BIMMeshProperties.ifc_definition_id: + if not obj or not tool.Geometry.get_active_representation(obj): return material = ifcopenshell.util.element.get_material(related_object) @@ -939,12 +970,15 @@ class DumbWallJoiner: element1 = tool.Ifc.get_entity(wall1) if not element1: return + + if tool.Ifc.is_moved(wall1): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) + axis1 = tool.Model.get_wall_axis(wall1) axis2 = copy.deepcopy(axis1) intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"]) if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)): return - connection = "ATEND" if cut_percentage > 0.5 else "ATSTART" wall2 = self.duplicate_wall(wall1) element2 = tool.Ifc.get_entity(wall2) @@ -1009,172 +1043,94 @@ class DumbWallJoiner: # The filling should be moved from element1 to element2. FilledOpeningGenerator().generate(filling_obj, wall2, target=filling_obj.matrix_world.translation) - axis1["reference"][1] = intersect - axis2["reference"][0] = intersect + p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) + p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale + self.set_axis(element1, p1, p3) + self.set_axis(element2, p3, p2) - # Create a connection between the walls - wall1_end = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART" - wall2_end = "ATEND" if tool.Cad.edge_percent(intersect, axis2["reference"]) > 0.5 else "ATSTART" - - ifcopenshell.api.run( - "geometry.connect_path", - tool.Ifc.get(), - relating_element=element1, - related_element=element2, - relating_connection=wall1_end, - related_connection=wall2_end, - description="MITRE", - ) - - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - self.recreate_wall(element2, wall2, axis2["reference"], axis2["reference"]) + self.recreate_wall(element1, wall1) + self.recreate_wall(element2, wall2) def flip(self, wall1: bpy.types.Object) -> None: - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - if tool.Ifc.is_moved(wall1): bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) - element1 = tool.Ifc.get_entity(wall1) - if not element1 or tool.Model.get_usage_type(element1) != "LAYER2": + if ( + not (element1 := tool.Ifc.get_entity(wall1)) + or not (usage := ifcopenshell.util.element.get_material(element1)) + or not usage.is_a("IfcMaterialLayerSetUsage") + or usage.LayerSetDirection != "AXIS2" + ): return - for rel in element1.ConnectedTo: - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingConnectionType in ["ATSTART", "ATEND"]: - rel.RelatingConnectionType = "ATSTART" if rel.RelatingConnectionType == "ATEND" else "ATEND" - for rel in element1.ConnectedFrom: - if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedConnectionType in ["ATSTART", "ATEND"]: - rel.RelatedConnectionType = "ATSTART" if rel.RelatedConnectionType == "ATEND" else "ATEND" + thickness = sum([l.LayerThickness for l in usage.ForLayerSet.MaterialLayers]) + if usage.DirectionSense == "POSITIVE": + usage.DirectionSense = "NEGATIVE" + else: + thickness *= -1 + usage.DirectionSense = "POSITIVE" - layers1 = tool.Model.get_material_layer_parameters(element1) - axis1 = tool.Model.get_wall_axis(wall1, layers1) - axis1["reference"][0], axis1["reference"][1] = axis1["reference"][1], axis1["reference"][0] - - flip_matrix = Matrix.Rotation(pi, 4, "Z") - wall1.matrix_world = wall1.matrix_world @ flip_matrix - wall1.matrix_world[0][3], wall1.matrix_world[1][3] = axis1["reference"][0] - bpy.context.view_layer.update() - - # The wall should flip, but all openings and fills should stay and shift to the opposite axis - opening_matrixes = {} - filling_matrixes = {} - for opening in [r.RelatedOpeningElement for r in element1.HasOpenings]: - opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist()) - opening_matrix.translation *= unit_scale - location = opening_matrix.translation - location_on_base = tool.Cad.point_on_edge(location, axis1["base"]) - location_on_side = tool.Cad.point_on_edge(location, axis1["side"]) - if (location_on_base - location).length < (location_on_side - location).length: - axis_offset = location_on_side - location_on_base - offset_from_axis = location_on_base - location - opening_matrix.translation = location_on_base - axis_offset - offset_from_axis - else: - axis_offset = location_on_side - location_on_base - offset_from_axis = location_on_side - location - opening_matrix.translation = location_on_side - axis_offset - offset_from_axis - opening_matrixes[opening] = opening_matrix - - for filling in [r.RelatedBuildingElement for r in opening.HasFillings]: - filling_obj = tool.Ifc.get_object(filling) - filling_matrix = filling_obj.matrix_world.copy() - - location = filling_matrix.translation - location_on_base = tool.Cad.point_on_edge(location, axis1["base"]) - location_on_side = tool.Cad.point_on_edge(location, axis1["side"]) - if (location_on_base - location).length < (location_on_side - location).length: - axis_offset = location_on_side - location_on_base - offset_from_axis = location_on_base - location - filling_matrix.translation = location_on_base - axis_offset - offset_from_axis - else: - axis_offset = location_on_side - location_on_base - offset_from_axis = location_on_side - location - filling_matrix.translation = location_on_side - axis_offset - offset_from_axis - filling_matrixes[filling] = filling_matrix - - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - DumbWallRecalculator().recalculate([wall1]) - - for opening in [r.RelatedOpeningElement for r in element1.HasOpenings]: - opening_matrix = opening_matrixes[opening] - ifcopenshell.api.run( - "geometry.edit_object_placement", tool.Ifc.get(), product=opening, matrix=opening_matrix - ) - for filling in [r.RelatedBuildingElement for r in opening.HasFillings]: - filling_matrix = filling_matrixes[filling] - filling_obj = tool.Ifc.get_object(filling) - filling_obj.matrix_world = filling_matrix - - if filling_matrixes: - bpy.context.view_layer.update() - - body = ifcopenshell.util.representation.get_representation(element1, "Model", "Body", "MODEL_VIEW") - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=wall1, - representation=body, - should_reload=True, - is_global=True, - should_sync_changes_first=False, + matrix = ifcopenshell.util.placement.get_local_placement(element1.ObjectPlacement) + offset = matrix[:, 1] * thickness + matrix[:, 3] += offset + ifcopenshell.api.geometry.edit_object_placement( + tool.Ifc.get(), product=element1, matrix=matrix, is_si=False, should_transform_children=False ) + self.recreate_wall(element1, wall1) def merge(self, wall1, wall2): + if tool.Ifc.is_moved(wall1): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) + if tool.Ifc.is_moved(wall2): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall2) + element1 = tool.Ifc.get_entity(wall1) element2 = tool.Ifc.get_entity(wall2) - axis1 = tool.Model.get_wall_axis(wall1) - axis2 = tool.Model.get_wall_axis(wall2) - angle = tool.Cad.angle_edges(axis1["reference"], axis2["reference"], signed=False, degrees=True) - if not tool.Cad.is_x(angle, 0, tolerance=0.001): + p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) + p3, p4 = ifcopenshell.util.representation.get_reference_line(element2) + + matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(element1.ObjectPlacement)) + matrix2 = ifcopenshell.util.placement.get_local_placement(element2.ObjectPlacement) + + p3 = (matrix1i @ matrix2 @ np.concatenate((p3, (0, 1))))[:2] + p4 = (matrix1i @ matrix2 @ np.concatenate((p4, (0, 1))))[:2] + + if not np.isclose(p1[1], p4[1]) or not np.isclose(p3[1], p4[1]): return - intersect1, connection1 = mathutils.geometry.intersect_point_line(axis2["reference"][0], *axis1["reference"]) - if not tool.Cad.is_x((intersect1 - axis2["reference"][0]).length, 0): - return - - intersect2, connection2 = mathutils.geometry.intersect_point_line(axis2["reference"][1], *axis1["reference"]) - if not tool.Cad.is_x((intersect2 - axis2["reference"][1]).length, 0): - return - - changed_connections = set() - - if connection1 < 0: - changed_connections.add("ATSTART") - axis1["reference"][0] = intersect2 if connection2 < connection1 else intersect1 - elif connection1 > 1: - changed_connections.add("ATEND") - axis1["reference"][1] = intersect2 if connection2 > connection1 else intersect1 - - for connection in changed_connections: - ifcopenshell.api.run( - "geometry.disconnect_path", tool.Ifc.get(), element=element1, connection_type=connection - ) + x_ordinates = tuple(co[0] for co in (p1, p2, p3, p4)) + p1[0] = min(x_ordinates) + p2[0] = max(x_ordinates) + self.set_axis(element1, p1, p2) for rel in element2.ConnectedTo: - if rel.RelatingConnectionType in changed_connections: - other = tool.Ifc.get_object(rel.RelatedElement) - ifcopenshell.api.run( - "geometry.connect_path", - tool.Ifc.get(), - relating_element=element1, - related_element=rel.RelatedElement, - relating_connection=rel.RelatingConnectionType, - related_connection=rel.RelatedConnectionType, - ) + ifcopenshell.api.geometry.disconnect_path( + tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType + ) + ifcopenshell.api.geometry.connect_path( + tool.Ifc.get(), + relating_element=element1, + related_element=rel.RelatedElement, + relating_connection=rel.RelatingConnectionType, + related_connection=rel.RelatedConnectionType, + ) for rel in element2.ConnectedFrom: - if rel.RelatedConnectionType in changed_connections: - ifcopenshell.api.run( - "geometry.connect_path", - tool.Ifc.get(), - relating_element=rel.RelatingElement, - related_element=element1, - relating_connection=rel.RelatingConnectionType, - related_connection=rel.RelatedConnectionType, - ) + ifcopenshell.api.geometry.disconnect_path( + tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType + ) + ifcopenshell.api.geometry.connect_path( + tool.Ifc.get(), + relating_element=rel.RelatingElement, + related_element=element1, + relating_connection=rel.RelatingConnectionType, + related_connection=rel.RelatedConnectionType, + ) - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - bpy.data.objects.remove(wall2) + self.recreate_wall(element1, wall1) + + tool.Geometry.delete_ifc_object(wall2) def duplicate_wall(self, wall1): wall2 = wall1.copy() @@ -1207,62 +1163,49 @@ class DumbWallJoiner: self.recreate_wall(element1, wall1) - def join_L(self, wall1, wall2): - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(wall2) - axis1 = tool.Model.get_wall_axis(wall1) - axis2 = tool.Model.get_wall_axis(wall2) - intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"]) - if intersect: - intersect, _ = intersect + def set_axis(self, wall, p1, p2): + axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW") + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + item = builder.polyline([p1, p2]) + rep = builder.get_representation(axis, items=[item]) + if old_rep := ifcopenshell.util.representation.get_representation(wall, axis): + ifcopenshell.util.element.replace_element(old_rep, rep) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_rep) else: - return - wall1_end = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART" - wall2_end = "ATEND" if tool.Cad.edge_percent(intersect, axis2["reference"]) > 0.5 else "ATSTART" + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=wall, representation=rep) - ifcopenshell.api.run( - "geometry.connect_path", - tool.Ifc.get(), - relating_element=element1, - related_element=element2, - relating_connection=wall1_end, - related_connection=wall2_end, - description="BUTT", - ) - - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - self.recreate_wall(element2, wall2, axis2["reference"], axis2["reference"]) - - def join_E(self, wall1, target): + def extend(self, wall1, target): + if tool.Ifc.is_moved(wall1): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) element1 = tool.Ifc.get_entity(wall1) - - axis1 = tool.Model.get_wall_axis(wall1) - intersect, connection = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"]) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + target = (wall1.matrix_world.inverted() @ target).to_2d() / unit_scale + intersect, connection = mathutils.geometry.intersect_point_line(target, p1, p2) connection = "ATEND" if connection > 0.5 else "ATSTART" ifcopenshell.api.run("geometry.disconnect_path", tool.Ifc.get(), element=element1, connection_type=connection) - axis = copy.deepcopy(axis1["reference"]) - body = copy.deepcopy(axis1["reference"]) - axis[1 if connection == "ATEND" else 0] = intersect - body[1 if connection == "ATEND" else 0] = intersect - - self.recreate_wall(element1, wall1, axis, body) + if connection == "ATEND": + self.set_axis(element1, p1, intersect) + else: + self.set_axis(element1, intersect, p2) + self.recreate_wall(element1, wall1) def set_length(self, wall1, si_length): element1 = tool.Ifc.get_entity(wall1) if not element1: return + if tool.Ifc.is_moved(wall1): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1) ifcopenshell.api.run("geometry.disconnect_path", tool.Ifc.get(), element=element1, connection_type="ATEND") - axis1 = tool.Model.get_wall_axis(wall1) - axis = copy.deepcopy(axis1["reference"]) - body = copy.deepcopy(axis1["reference"]) - end = (wall1.matrix_world @ Vector((si_length, 0, 0))).to_2d() - axis[1] = end - body[1] = end - self.recreate_wall(element1, wall1, axis, body) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + p1, p2 = ifcopenshell.util.representation.get_reference_line(element1) + p2[0] = p1[0] + si_length / unit_scale + self.set_axis(element1, p1, p2) + self.recreate_wall(element1, wall1) def join_T(self, wall1, wall2): element1 = tool.Ifc.get_entity(wall1) @@ -1288,177 +1231,36 @@ class DumbWallJoiner: self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - def join_V(self, wall1, wall2): - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(wall2) - axis1 = tool.Model.get_wall_axis(wall1) - axis2 = tool.Model.get_wall_axis(wall2) - intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"]) - # Allow connecting contiguous walls - if not intersect: - for v1 in axis1["reference"]: - for v2 in axis2["reference"]: - if tool.Cad.are_vectors_equal(v1, v2, 1e-5): - intersect = (v1, v2) - if intersect: - intersect, _ = intersect - else: - return - wall1_end = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART" - wall2_end = "ATEND" if tool.Cad.edge_percent(intersect, axis2["reference"]) > 0.5 else "ATSTART" - - ifcopenshell.api.run( - "geometry.connect_path", - tool.Ifc.get(), - relating_element=element1, - related_element=element2, - relating_connection=wall1_end, - related_connection=wall2_end, - description="MITRE", - ) - - self.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - self.recreate_wall(element2, wall2, axis2["reference"], axis2["reference"]) + def connect(self, obj1, obj2): + wall1 = tool.Ifc.get_entity(obj1) + wall2 = tool.Ifc.get_entity(obj2) + if tool.Ifc.is_moved(obj1): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj1) + if tool.Ifc.is_moved(obj2): + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj2) + ifcopenshell.api.geometry.connect_wall(tool.Ifc.get(), wall1=wall1, wall2=wall2) + self.recreate_wall(wall1, obj1) + self.recreate_wall(wall2, obj2) def recreate_wall(self, element: ifcopenshell.entity_instance, obj: bpy.types.Object, axis=None, body=None) -> None: - if axis is None or body is None: - axis = body = tool.Model.get_wall_axis(obj)["reference"] - self.axis = copy.deepcopy(axis) - self.body = copy.deepcopy(body) - extrusion_data = self.get_extrusion_data(tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id)) - height = extrusion_data["height"] - x_angle = extrusion_data["x_angle"] - self.clippings = [] - layers = tool.Model.get_material_layer_parameters(element) - - for rel in element.ConnectedTo: - if rel.is_a("IfcRelConnectsPathElements"): - connection = rel.RelatingConnectionType - other = tool.Ifc.get_object(rel.RelatedElement) - if connection not in ["ATPATH", "NOTDEFINED"]: - self.join( - obj, other, connection, rel.RelatedConnectionType, is_relating=True, description=rel.Description - ) - for rel in element.ConnectedFrom: - if rel.is_a("IfcRelConnectsPathElements"): - connection = rel.RelatedConnectionType - other = tool.Ifc.get_object(rel.RelatingElement) - if connection not in ["ATPATH", "NOTDEFINED"]: - self.join( - obj, - other, - connection, - rel.RelatingConnectionType, - is_relating=False, - description=rel.Description, - ) - - previous_matrix = obj.matrix_world.copy() - previous_origin = previous_matrix.translation.xy - obj.matrix_world.translation.xy = self.body[0] - bpy.context.view_layer.update() - - for rel in element.ConnectedFrom: - if rel.is_a() == "IfcRelConnectsElements": - height = self.clip(obj, tool.Ifc.get_object(rel.RelatingElement)) - - new_matrix = copy.deepcopy(obj.matrix_world) - new_matrix.invert() - - for clipping in self.clippings: - if clipping["operand_type"] == "IfcHalfSpaceSolid": - clipping["matrix"] = new_matrix @ clipping["matrix"] - - length = (self.body[1] - self.body[0]).length - - if self.axis_context: - axis = [(new_matrix @ a.to_3d()).to_2d() for a in self.axis] - new_axis = ifcopenshell.api.run( - "geometry.add_axis_representation", tool.Ifc.get(), context=self.axis_context, axis=axis - ) - old_axis = ifcopenshell.util.representation.get_representation(element, "Plan", "Axis", "GRAPH_VIEW") - if old_axis: - for inverse in tool.Ifc.get().get_inverse(old_axis): - ifcopenshell.util.element.replace_attribute(inverse, old_axis, new_axis) - bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_axis) - else: - ifcopenshell.api.run( - "geometry.assign_representation", tool.Ifc.get(), product=element, representation=new_axis - ) - - new_body = ifcopenshell.api.run( - "geometry.add_wall_representation", - tool.Ifc.get(), - context=self.body_context, - length=length, - height=height, - x_angle=x_angle, - direction_sense=layers["direction_sense"], - offset=layers["offset"], - thickness=layers["thickness"], - clippings=self.clippings, - booleans=tool.Model.get_manual_booleans(element), - ) - - old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - if old_body: - for inverse in tool.Ifc.get().get_inverse(old_body): - ifcopenshell.util.element.replace_attribute(inverse, old_body, new_body) - obj.data.BIMMeshProperties.ifc_definition_id = int(new_body.id()) - obj.data.name = f"{self.body_context.id()}/{new_body.id()}" - bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_body) - else: - ifcopenshell.api.run( - "geometry.assign_representation", tool.Ifc.get(), product=element, representation=new_body - ) - - wall_moved = tool.Ifc.is_moved(obj) - if wall_moved: - # Openings should move with the host overall ... - # ... except their position should stay the same along the local X axis of the wall - for opening in [ - r.RelatedOpeningElement for r in element.HasOpenings if not r.RelatedOpeningElement.HasFillings - ]: - percent = tool.Cad.edge_percent( - self.body[0], (previous_origin, (previous_matrix @ Vector((1, 0, 0))).to_2d()) - ) - is_x_offset_increased = True if percent < 0 else False - - change_in_x = (self.body[0] - previous_origin).length / self.unit_scale - coordinates = list(opening.ObjectPlacement.RelativePlacement.Location.Coordinates) - if is_x_offset_increased: - coordinates[0] += change_in_x - else: - coordinates[0] -= change_in_x - opening.ObjectPlacement.RelativePlacement.Location.Coordinates = coordinates - - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - - # If opening has filling then stick to the filling's position - # We're applying new openings position only after wall position is applied - for opening in [r.RelatedOpeningElement for r in element.HasOpenings if r.RelatedOpeningElement.HasFillings]: - similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening) - filling_obj = tool.Ifc.get_object(opening.HasFillings[0].RelatedBuildingElement) - filling_moved = tool.Ifc.is_moved(filling_obj) - if filling_moved: - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj) - if filling_moved or wall_moved: - ifcopenshell.api.run( - "geometry.edit_object_placement", tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world - ) - bonsai.core.geometry.edit_similar_opening_placement(tool.Geometry, opening, similar_openings) - + rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, obj=obj, - representation=new_body, + representation=rep, should_reload=True, is_global=True, should_sync_changes_first=False, ) tool.Geometry.record_object_materials(obj) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + matrix[:, 3] *= unit_scale + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix) + tool.Geometry.record_object_position(obj) + def create_matrix(self, p, x, y, z): return Matrix([x, y, z, p]).to_4x4().transposed() @@ -1473,7 +1275,7 @@ class DumbWallJoiner: results["direction"] = Vector(item.ExtrudedDirection.DirectionRatios) results["x_angle"] = Vector((0, 1)).angle_signed(Vector((y, z))) results["is_sloped"] = True - results["height"] = (item.Depth * self.unit_scale) / (1 / cos(results["x_angle"])) + results["height"] = (item.Depth * self.unit_scale) / abs(1 / cos(results["x_angle"])) break elif item.is_a("IfcBooleanClippingResult"): # should be before IfcBooleanResult check item = item.FirstOperand @@ -1486,226 +1288,12 @@ class DumbWallJoiner: break return results - def join(self, wall1, wall2, connection1, connection2, is_relating=True, description="BUTT"): - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(wall2) - layers1 = tool.Model.get_material_layer_parameters(element1) - layers2 = tool.Model.get_material_layer_parameters(element2) - axis1 = tool.Model.get_wall_axis(wall1, layers1) - axis2 = tool.Model.get_wall_axis(wall2, layers2) - body1 = ifcopenshell.util.representation.get_representation(element1, "Model", "Body", "MODEL_VIEW") - body2 = ifcopenshell.util.representation.get_representation(element2, "Model", "Body", "MODEL_VIEW") - extrusion1 = self.get_extrusion_data(body1) - extrusion2 = self.get_extrusion_data(body2) - direction1 = (wall1.matrix_world.to_quaternion() @ extrusion1["direction"]).normalized() - direction2 = (wall2.matrix_world.to_quaternion() @ extrusion2["direction"]).normalized() - height1 = extrusion1["height"] * self.unit_scale - height2 = extrusion2["height"] * self.unit_scale - depth1 = direction1 * height1 - depth2 = direction2 * height2 - normal1 = (axis1["base"][1] - axis1["base"][0]).to_3d().normalized().cross(direction1) - normal2 = (axis2["base"][1] - axis2["base"][0]).to_3d().normalized().cross(direction2) - - angle = tool.Cad.angle_edges(axis1["reference"], axis2["reference"], signed=True, degrees=True) - if tool.Cad.is_x(abs(angle), (0, 180), tolerance=0.001): - return False - - # Work out axis line - intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"]) - if intersect: - intersect, _ = intersect - else: - return False - - proposed_axis = [self.axis[0], intersect] if connection1 == "ATEND" else [intersect, self.axis[1]] - - if tool.Cad.is_x(tool.Cad.angle_edges(self.axis, proposed_axis, degrees=True), 180, tolerance=0.001): - # The user has moved the wall into an invalid position that cannot connect at the desired end - return False - - self.axis = proposed_axis - - # Work out body - - # Bottom and top plane point - bp1 = wall1.matrix_world @ Vector(wall1.bound_box[0]) - bp2 = wall2.matrix_world @ Vector(wall2.bound_box[0]) - tp1 = wall1.matrix_world @ Vector(wall1.bound_box[1]) - - # Axis lines on bottom, for reference, base, and side axes - def to_3d_axis(axis, z): - return (Vector((*axis[0], z)), Vector((*axis[1], z))) - - bra1 = to_3d_axis(axis1["reference"], bp1.z) - bba1 = to_3d_axis(axis1["base"], bp1.z) - tba1 = to_3d_axis(axis1["base"], tp1.z) - bsa1 = to_3d_axis(axis1["side"], bp1.z) - bba2 = to_3d_axis(axis2["base"], bp2.z) - bsa2 = to_3d_axis(axis2["side"], bp2.z) - - # Intersecting the walls sides defined by planes gives 4 lines of intersection - # Line point, and line direction - lpb1, ldb1 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bba2[0], normal2) - lpb2, ldb2 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bsa2[0], normal2) - lps1, lds1 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bba2[0], normal2) - lps2, lds2 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bsa2[0], normal2) - - # Intersecting the 4 lines gives the 8 possible verts of intersection - # 4 on bottom, and 4 on top. 4 on our base line, 4 on our side line. - # Diagram: https://i.imgur.com/jwWx2Ox.png - # NOTE: bb/bs always equal lpb/lps? - bb1 = mathutils.geometry.intersect_line_plane(lpb1, lpb1 + ldb1, bp1, Vector((0, 0, 1))) - bb2 = mathutils.geometry.intersect_line_plane(lpb2, lpb2 + ldb2, bp1, Vector((0, 0, 1))) - bs1 = mathutils.geometry.intersect_line_plane(lps1, lps1 + lds1, bp1, Vector((0, 0, 1))) - bs2 = mathutils.geometry.intersect_line_plane(lps2, lps2 + lds2, bp1, Vector((0, 0, 1))) - - # similar to bb/bs but also have local z offset - tb1 = mathutils.geometry.intersect_line_plane(lpb1, lpb1 + ldb1, tp1, Vector((0, 0, 1))) - tb2 = mathutils.geometry.intersect_line_plane(lpb2, lpb2 + ldb2, tp1, Vector((0, 0, 1))) - ts1 = mathutils.geometry.intersect_line_plane(lps1, lps1 + lds1, tp1, Vector((0, 0, 1))) - ts2 = mathutils.geometry.intersect_line_plane(lps2, lps2 + lds2, tp1, Vector((0, 0, 1))) - - # Let's distinguish the 8 points by whether they are nearer or further away from the other end - # These 8 points will be used to find the final body position and clippings. - connected_at_end = connection1 == "ATEND" - i = 0 if connected_at_end else 1 - - def get_closest_and_furthest_vectors(ref_point_2d, vectors, clamp_axis=None): - def clamp_point_by_direction(point, edge): - percent = tool.Cad.edge_percent(point, edge) - if percent < 0: - return edge[0] - return point - - # When there is a small angle between walls, intersection points can occur outside the wall's axis. - # Which can lead to inaccuracies - therefore we bottom clamp them to stay within the axis - if clamp_axis: - # if wall connected at the start then reference point will be at the end - # therefore we reverse the axis - if not connected_at_end: - clamp_axis = clamp_axis[::-1] - vectors = tuple([clamp_point_by_direction(v, clamp_axis) for v in vectors]) - - return tool.Cad.closest_and_furthest_vectors(ref_point_2d.to_3d(), vectors) - - bbn, bbf = get_closest_and_furthest_vectors(axis1["base"][i], (bb1, bb2), bba1) - bsn, bsf = get_closest_and_furthest_vectors(axis1["side"][i], (bs1, bs2)) - tbn, tbf = get_closest_and_furthest_vectors(axis1["base"][i], (tb1, tb2), tba1) - tsn, tsf = get_closest_and_furthest_vectors(axis1["side"][i], (ts1, ts2)) - - j = 1 if connected_at_end else 0 - if description == "MITRE": - # Mitre joints are an unofficial convention - bsf_ = tool.Cad.point_on_edge(bsf, bba1) - tbf_ = tool.Cad.point_on_edge(tbf, bba1) - tsf_ = tool.Cad.point_on_edge(tsf, bba1) - new_body = tool.Cad.furthest_vector(bba1[i], (bbf, bsf_)) - new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tbf_)) - new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tsf_)).copy() - self.body[j] = tool.Cad.point_on_edge(new_body, bra1).to_2d() - - if connection1 == connection2: - if (connected_at_end and angle > 0) or (not connected_at_end and angle < 0): - if layers1["direction_sense"] == "POSITIVE": - pt = bbf.to_2d().to_3d() - x_axis = bsn - bbf - y_axis = tbf - bbf - else: - pt = bsf.to_2d().to_3d() - x_axis = bbn - bsf - y_axis = tsf - bsf - else: - if layers1["direction_sense"] == "POSITIVE": - pt = bbn.to_2d().to_3d() - x_axis = bsf - bbn - y_axis = tbn - bbn - else: - pt = bsn.to_2d().to_3d() - x_axis = bbf - bsn - y_axis = tsn - bsn - else: - if (connected_at_end and angle < 0) or (not connected_at_end and angle > 0): - if layers1["direction_sense"] == "POSITIVE": - pt = bbf.to_2d().to_3d() - x_axis = bsn - bbf - y_axis = tbf - bbf - else: - pt = bsf.to_2d().to_3d() - x_axis = bbn - bsf - y_axis = tsf - bsf - else: - if layers1["direction_sense"] == "POSITIVE": - pt = bbn.to_2d().to_3d() - x_axis = bsf - bbn - y_axis = tbn - bbn - else: - pt = bsn.to_2d().to_3d() - x_axis = bbf - bsn - y_axis = tsn - bsn - - if connection1 != "ATEND": - y_axis *= -1 - - x_axis.normalize() - y_axis.normalize() - z_axis = x_axis.cross(y_axis) - y_axis = z_axis.cross(x_axis) - - self.clippings.append( - { - "type": "IfcBooleanClippingResult", - "operand_type": "IfcHalfSpaceSolid", - "matrix": self.create_matrix(pt, x_axis, y_axis, z_axis), - } - ) - else: - # This is the standard L and T joints described by IFC - if ( - tool.Cad.is_x(abs(angle), (90, 270), tolerance=0.001) - and not extrusion1["is_sloped"] - and not extrusion2["is_sloped"] - ): - if is_relating: - self.body[j] = tool.Cad.point_on_edge(bbf, bra1).to_2d() - else: - self.body[j] = tool.Cad.point_on_edge(bbn, bra1).to_2d() - return True - - bsf_ = tool.Cad.point_on_edge(bsf, bba1) - tbf_ = tool.Cad.point_on_edge(tbf, bba1) - tsf_ = tool.Cad.point_on_edge(tsf, bba1) - new_body = tool.Cad.furthest_vector(bba1[i], (bbf, bsf_)).copy() - new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tbf_)).copy() - new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tsf_)).copy() - self.body[j] = tool.Cad.point_on_edge(new_body, bra1).to_2d() - - if is_relating: - pt = bbf.to_2d().to_3d() - x_axis = bsf - bbf - y_axis = tbf - bbf - else: - pt = bbn.to_2d().to_3d() - x_axis = bsn - bbn - y_axis = tbn - bbn - if connection1 != "ATEND": - y_axis *= -1 - z_axis = x_axis.cross(y_axis) - y_axis = z_axis.cross(x_axis) - - self.clippings.append( - { - "type": "IfcBooleanClippingResult", - "operand_type": "IfcHalfSpaceSolid", - "matrix": self.create_matrix(pt, x_axis, y_axis, z_axis), - } - ) - - return True - - def clip(self, wall1, slab2): + # TODO reimplement in new version and deprecate + def clip(self, wall1: bpy.types.Object, slab2: bpy.types.Object) -> float: """returns height of the clipped wall, adds clipping plane to `clippings`""" element1 = tool.Ifc.get_entity(wall1) element2 = tool.Ifc.get_entity(slab2) + assert element1 and element2 layers1 = tool.Model.get_material_layer_parameters(element1) axis1 = tool.Model.get_wall_axis(wall1, layers1) @@ -1713,10 +1301,25 @@ class DumbWallJoiner: bases = [axis1["base"][0].to_3d(), axis1["base"][1].to_3d(), axis1["side"][0].to_3d(), axis1["side"][1].to_3d()] bases = [Vector((v[0], v[1], wall1.matrix_world.translation.z)) for v in bases] # add wall Z location - extrusion = self.get_extrusion_data(tool.Ifc.get().by_id(wall1.data.BIMMeshProperties.ifc_definition_id)) + representation = tool.Geometry.get_active_representation(wall1) + assert representation + extrusion = self.get_extrusion_data(representation) wall_dir = wall1.matrix_world.to_quaternion() @ extrusion["direction"] - slab_pt = slab2.matrix_world @ Vector((0, 0, 0)) + slab_element = tool.Ifc.get_entity(slab2) + slab_params = tool.Model.get_material_layer_parameters(slab_element) + slab_representation = ifcopenshell.util.representation.get_representation( + slab_element, "Model", "Body", "MODEL_VIEW" + ) + assert slab_representation + slab_extrusion = tool.Model.get_extrusion(slab_representation) + existing_x_angle = tool.Model.get_existing_x_angle(slab_extrusion) + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle + existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle + offset = slab_params["offset"] + if slab_params["direction_sense"] == "NEGATIVE": + offset -= slab_params["thickness"] + slab_pt = slab2.matrix_world @ Vector((0, 0, 0)) + Vector((0, 0, offset * abs(1 / cos(existing_x_angle)))) slab_dir = slab2.matrix_world.to_quaternion() @ Vector((0, 0, -1)) tops = [mathutils.geometry.intersect_line_plane(b, b + wall_dir, slab_pt, slab_dir) for b in bases] diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index c82847eacd..a2ef7507f3 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -28,6 +28,7 @@ import bonsai.core.root import bonsai.core.geometry from ifcopenshell.api.geometry.add_window_representation import DEFAULT_PANEL_SCHEMAS import ifcopenshell.api +import ifcopenshell.api.material import ifcopenshell.util.element import ifcopenshell.util.representation import ifcopenshell.util.shape_builder @@ -41,8 +42,10 @@ V_ = tool.Blender.V_ def update_window_modifier_representation(context: bpy.types.Context) -> None: obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMWindowProperties + assert element + props = tool.Model.get_window_props(obj) ifc_file = tool.Ifc.get() si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) @@ -73,7 +76,7 @@ def update_window_modifier_representation(context: bpy.types.Context) -> None: } representation_data["panel_properties"].append(panel_data) - previously_active_context = tool.Geometry.get_active_representation_context(obj) + active_context = tool.Geometry.get_active_representation_context(obj) # ELEVATION_VIEW representation profile = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Profile", "ELEVATION_VIEW") @@ -88,8 +91,25 @@ def update_window_modifier_representation(context: bpy.types.Context) -> None: # (Model/Body defined only BEFORE Plan/Body to prevent #2744) body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") representation_data["context"] = body + representation_data["part_of_product"] = ifcopenshell.util.representation.get_part_of_product(element, body) model_representation = ifcopenshell.api.run("geometry.add_window_representation", ifc_file, **representation_data) + representation_data["part_of_product"] = None tool.Model.replace_object_ifc_representation(body, obj, model_representation) + if fallback_material := (int(props.lining_material) or int(props.framing_material) or int(props.glazing_material)): + ifcopenshell.api.material.set_shape_aspect_constituents( + ifc_file, + element=element, + context=body, + materials={ + "Lining": tool.Ifc.get().by_id(int(props.lining_material) or fallback_material), + "Framing": tool.Ifc.get().by_id(int(props.framing_material) or fallback_material), + "Glazing": tool.Ifc.get().by_id(int(props.glazing_material) or fallback_material), + }, + ) + elif material := ifcopenshell.util.element.get_material(element): + ifcopenshell.api.material.unassign_material(ifc_file, products=[element]) + if not material.is_a("IfcMaterial") and not ifc_file.get_total_inverses(material): + ifcopenshell.api.material.remove_material_set(ifc_file, material=material) # PLAN_VIEW representation plan = ifcopenshell.util.representation.get_context(ifc_file, "Plan", "Body", "PLAN_VIEW") @@ -100,24 +120,15 @@ def update_window_modifier_representation(context: bpy.types.Context) -> None: ) tool.Model.replace_object_ifc_representation(plan, obj, plan_representation) - # adding switch representation at the end instead of changing order of representations - # to prevent #2744 - if tool.Geometry.get_active_representation_context(obj) != previously_active_context: - previously_active_representation = ifcopenshell.util.representation.get_representation( - element, - previously_active_context.ContextType, - previously_active_context.ContextIdentifier, - previously_active_context.TargetView, - ) - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=previously_active_representation, - should_reload=True, - is_global=True, - should_sync_changes_first=True, - ) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=ifcopenshell.util.representation.get_representation(element, active_context), + should_reload=True, + is_global=True, + should_sync_changes_first=True, + ) # type attributes if tool.Ifc.get_schema() != "IFC2X3": @@ -250,7 +261,8 @@ def create_bm_window( def update_window_modifier_bmesh(context: bpy.types.Context) -> None: obj = context.active_object - props = obj.BIMWindowProperties + assert obj + props = tool.Model.get_window_props(obj) panel_schema = DEFAULT_PANEL_SCHEMAS[props.window_type] accumulated_height = [0] * len(panel_schema[0]) built_panels = [] @@ -439,8 +451,10 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMWindowProperties + assert element + props = tool.Model.get_window_props(obj) window_data = props.get_general_kwargs(convert_to_project_units=True) lining_props = props.get_lining_kwargs(convert_to_project_units=True) @@ -469,11 +483,13 @@ class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) + assert element data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data")) data.update(data.pop("lining_properties")) data.update(data.pop("panel_properties")) - props = obj.BIMWindowProperties + props = tool.Model.get_window_props(obj) props.set_props_kwargs_from_ifc_data(data) body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") @@ -498,8 +514,10 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - props = obj.BIMWindowProperties + assert element + props = tool.Model.get_window_props(obj) window_data = props.get_general_kwargs(convert_to_project_units=True) lining_props = props.get_lining_kwargs(convert_to_project_units=True) @@ -525,11 +543,14 @@ class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMWindowProperties + assert obj + props = tool.Model.get_window_props(obj) element = tool.Ifc.get_entity(obj) + assert element data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data")) data.update(data.pop("lining_properties")) data.update(data.pop("panel_properties")) + data.update(tool.Model.get_constituents_props_data(element)) # required since we could load pset from .ifc and BIMWindowProperties won't be set props.set_props_kwargs_from_ifc_data(data) @@ -545,9 +566,11 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMWindowProperties + assert obj element = tool.Ifc.get_entity(obj) - obj.BIMWindowProperties.is_editing = False + assert element + props = tool.Model.get_window_props(obj) + props.is_editing = False pset = tool.Pset.get_element_pset(element, "BBIM_Window") ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=pset) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index ffb0737015..cb1cdde985 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -30,6 +30,7 @@ from bonsai.bim.module.model.data import AuthoringData, ItemData from bonsai.bim.module.system.data import PortData from bonsai.bim.module.model.prop import get_ifc_class from typing import Optional, Union +from functools import partial def load_custom_icons(): @@ -96,14 +97,16 @@ class BimTool(WorkSpaceTool): def draw_settings( cls, context: bpy.types.Context, layout: bpy.types.UILayout, ws_tool: bpy.types.WorkSpaceTool ) -> None: - if context.scene.BIMGeometryProperties.mode == "ITEM": + props = tool.Geometry.get_geometry_props() + ifc_element_type = None if cls.ifc_element_type == "all" else cls.ifc_element_type + if props.mode == "ITEM": EditItemUI.draw(context, layout) elif ( active_ifc_object := (context.active_object and tool.Ifc.get_entity(context.active_object)) ) and context.selected_objects: - EditObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) + EditObjectUI.draw(context, layout, ifc_element_type=ifc_element_type) else: - CreateObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) + CreateObjectUI.draw(context, layout, ifc_element_type=ifc_element_type) class WallTool(BimTool): @@ -363,43 +366,7 @@ class CableTool(BimTool): ifc_element_type = "IfcCableSegmentType" -def add_layout_hotkey_operator( - layout: bpy.types.UILayout, - text: str, - hotkey: str, - description: Union[str, None], - ui_context: str = "", - *, - operator: str = "bim.hotkey", -) -> bpy.types.OperatorProperties: - """ - :param operator: Operator to display in UI. Displaying the specific operator in UI can be useful - to provide poll error messages. - """ - parts = hotkey.split("_") if hotkey else [] - modifier, key = (parts + ["", ""])[:2] - - op_text = "" if ui_context == "TOOL_HEADER" else text - custom_icon = custom_icon_previews.get(text.upper().replace(" ", "_"), custom_icon_previews["IFC"]).icon_id - modifier_icon, modifier_str = MODIFIERS.get(modifier, ("NONE", "")) - - row = layout.row(align=True) - op = row.operator(operator, text=op_text, icon_value=custom_icon) - - if ui_context != "TOOL_HEADER": - row.label(text="", icon=modifier_icon) - row.label(text="", icon=f"EVENT_{key}" if key else "BLANK1") - - hotkey_description = f"Hotkey: {modifier_str} {key}".strip() - description = "\n\n".join(filter(None, [description, hotkey_description])) - - if operator == "bim.hotkey": - op.hotkey = hotkey - if ui_context == "TOOL_HEADER": - op.description = text + "\n" + description - else: - op.description = description - return op +add_layout_hotkey_operator = partial(tool.Blender.add_layout_hotkey_operator, tool_name="bim", module_name=__name__) def format_ifc_camel_case(string): @@ -430,7 +397,7 @@ class EditItemUI: obj = context.active_object assert obj - mesh_props = obj.data.BIMMeshProperties + mesh_props = tool.Geometry.get_mesh_props(obj.data) if AuthoringData.data["is_representation_item_swept_solid"]: # TODO: support EndSweptArea for IfcRevolvedAreaSolidTapered, # will need to add second attribute for this. @@ -439,11 +406,14 @@ class EditItemUI: if mesh_props.item_profile == "-": op = row.operator("bim.name_profile", text="", icon="TAG") op.extrusion_item_obj = obj.name + else: + op = row.operator("bim.profiles_ui_select", icon="ZOOM_SELECTED", text="") + op.profile_id = int(mesh_props.item_profile) - for item_attribute in obj.data.BIMMeshProperties.item_attributes: + for item_attribute in mesh_props.item_attributes: row = cls.layout.row() draw_attribute(item_attribute, cls.layout) - if len(obj.data.BIMMeshProperties.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]: + if len(mesh_props.item_attributes) or AuthoringData.data["is_representation_item_swept_solid"]: row = cls.layout.row() row.operator("bim.update_item_attributes", icon="FILE_REFRESH", text="") @@ -499,9 +469,7 @@ class CreateObjectUI: layout: bpy.types.UILayout @classmethod - def draw( - cls, context: bpy.types.Context, layout: bpy.types.UILayout, ifc_element_type: Optional[str] = None - ) -> None: + def draw(cls, context: bpy.types.Context, layout: bpy.types.UILayout, ifc_element_type: Union[str, None]) -> None: cls.layout = layout cls.props = tool.Model.get_model_props() @@ -515,15 +483,13 @@ class CreateObjectUI: if not AuthoringData.is_loaded: AuthoringData.load(ifc_element_type) - elif ifc_element_type == "all" and AuthoringData.data["ifc_element_type"] is not None: - AuthoringData.load("all") elif AuthoringData.data["ifc_element_type"] != ifc_element_type: AuthoringData.load(ifc_element_type) - if ifc_element_type and context.region.type == "TOOL_HEADER": + if context.region.type == "TOOL_HEADER": tool_name = ( "Multi Object Tool" - if ifc_element_type == "all" + if ifc_element_type is None else format_ifc_camel_case(ifc_element_type.removesuffix("Type")) + " Tool" ) cls.layout.label(text=tool_name, icon="TOOL_SETTINGS") @@ -632,11 +598,11 @@ class CreateObjectUI: data=cls.props, property="x_angle", text="Slope" if ui_context != "TOOL_HEADER" else "A", icon="FILE_3D" ) - elif ifc_class in ("IfcColumnType", "IfcMemberType"): + elif ifc_class in ("IfcColumnType", "IfcPileType"): row.prop(data=cls.props, property="cardinal_point", text="Axis") row.prop(data=cls.props, property="extrusion_depth", text="Height" if ui_context != "TOOL_HEADER" else "H") - elif ifc_class in ("IfcBeamType"): + elif ifc_class in ("IfcBeamType", "IfcMemberType"): row.prop(data=cls.props, property="cardinal_point", text="Axis") row.prop(data=cls.props, property="extrusion_depth", text="Length" if ui_context != "TOOL_HEADER" else "L") @@ -676,12 +642,13 @@ class CreateObjectUI: if not (ifc_class := AuthoringData.data["ifc_class_current"]): return + relating_type_data = AuthoringData.data["relating_type_data"] box = cls.layout.box() row = box.row(align=True) - thumbnail: int = AuthoringData.data["type_thumbnail"] + thumbnail: int = AuthoringData.type_thumbnails.get(relating_type_data["id"], 0) row.template_icon(icon_value=thumbnail) - row.operator("bim.launch_type_manager", text=AuthoringData.data["relating_type_name"], emboss=False) + row.operator("bim.launch_type_manager", text=relating_type_data["name"], emboss=False) row.operator( "bim.launch_type_manager", icon=tool.Blender.TYPE_MANAGER_ICON, @@ -695,7 +662,7 @@ class CreateObjectUI: row.alignment = "CENTER" row.operator( "bim.launch_type_manager", - text=AuthoringData.data["relating_type_description"], + text=relating_type_data["description"], emboss=False, ) @@ -708,19 +675,18 @@ class CreateObjectUI: for _ in range(4): row2.operator("bim.launch_type_manager", text="", emboss=False) else: - op = box.operator( + box.operator( "bim.load_type_thumbnails", text="", icon="FILE_REFRESH", emboss=False, ) - op.ifc_class = ifc_class row = box.row(align=True) row.alignment = "CENTER" row.operator( "bim.launch_type_manager", - text=AuthoringData.data["predefined_type"], + text=AuthoringData.data["relating_type_data"].get("predefined_type"), emboss=False, ) @@ -746,8 +712,6 @@ class EditObjectUI: if not AuthoringData.is_loaded: AuthoringData.load(ifc_element_type) - elif ifc_element_type == "all" and AuthoringData.data["ifc_element_type"] is not None: - AuthoringData.load("all") elif AuthoringData.data["ifc_element_type"] != ifc_element_type: AuthoringData.load(ifc_element_type) @@ -817,7 +781,9 @@ class EditObjectUI: op.cardinal_point = int(cls.props.cardinal_point) row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row label = ( - "Height" if AuthoringData.data["active_class"] in ("IfcColumn", "IfcColumnStandardCase") else "Length" + "Height" + if AuthoringData.data["active_class"] in ("IfcColumn", "IfcColumnStandardCase", "IfcPile") + else "Length" ) row.prop( data=cls.props, property="extrusion_depth", text=label if ui_context != "TOOL_HEADER" else label[0] @@ -856,11 +822,7 @@ class EditObjectUI: add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context) row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator( - row, "Butt", "S_T", "Intersects two non-parallel elements to a butt corner junction", ui_context - ) - row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row - add_layout_hotkey_operator( - row, "Mitre", "S_Y", "Intersects two non-parallel elements to a mitred corner junction", ui_context + row, "Trim", "S_T", "Connects and trims two non-parallel elements into a joint", ui_context ) row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Unjoin Walls", "S_U", "", ui_context) @@ -872,7 +834,7 @@ class EditObjectUI: ) row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Rotate 90", "S_R", "Rotate the selected Element by 90 degrees", ui_context) - if AuthoringData.data["relating_type_material_usage"] == "LAYER3": + if AuthoringData.data["relating_type_data"].get("usage") == "LAYER3": row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator( row, @@ -885,8 +847,8 @@ class EditObjectUI: elif AuthoringData.data["active_material_usage"] == "LAYER3": if "LAYER2" in AuthoringData.data["selected_material_usages"]: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row - add_layout_hotkey_operator(cls.layout, "Extend Wall To Slab", "S_E", "", ui_context) - if AuthoringData.data["relating_type_material_usage"] == "LAYER2": + add_layout_hotkey_operator(cls.layout, "Extend To Underside", "S_E", "", ui_context) + if AuthoringData.data["relating_type_data"].get("usage") == "LAYER2": row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator( row, @@ -944,6 +906,11 @@ class EditObjectUI: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context) + else: + if "LAYER2" in AuthoringData.data["selected_material_usages"]: + row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row + add_layout_hotkey_operator(cls.layout, "Extend To Undersideb", "S_E", "", ui_context) + if AuthoringData.data["is_flippable_element"]: cls.draw_flip(ui_context, row) @@ -1047,6 +1014,7 @@ class EditObjectUI: @classmethod def draw_modes(cls, context: bpy.types.Context) -> None: + obj = context.active_object ui_context = str(context.region.type) row = cls.layout.row(align=True) row.separator() @@ -1056,9 +1024,7 @@ class EditObjectUI: if len(context.selected_objects) == 1 and AuthoringData.data["has_extrusion"]: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator(row, "Edit Profile", "S_E", "", ui_context) - elif ( - tool.Model.is_parametric_railing_active() and not context.active_object.BIMRailingProperties.is_editing_path - ): + elif tool.Model.is_parametric_railing_active() and not tool.Model.get_railing_props(obj).is_editing_path: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row row.operator( "bim.enable_editing_railing_path", @@ -1082,8 +1048,8 @@ class EditObjectUI: class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.hotkey" - bl_label = "BIM Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() x: bpy.props.FloatProperty(name="X", default=0.5) @@ -1134,7 +1100,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): row.prop(self, "z") def hotkey_S_A(self): - if bpy.context.scene.BIMGeometryProperties.mode == "ITEM": + gprops = tool.Geometry.get_geometry_props() + if gprops.mode == "ITEM": bpy.ops.wm.call_menu(name="BIM_MT_add_representation_item") return @@ -1168,9 +1135,12 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return bpy.ops.bim.draw_polyline_wall("INVOKE_DEFAULT") elif tool.Model.get_usage_type(relating_type) == "LAYER3": return bpy.ops.bim.draw_polyline_slab("INVOKE_DEFAULT") - elif tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type_class != "IfcColumnType": + elif tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type_class not in ( + "IfcColumnType", + "IfcPileType", + ): return bpy.ops.bim.draw_polyline_profile("INVOKE_DEFAULT") - return bpy.ops.bim.add_occurrence("INVOKE_DEFAULT") + return bpy.ops.bim.draw_occurrence("INVOKE_DEFAULT") def hotkey_S_Q(self): if not bpy.context.selected_objects: @@ -1201,23 +1171,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not bpy.context.selected_objects or not (active_object := bpy.context.active_object): return - # NOTE: placing it before the other operations because railing can also be SweptSolid - # and it might conflict with one of the conditions below - if ( - tool.Model.is_parametric_railing_active() - and not bpy.context.active_object.BIMRailingProperties.is_editing_path - ): - bpy.ops.bim.enable_editing_railing_path() - return - - elif tool.Model.is_parametric_roof_active() and not bpy.context.active_object.BIMRoofProperties.is_editing_path: - # undo the unselection done above because roof has no usage type - bpy.ops.bim.enable_editing_roof_path() - return - - elif tool.Model.is_parametric_window_active() or tool.Model.is_parametric_door_active(): - return - selected_usages: dict[str, list[bpy.types.Object]] = {} for obj in bpy.context.selected_objects: element = tool.Ifc.get_entity(obj) @@ -1228,11 +1181,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if not usage: representation = tool.Geometry.get_active_representation(obj) representation = tool.Geometry.resolve_mapped_representation(representation) - if representation and representation.RepresentationType == "SweptSolid": - usage = "SWEPTSOLID" - else: - obj.select_set(False) - continue selected_usages.setdefault(usage, []).append(obj) if len(bpy.context.selected_objects) == 1: @@ -1261,9 +1209,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): elif self.active_material_usage == "PROFILE": # Extend PROFILE to cursor bpy.ops.bim.extend_profile(join_type="T") - else: - # Edit SWEPTSOLID profile (assuming single profile for now) - bpy.ops.bim.enable_editing_extrusion_profile() elif self.active_material_usage == "LAYER2" and selected_usages.get("PROFILE", []): # Extend PROFILEs to LAYER2 @@ -1271,23 +1216,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): [o.select_set(False) for o in selected_usages.get("LAYER2", []) if o != bpy.context.active_object] bpy.ops.bim.extend_profile(join_type="T") - elif self.active_material_usage == "LAYER3" and selected_usages.get("LAYER2", []): - # Extend LAYER2s to LAYER3 - [o.select_set(False) for o in selected_usages.get("PROFILE", [])] - [o.select_set(False) for o in selected_usages.get("LAYER3", []) if o != bpy.context.active_object] - try: - core.join_walls_TZ(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) - except core.RequireAtLeastTwoLayeredElements as e: - self.report({"ERROR"}, str(e)) - elif self.active_material_usage == "LAYER2": - # Extend LAYER2s to LAYER2 - [o.select_set(False) for o in selected_usages.get("LAYER3", [])] - [o.select_set(False) for o in selected_usages.get("PROFILE", [])] - try: - core.join_walls_TZ(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) - except core.RequireAtLeastTwoLayeredElements as e: - self.report({"ERROR"}, str(e)) + bpy.ops.bim.extend_walls_to_wall() elif self.active_material_usage == "PROFILE": # Extend PROFILEs to PROFILE @@ -1295,6 +1225,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): [o.select_set(False) for o in selected_usages.get("LAYER2", [])] bpy.ops.bim.extend_profile(join_type="T") + else: + bpy.ops.bim.extend_walls_to_underside() + def hotkey_S_F(self): if not bpy.context.selected_objects: return @@ -1350,7 +1283,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return if self.active_material_usage == "LAYER2": bpy.ops.bim.rotate_90(axis="Z") - elif self.active_class in ("IfcColumn", "IfcColumnStandardCase"): + elif self.active_class in ("IfcColumn", "IfcColumnStandardCase", "IfcPile"): bpy.ops.bim.rotate_90(axis="Z") elif self.active_class in ("IfcBeam", "IfcBeamStandardCase", "IfcMember", "IfcMemberStandardCase"): bpy.ops.bim.rotate_90(axis="Y") @@ -1366,7 +1299,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return if self.active_material_usage == "LAYER2": try: - core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model, join_type="L") + core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) except core.RequireTwoWallsError as e: self.report({"ERROR"}, str(e)) elif self.active_material_usage == "PROFILE": @@ -1392,12 +1325,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): def hotkey_S_Y(self): if not bpy.context.selected_objects: return - if self.active_material_usage == "LAYER2": - try: - core.join_walls_LV(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model, join_type="V") - except core.RequireTwoWallsError as e: - self.report({"ERROR"}, str(e)) - elif self.active_class in ("IfcDuctSegment", "IfcPipeSegment", "IfcCableCarrierSegment", "IfcCableSegment"): + if self.active_class in ("IfcDuctSegment", "IfcPipeSegment", "IfcCableCarrierSegment", "IfcCableSegment"): bpy.ops.bim.fit_flow_segments() elif self.active_material_usage == "PROFILE": bpy.ops.bim.extend_profile(join_type="V") @@ -1439,10 +1367,3 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): custom_icon_previews = None display_mode = None - -MODIFIERS = { - "A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"), - "C": ("EVENT_CTRL", "CTRL"), - "S": ("EVENT_SHIFT", "⇧"), - "E": ("EVENT_PADENTER", "ENTER" if sys.platform == "Darwin" else "RETURN"), -} diff --git a/src/bonsai/bonsai/bim/module/nest/operator.py b/src/bonsai/bonsai/bim/module/nest/operator.py index 00b1f15f33..f3247817ec 100644 --- a/src/bonsai/bonsai/bim/module/nest/operator.py +++ b/src/bonsai/bonsai/bim/module/nest/operator.py @@ -21,7 +21,6 @@ import ifcopenshell import ifcopenshell.util.element import bonsai.tool as tool import bonsai.core.nest as core -from bonsai.bim.ifc import IfcStore class BIM_OT_nest_assign_object(bpy.types.Operator, tool.Ifc.Operator): @@ -113,7 +112,7 @@ class BIM_OT_select_components(bpy.types.Operator): obj: bpy.props.StringProperty() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() obj = bpy.data.objects.get(self.obj) or context.active_object components = ifcopenshell.util.element.get_components(tool.Ifc.get_entity(obj)) component_objs = set(tool.Ifc.get_object(c) for c in components) @@ -132,7 +131,7 @@ class BIM_OT_select_nest(bpy.types.Operator): obj: bpy.props.StringProperty() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() obj = bpy.data.objects.get(self.obj) or context.active_object nest = ifcopenshell.util.element.get_nest(tool.Ifc.get_entity(obj)) nest_obj = tool.Ifc.get_object(nest) diff --git a/src/bonsai/bonsai/bim/module/nest/prop.py b/src/bonsai/bonsai/bim/module/nest/prop.py index 67fed8fe2d..d5e48b2479 100644 --- a/src/bonsai/bonsai/bim/module/nest/prop.py +++ b/src/bonsai/bonsai/bim/module/nest/prop.py @@ -40,7 +40,7 @@ def update_relating_object(self, context): if self.relating_object is None: return - if not self.relating_object.BIMObjectProperties.ifc_definition_id: + if not tool.Blender.get_ifc_definition_id(self.relating_object): context.window_manager.popup_menu(message, title="Invalid Element Selected", icon="INFO") self.relating_object = None diff --git a/src/bonsai/bonsai/bim/module/nest/ui.py b/src/bonsai/bonsai/bim/module/nest/ui.py index b1983a2411..601fedf098 100644 --- a/src/bonsai/bonsai/bim/module/nest/ui.py +++ b/src/bonsai/bonsai/bim/module/nest/ui.py @@ -16,9 +16,9 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bonsai.tool as tool from bpy.types import Panel from bonsai.bim.module.nest.data import NestData -from bonsai.bim.ifc import IfcStore class BIM_PT_nest(Panel): @@ -32,14 +32,14 @@ class BIM_PT_nest(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcObjectDefinition"): + if not tool.Ifc.get().by_id(ifc_id).is_a("IfcObjectDefinition"): return False return True @@ -55,7 +55,7 @@ class BIM_PT_nest(Panel): row.prop(props, "relating_object", text="") if props.relating_object: op = row.operator("bim.nest_assign_object", icon="CHECKMARK", text="") - op.relating_object = props.relating_object.BIMObjectProperties.ifc_definition_id + op.relating_object = tool.Blender.get_ifc_definition_id(props.relating_object) row.operator("bim.disable_editing_nest", icon="CANCEL", text="") else: row = layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index 6438b57246..690f66a079 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -24,41 +24,41 @@ import ifcpatch import bonsai.tool as tool import bonsai.core.patch as core import bonsai.bim.handler +from bpy_extras.io_utils import ImportHelper, ExportHelper from pathlib import Path -from typing import cast +from typing import cast, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.prop import AttributeDataType -class SelectIfcPatchInput(bpy.types.Operator): +class SelectIfcPatchInput(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_ifc_patch_input" bl_label = "Select IFC Patch Input" + bl_description = "Select filepath for IFC patch input." bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifcZIP;*.ifcXML", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filename_ext = ".ifc" def execute(self, context): - context.scene.BIMPatchProperties.ifc_patch_input = self.filepath + props = tool.Patch.get_patch_props() + props.ifc_patch_input = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class SelectIfcPatchOutput(bpy.types.Operator): +class SelectIfcPatchOutput(bpy.types.Operator, ExportHelper): bl_idname = "bim.select_ifc_patch_output" bl_label = "Select IFC Patch Output" + bl_description = "Select filepath for IFC patch output." bl_options = {"REGISTER", "UNDO"} + filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifcZIP;*.ifcXML", options={"HIDDEN"}) filename_ext = ".ifc" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") def execute(self, context): - context.scene.BIMPatchProperties.ifc_patch_output = self.filepath + props = tool.Patch.get_patch_props() + props.ifc_patch_output = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class ExecuteIfcPatch(bpy.types.Operator): bl_idname = "bim.execute_ifc_patch" @@ -67,7 +67,7 @@ class ExecuteIfcPatch(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.BIMPatchProperties + props = tool.Patch.get_patch_props() if props.ifc_patch_recipes == "-": cls.poll_message_set("No recipe selected.") return False @@ -77,7 +77,7 @@ class ExecuteIfcPatch(bpy.types.Operator): return True def execute(self, context): - props = context.scene.BIMPatchProperties + props = tool.Patch.get_patch_props() recipe_name = props.ifc_patch_recipes arguments = [] @@ -91,7 +91,7 @@ class ExecuteIfcPatch(bpy.types.Operator): arguments = tool.Patch.post_process_patch_arguments(recipe_name, arguments) args = ifcpatch.ArgumentsDict( - recipe=props.ifc_patch_recipes, + recipe=recipe_name, arguments=arguments, log=tool.Blender.get_data_dir_path("process.log").__str__(), ) @@ -106,9 +106,9 @@ class ExecuteIfcPatch(bpy.types.Operator): ifc_patch_output = props.ifc_patch_output or props.ifc_patch_input output = ifcpatch.execute(args) - if tool.Patch.does_patch_has_output(props.ifc_patch_recipes): + if tool.Patch.does_patch_has_output(recipe_name): ifcpatch.write(output, ifc_patch_output) - self.report({"INFO"}, f"{props.ifc_patch_recipes} patch executed successfully") + self.report({"INFO"}, f"{recipe_name} patch executed successfully") return {"FINISHED"} @@ -121,7 +121,7 @@ class UpdateIfcPatchArguments(bpy.types.Operator): if self.recipe == "-": print("No Recipe Selected. Impossible to load arguments") return {"FINISHED"} - patch_args = context.scene.BIMPatchProperties.ifc_patch_args_attr + patch_args = tool.Patch.get_patch_props().ifc_patch_args_attr patch_args.clear() docs = ifcpatch.extract_docs(self.recipe, "Patcher", "__init__", ("src", "file", "logger", "args")) if docs and "inputs" in docs: @@ -131,24 +131,26 @@ class UpdateIfcPatchArguments(bpy.types.Operator): new_attr = patch_args.add() data_type = arg_info.get("type", "str") - if tool.Patch.is_filepath_argument(self.recipe, arg_name): + is_filepath_argument = tool.Patch.is_filepath_argument(arg_info) + if is_filepath_argument: data_type = "file" new_attr.metadata = "single_file" if isinstance(data_type, list): - if "file" in data_type or tool.Patch.is_filepath_argument(self.recipe, arg_name): + if "file" in data_type or is_filepath_argument: data_type = ["file"] data_type = [dt for dt in data_type if dt != "NoneType"][0] - new_attr.data_type = { + data_types: dict[str, AttributeDataType] = { "Literal": "enum", "file": "file", "str": "string", "float": "float", "int": "integer", "bool": "boolean", - }[data_type] + } + new_attr.data_type = data_types[data_type] new_attr.name = self.pretty_arg_name(arg_name) if new_attr.data_type == "enum": new_attr.enum_items = json.dumps(arg_info.get("enum_items", [])) @@ -211,7 +213,7 @@ class ExtractSelectedElements(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMPatchProperties + props = tool.Patch.get_patch_props() recipe_name = props.ifc_patch_recipes if recipe_name != "ExtractElements": diff --git a/src/bonsai/bonsai/bim/module/patch/prop.py b/src/bonsai/bonsai/bim/module/patch/prop.py index f491cb7ed4..3d372e5036 100644 --- a/src/bonsai/bonsai/bim/module/patch/prop.py +++ b/src/bonsai/bonsai/bim/module/patch/prop.py @@ -33,9 +33,10 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal, Union -ifcpatchrecipes_enum = [] +ifcpatchrecipes_enum: list[tuple[str, str, str]] = [] def purge(): @@ -43,7 +44,7 @@ def purge(): ifcpatchrecipes_enum = [] -def get_ifcpatch_recipes(self, context): +def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: global ifcpatchrecipes_enum if len(ifcpatchrecipes_enum) < 1: # Have to add a blank entry because otherwise default recipe might be not loaded @@ -56,12 +57,23 @@ def get_ifcpatch_recipes(self, context): if f == "__init__": continue docs = ifcpatch.extract_docs(f, "Patcher", "__init__", ("src", "file", "logger", "args")) - ifcpatchrecipes_enum.append((f, f, docs.get("description", "") if docs else "")) + if docs is None: + description = "" + else: + description = docs["description"] + inputs = docs["inputs"] + if inputs: + if description: + description += "\n\n" + description += "Parameters:" + for param, input_data in inputs.items(): + description += f"\n\n- {param}: {input_data['description']}" + ifcpatchrecipes_enum.append((f, f, description)) ifcpatchrecipes_enum.sort(key=lambda x: x[0]) return ifcpatchrecipes_enum -def update_ifc_patch_recipe(self, context): +def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None: bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes) @@ -75,3 +87,10 @@ class BIMPatchProperties(PropertyGroup): name="Load from Memory", description="Use IFC file currently loaded in Bonsai", ) + + if TYPE_CHECKING: + ifc_patch_recipes_enum: Union[Literal["-"], str] + ifc_patch_input: str + ifc_patch_output: str + ifc_patch_args_attr: bpy.types.bpy_prop_collection_idprop[Attribute] + should_load_from_memory: bool diff --git a/src/bonsai/bonsai/bim/module/patch/ui.py b/src/bonsai/bonsai/bim/module/patch/ui.py index 539544055d..03822642a9 100644 --- a/src/bonsai/bonsai/bim/module/patch/ui.py +++ b/src/bonsai/bonsai/bim/module/patch/ui.py @@ -36,8 +36,7 @@ class BIM_PT_patch(bpy.types.Panel): layout.use_property_split = True layout.use_property_decorate = False - scene = context.scene - props = scene.BIMPatchProperties + props = tool.Patch.get_patch_props() row = layout.row() prop_with_search(row, props, "ifc_patch_recipes") diff --git a/src/bonsai/bonsai/bim/module/profile/data.py b/src/bonsai/bonsai/bim/module/profile/data.py index 9f89cf01ed..cc28d8b008 100644 --- a/src/bonsai/bonsai/bim/module/profile/data.py +++ b/src/bonsai/bonsai/bim/module/profile/data.py @@ -21,6 +21,7 @@ import bpy.utils import bpy.utils.previews import ifcopenshell.util.doc import bonsai.tool as tool +from typing import Any def refresh(): @@ -28,7 +29,7 @@ def refresh(): class ProfileData: - data = {} + data: dict[str, Any] = {} failed_previews: set[int] = set() preview_collection = bpy.utils.previews.new() is_loaded = False @@ -37,6 +38,7 @@ class ProfileData: def load(cls): cls.data = { "total_profiles": cls.total_profiles(), + "does_active_profile_exist": cls.does_active_profile_exist(), "active_profile_users": cls.active_profile_users(), "profile_classes": cls.profile_classes(), "is_arbitrary_profile": cls.is_arbitrary_profile(), @@ -50,12 +52,30 @@ class ProfileData: return len([p for p in tool.Ifc.get().by_type("IfcProfileDef") if p.ProfileName]) @classmethod - def active_profile_users(cls): - profiles_props = bpy.context.scene.BIMProfileProperties - if profiles_props.active_profile_index >= len(profiles_props.profiles): + def update_active_profile_data(cls) -> None: + cls.data["does_active_profile_exist"] = cls.does_active_profile_exist() + cls.data["active_profile_users"] = cls.active_profile_users() + + @classmethod + def does_active_profile_exist(cls) -> bool: + """ + Currently not sure if our UI is always preserving existing named profiles, + so this check is added to avoid breaking UI in case of a missing profile. + """ + active_profile = tool.Profile.get_active_profile_ui() + if active_profile is None: + return False + profile_ifc = tool.Ifc.get_entity_by_id(active_profile.ifc_definition_id) + return profile_ifc is not None + + @classmethod + def active_profile_users(cls) -> int: + active_profile = tool.Profile.get_active_profile_ui() + if active_profile is None: + return 0 + profile_ifc = tool.Ifc.get_entity_by_id(active_profile.ifc_definition_id) + if profile_ifc is None: return 0 - profile_prop = profiles_props.profiles[profiles_props.active_profile_index] - profile_ifc = tool.Ifc.get().by_id(profile_prop.ifc_definition_id) return tool.Ifc.get().get_total_inverses(profile_ifc) @classmethod @@ -83,7 +103,7 @@ class ProfileData: @classmethod def is_arbitrary_profile(cls): - props = bpy.context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() if props.active_profile_id: profile = tool.Ifc.get().by_id(props.active_profile_id) if profile.is_a("IfcArbitraryClosedProfileDef"): @@ -94,7 +114,7 @@ class ProfileData: obj = bpy.context.active_object return ( obj - and obj.data - and hasattr(obj.data, "BIMMeshProperties") - and obj.data.BIMMeshProperties.subshape_type == "PROFILE" + and (data := obj.data) + and isinstance(data, bpy.types.Mesh) + and tool.Geometry.get_mesh_props(data).subshape_type == "PROFILE" ) diff --git a/src/bonsai/bonsai/bim/module/profile/operator.py b/src/bonsai/bonsai/bim/module/profile/operator.py index da91595dc6..3a2b026778 100644 --- a/src/bonsai/bonsai/bim/module/profile/operator.py +++ b/src/bonsai/bonsai/bim/module/profile/operator.py @@ -18,6 +18,7 @@ import bpy import ifcopenshell.api +import ifcopenshell.api.profile import ifcopenshell.util.element import bonsai.bim.helper import bonsai.tool as tool @@ -35,7 +36,7 @@ class LoadProfiles(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() props.profiles.clear() filter_material_profiles = props.is_filtering_material_profiles @@ -43,7 +44,9 @@ class LoadProfiles(bpy.types.Operator): for profile in tool.Ifc.get().by_type("IfcProfileDef"): if filter_material_profiles: inverse_references = tool.Ifc.get().get_inverse(profile) - related_material_profiles = [ref for ref in inverse_references if ref.is_a("IfcMaterialProfile")] + related_material_profiles = next( + (ref for ref in inverse_references if ref.is_a("IfcMaterialProfile")), None + ) if not related_material_profiles: continue if not profile.ProfileName: @@ -64,7 +67,8 @@ class DisableProfileEditingUI(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMProfileProperties.is_editing = False + props = tool.Profile.get_profile_props() + props.is_editing = False return {"FINISHED"} @@ -75,7 +79,7 @@ class RemoveProfileDef(bpy.types.Operator, tool.Ifc.Operator): profile: bpy.props.IntProperty() def _execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() current_index = props.active_profile_index ifc_file = tool.Ifc.get() @@ -113,7 +117,7 @@ class EnableEditingProfile(bpy.types.Operator): profile: bpy.props.IntProperty() def execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() props.profile_attributes.clear() bonsai.bim.helper.import_attributes2(tool.Ifc.get().by_id(self.profile), props.profile_attributes) props.active_profile_id = self.profile @@ -126,7 +130,8 @@ class DisableEditingProfile(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMProfileProperties.active_profile_id = 0 + props = tool.Profile.get_profile_props() + props.active_profile_id = 0 bpy.ops.bim.disable_editing_arbitrary_profile() return {"FINISHED"} @@ -137,7 +142,7 @@ class EditProfile(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() attributes = bonsai.bim.helper.export_attributes(props.profile_attributes) profile = tool.Ifc.get().by_id(props.active_profile_id) ifcopenshell.api.run("profile.edit_profile", tool.Ifc.get(), profile=profile, attributes=attributes) @@ -152,7 +157,7 @@ class AddProfileDef(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() profile_class = props.profile_classes if profile_class == "IfcArbitraryClosedProfileDef": obj = props.object_to_profile @@ -172,12 +177,12 @@ class AddProfileDef(bpy.types.Operator, tool.Ifc.Operator): props.object_to_profile = None if not indices: points = [(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.1), (0, 0)] - profile = ifcopenshell.api.run("profile.add_arbitrary_profile", tool.Ifc.get(), profile=points) + profile = ifcopenshell.api.profile.add_arbitrary_profile(tool.Ifc.get(), profile=points) else: if "inner_curves" not in indices: points = [(obj.data.vertices[i].co.x, obj.data.vertices[i].co.y) for i in indices["profile"]] points.append(points[0]) - profile = ifcopenshell.api.run("profile.add_arbitrary_profile", tool.Ifc.get(), profile=points) + profile = ifcopenshell.api.profile.add_arbitrary_profile(tool.Ifc.get(), profile=points) else: outer_points = [(obj.data.vertices[i].co.x, obj.data.vertices[i].co.y) for i in indices["profile"]] outer_points.append(outer_points[0]) @@ -187,8 +192,7 @@ class AddProfileDef(bpy.types.Operator, tool.Ifc.Operator): ] for curve in inner_points: curve.append(curve[0]) - profile = ifcopenshell.api.run( - "profile.add_arbitrary_profile_with_voids", + profile = ifcopenshell.api.profile.add_arbitrary_profile_with_voids( tool.Ifc.get(), outer_profile=outer_points, inner_profiles=inner_points, @@ -229,12 +233,18 @@ class EnableEditingArbitraryProfile(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() active_profile = props.profiles[props.active_profile_index] profile_id = active_profile.ifc_definition_id - props.active_arbitrary_profile_id = profile_id profile = tool.Ifc.get().by_id(profile_id) - obj = tool.Model.import_profile(profile) + + try: + obj = tool.Model.import_profile(profile) + except tool.Model.UnsupportedCurveForConversion as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + props.active_arbitrary_profile_id = profile_id tool.Ifc.link(profile, obj) bpy.context.scene.collection.objects.link(obj) tool.Blender.select_and_activate_single_object(context, obj) @@ -246,14 +256,19 @@ class EnableEditingArbitraryProfile(bpy.types.Operator): def disable_editing_arbitrary_profile(context): obj = context.active_object - if obj and obj.type == "MESH" and obj.data and obj.data.BIMMeshProperties.subshape_type == "PROFILE": + if ( + obj + and (mesh := obj.data) + and isinstance(mesh, bpy.types.Mesh) + and tool.Geometry.get_mesh_props(mesh).subshape_type == "PROFILE" + ): ProfileDecorator.uninstall() bpy.ops.object.mode_set(mode="OBJECT") profile_mesh = obj.data bpy.data.objects.remove(obj) bpy.data.meshes.remove(profile_mesh) - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() props.active_arbitrary_profile_id = 0 # need to update profile manager ui # if this was called from decorator @@ -276,7 +291,7 @@ class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() old_profile = tool.Ifc.get().by_id(props.active_arbitrary_profile_id) obj = context.active_object @@ -288,7 +303,7 @@ class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): if not profile: def msg(self, context): - self.layout.label(text="INVALID PROFILE: " + indices[1]) + self.layout.label(text="INVALID PROFILE") bpy.context.window_manager.popup_menu(msg, title="Error", icon="ERROR") ProfileDecorator.install( @@ -322,7 +337,7 @@ class SelectProfileInProfilesUI(bpy.types.Operator): profile_id: bpy.props.IntProperty() def execute(self, context): - props = bpy.context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() ifc_file = tool.Ifc.get() profile = ifc_file.by_id(self.profile_id) bpy.ops.bim.load_profiles() @@ -335,7 +350,7 @@ class SelectProfileInProfilesUI(bpy.types.Operator): props.active_profile_index = profile_index self.report( {"INFO"}, - f"Profile '{profile.Name or 'Unnamed'}' is selected in Profiles UI.", + f"Profile '{profile.ProfileName or 'Unnamed'}' is selected in Profiles UI.", ) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/profile/prop.py b/src/bonsai/bonsai/bim/module/profile/prop.py index 801e7e0204..e2da08091f 100644 --- a/src/bonsai/bonsai/bim/module/profile/prop.py +++ b/src/bonsai/bonsai/bim/module/profile/prop.py @@ -21,7 +21,6 @@ import ifcopenshell import ifcopenshell.util.schema import ifcopenshell.util.attribute import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute from bonsai.bim.module.profile.data import ProfileData from bpy.types import PropertyGroup @@ -35,10 +34,10 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union -def get_profile_classes(self, context): +def get_profile_classes(self: "BIMProfileProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not ProfileData.is_loaded: ProfileData.load() return ProfileData.data["profile_classes"] @@ -47,7 +46,9 @@ def get_profile_classes(self, context): def update_profile_name(self: "Profile", context: bpy.types.Context) -> None: from bonsai.bim.handler import refresh_ui_data - profile = tool.Ifc.get().by_id(self.ifc_definition_id) + profile = tool.Ifc.get_entity_by_id(self.ifc_definition_id) + if not profile: + return profile.ProfileName = self.name refresh_ui_data() @@ -63,16 +64,18 @@ class Profile(PropertyGroup): ifc_definition_id: int -def update_active_profile_index(self, context): - ProfileData.data["active_profile_users"] = ProfileData.active_profile_users() +def update_active_profile_index(self: "BIMProfileProperties", context: bpy.types.Context) -> None: + ProfileData.update_active_profile_data() class BIMProfileProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") profiles: CollectionProperty(name="Profiles", type=Profile) active_profile_index: IntProperty(name="Active Profile Index", update=update_active_profile_index) - active_profile_id: IntProperty(name="Active Profile Id") - active_arbitrary_profile_id: IntProperty(name="Active Arbitrary Profile Id") + active_profile_id: IntProperty(name="Active Profile Id", description="Currently edited profile ID (attributes).") + active_arbitrary_profile_id: IntProperty( + name="Active Arbitrary Profile Id", description="Currently edited arbitrary profile ID." + ) profile_attributes: CollectionProperty(name="Profile Attributes", type=Attribute) profile_classes: EnumProperty(items=get_profile_classes, name="Profile Classes") is_filtering_material_profiles: bpy.props.BoolProperty( @@ -91,6 +94,17 @@ class BIMProfileProperties(PropertyGroup): poll=lambda self, obj: obj.type == "MESH", ) + if TYPE_CHECKING: + is_editing: bool + profiles: bpy.types.bpy_prop_collection_idprop[Profile] + active_profile_index: int + active_profile_id: int + active_arbitrary_profile_id: int + profile_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + profile_classes: str + is_filtering_material_profiles: bool + object_to_profile: Union[bpy.types.Object, None] + def generate_thumbnail_for_active_profile(): from PIL import Image, ImageDraw @@ -98,7 +112,7 @@ def generate_thumbnail_for_active_profile(): if bpy.app.background: return - props = bpy.context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() ifc_file = tool.Ifc.get() preview_collection = ProfileData.preview_collection diff --git a/src/bonsai/bonsai/bim/module/profile/ui.py b/src/bonsai/bonsai/bim/module/profile/ui.py index 6718396412..1de4a077c6 100644 --- a/src/bonsai/bonsai/bim/module/profile/ui.py +++ b/src/bonsai/bonsai/bim/module/profile/ui.py @@ -16,12 +16,17 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.bim.helper import bonsai.tool as tool from bpy.types import Panel, UIList from bonsai.bim.module.profile.data import ProfileData from bonsai.bim.module.profile.prop import generate_thumbnail_for_active_profile +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.profile.prop import BIMProfileProperties, Profile class BIM_PT_profiles(Panel): @@ -40,7 +45,7 @@ class BIM_PT_profiles(Panel): def draw(self, context): if not ProfileData.is_loaded: ProfileData.load() - self.props = context.scene.BIMProfileProperties + self.props = tool.Profile.get_profile_props() active_profile = None if self.props.is_editing and (active_profile := tool.Profile.get_active_profile_ui()): @@ -70,6 +75,8 @@ class BIM_PT_profiles(Panel): if not self.props.is_editing: return + does_active_profile_exist: bool = ProfileData.data["does_active_profile_exist"] + row = self.layout.row(align=True) if self.props.profile_classes == "IfcArbitraryClosedProfileDef": split = row.split(factor=0.5, align=True) @@ -80,8 +87,37 @@ class BIM_PT_profiles(Panel): else: row.prop(self.props, "profile_classes", text="") row.operator("bim.add_profile_def", text="", icon="ADD") - row.operator("bim.duplicate_profile_def", icon="DUPLICATE", text="") - row.operator("bim.select_by_profile", icon="RESTRICT_SELECT_OFF", text="") + + if active_profile and not does_active_profile_exist: + box = self.layout.box() + box.label(icon="ERROR", text=f"Active profile is missing from IFC project.") + row = box.row(align=True) + row.label(text="Reload Profiles UI.") + row.operator("bim.load_profiles", text="", icon="FILE_REFRESH") + + elif active_profile and does_active_profile_exist: + row = self.layout.row(align=True) + row.alignment = "RIGHT" + + is_editable = active_profile.ifc_class in ( + "IfcArbitraryClosedProfileDef", + "IfcArbitraryProfileDefWithVoids", + "IfcCompositeProfileDef", + ) + if self.props.active_profile_id == active_profile.ifc_definition_id: + row.operator("bim.edit_profile", text="", icon="CHECKMARK") + row.operator("bim.disable_editing_profile", text="", icon="CANCEL") + elif self.props.active_arbitrary_profile_id: + row.operator("bim.edit_arbitrary_profile", text="", icon="CHECKMARK") + row.operator("bim.disable_editing_arbitrary_profile", text="", icon="CANCEL") + else: + row.operator("bim.duplicate_profile_def", icon="DUPLICATE", text="") + row.operator("bim.select_by_profile", icon="RESTRICT_SELECT_OFF", text="") + if is_editable: + row.operator("bim.enable_editing_arbitrary_profile", text="", icon="ITALIC") + op = row.operator("bim.enable_editing_profile", text="", icon="GREASEPENCIL") + op.profile = active_profile.ifc_definition_id + row.operator("bim.remove_profile_def", text="", icon="X").profile = active_profile.ifc_definition_id self.layout.template_list( "BIM_UL_profiles", @@ -95,21 +131,7 @@ class BIM_PT_profiles(Panel): row = self.layout.row() row.prop(self.props, "is_filtering_material_profiles", text="Filter Material Profiles") - if active_profile: - if active_profile.ifc_class in ( - "IfcArbitraryClosedProfileDef", - "IfcArbitraryProfileDefWithVoids", - ): - if self.props.active_arbitrary_profile_id: - row = self.layout.row(align=True) - row.operator("bim.edit_arbitrary_profile", text="Save Arbitrary Profile", icon="CHECKMARK") - row.operator("bim.disable_editing_arbitrary_profile", text="", icon="CANCEL") - else: - row = self.layout.row() - row.operator( - "bim.enable_editing_arbitrary_profile", text="Edit Arbitrary Profile", icon="GREASEPENCIL" - ) - + if active_profile and does_active_profile_exist: users_of_profile = ProfileData.data["active_profile_users"] self.layout.label(icon="INFO", text=f"Profile has {users_of_profile} inverse relationship(s) in project") @@ -121,19 +143,19 @@ class BIM_PT_profiles(Panel): class BIM_UL_profiles(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - props = context.scene.BIMProfileProperties + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMProfileProperties, + item: Profile, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) + if item.ifc_definition_id == data.active_profile_id: + row.label(text="", icon="GREASEPENCIL") row.prop(item, "name", text="", emboss=False) row.label(text=item.ifc_class) - - if props.active_profile_id == item.ifc_definition_id: - row.operator("bim.edit_profile", text="", icon="CHECKMARK") - row.operator("bim.disable_editing_profile", text="", icon="CANCEL") - elif props.active_profile_id: - row.operator("bim.remove_profile_def", text="", icon="X").profile = item.ifc_definition_id - else: - op = row.operator("bim.enable_editing_profile", text="", icon="GREASEPENCIL") - op.profile = item.ifc_definition_id - row.operator("bim.remove_profile_def", text="", icon="X").profile = item.ifc_definition_id diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index d87814c026..81bf9e9ec7 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -48,6 +48,7 @@ classes = ( operator.LoadProject, operator.LoadProjectElements, operator.MeasureTool, + operator.MeasureFaceAreaTool, operator.ClearMeasurement, operator.NewProject, operator.QueryLinkedElement, @@ -66,6 +67,7 @@ classes = ( operator.UnlinkIfc, operator.UnloadLink, workspace.ExploreHotkey, + prop.LibraryBreadcrumb, prop.LibraryElement, prop.FilterCategory, prop.Link, diff --git a/src/bonsai/bonsai/bim/module/project/data.py b/src/bonsai/bonsai/bim/module/project/data.py index fe9d5a8d47..50aad50e07 100644 --- a/src/bonsai/bonsai/bim/module/project/data.py +++ b/src/bonsai/bonsai/bim/module/project/data.py @@ -134,10 +134,11 @@ class ProjectLibraryData: @classmethod def project_libraries_enum(cls) -> list[tuple[str, str, str, str, int]]: - results = [ - ("*", "All Libraries", "Show all elements", "", 0), - ("-", "No Library", "Show elements without library assigned", "", 1), - ] + results = [] + project_libraries = cls.data["project_libraries"].values() + if not project_libraries: + results.append(("-", "No Library", "", "", 0)) + props = tool.Project.get_project_props() libs = [] for i, data in enumerate(cls.data["project_libraries"].values(), len(results)): diff --git a/src/bonsai/bonsai/bim/module/project/decorator.py b/src/bonsai/bonsai/bim/module/project/decorator.py index cd00264224..0adb1a4c4e 100644 --- a/src/bonsai/bonsai/bim/module/project/decorator.py +++ b/src/bonsai/bonsai/bim/module/project/decorator.py @@ -31,7 +31,8 @@ from typing import Union @persistent def toggle_decorations_on_load(*args): - if bpy.context.scene.BIMProjectProperties.clipping_planes: + props = tool.Project.get_project_props() + if props.clipping_planes: ClippingPlaneDecorator.install(bpy.context) else: ClippingPlaneDecorator.uninstall() @@ -99,7 +100,7 @@ class ProjectDecorator: selected_edges = [] selected_tris = [] - props = context.scene.BIMProjectProperties + props = tool.Project.get_project_props() try: obj = props.queried_obj selected_vertices = obj["selected_vertices"] @@ -171,7 +172,8 @@ class ClippingPlaneDecorator: unselected_edges = [] unselected_tris = [] - for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: + props = tool.Project.get_project_props() + for clipping_plane in props.clipping_planes: obj = clipping_plane.obj if not obj or not obj.data: continue @@ -321,7 +323,7 @@ class MeasureDecorator: all_positions = [] for i in range(len(polyline_points)): - if i < 2 and measure_type == "AREA": + if i < 1 and measure_type == "AREA": continue if i == 0: continue diff --git a/src/bonsai/bonsai/bim/module/project/gizmo.py b/src/bonsai/bonsai/bim/module/project/gizmo.py index e5da7e3d52..bb372b9cb7 100644 --- a/src/bonsai/bonsai/bim/module/project/gizmo.py +++ b/src/bonsai/bonsai/bim/module/project/gizmo.py @@ -18,6 +18,7 @@ import bpy +import bonsai.tool as tool from bpy.types import GizmoGroup from mathutils import Matrix @@ -32,11 +33,12 @@ class ClippingPlane(GizmoGroup): @classmethod def poll(cls, context): obj = context.object + props = tool.Project.get_project_props() return ( context.selected_objects and obj and obj.name.startswith("ClippingPlane") - and obj in [sp.obj for sp in context.scene.BIMProjectProperties.clipping_planes] + and obj in [sp.obj for sp in props.clipping_planes] ) def setup(self, context): diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 80ac7d7b31..68180a5e21 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -47,11 +47,11 @@ import bonsai.bim.helper import bonsai.bim.schema import bonsai.tool as tool import bonsai.core.project as core +from bpy_extras.io_utils import ExportHelper, ImportHelper from bonsai.bim.ifc import IfcStore from bonsai.bim.ui import IFCFileSelector from bonsai.bim import import_ifc from bonsai.bim import export_ifc -from collections import defaultdict from math import radians from pathlib import Path from collections import defaultdict @@ -60,9 +60,13 @@ from bpy.app.handlers import persistent from ifcopenshell.geom import ShapeElementType from bonsai.bim.module.project.data import LinksData, ProjectLibraryData from bonsai.bim.module.project.decorator import ProjectDecorator, ClippingPlaneDecorator, MeasureDecorator -from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.project.prop import BreadcrumbType +from bonsai.bim.module.model.decorator import PolylineDecorator, FaceAreaDecorator from bonsai.bim.module.model.polyline import PolylineOperator -from typing import Union, TYPE_CHECKING +from typing import Union, TYPE_CHECKING, get_args + +if TYPE_CHECKING: + from bonsai.bim.module.project.prop import Link class NewProject(bpy.types.Operator): @@ -74,35 +78,37 @@ class NewProject(bpy.types.Operator): def execute(self, context): bpy.ops.wm.read_homefile() + pprops = tool.Project.get_project_props() + bim_props = tool.Blender.get_bim_props() if self.preset == "metric_m": - bpy.context.scene.BIMProjectProperties.export_schema = "IFC4" + pprops.export_schema = "IFC4" bpy.context.scene.unit_settings.system = "METRIC" bpy.context.scene.unit_settings.length_unit = "METERS" - bpy.context.scene.BIMProperties.area_unit = "SQUARE_METRE" - bpy.context.scene.BIMProperties.volume_unit = "CUBIC_METRE" - bpy.context.scene.BIMProjectProperties.template_file = "0" + bim_props.area_unit = "SQUARE_METRE" + bim_props.volume_unit = "CUBIC_METRE" + pprops.template_file = "0" elif self.preset == "metric_mm": - bpy.context.scene.BIMProjectProperties.export_schema = "IFC4" + pprops.export_schema = "IFC4" bpy.context.scene.unit_settings.system = "METRIC" bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" - bpy.context.scene.BIMProperties.area_unit = "SQUARE_METRE" - bpy.context.scene.BIMProperties.volume_unit = "CUBIC_METRE" - bpy.context.scene.BIMProjectProperties.template_file = "0" + bim_props.area_unit = "SQUARE_METRE" + bim_props.volume_unit = "CUBIC_METRE" + pprops.template_file = "0" elif self.preset == "imperial_ft": - bpy.context.scene.BIMProjectProperties.export_schema = "IFC4" + pprops.export_schema = "IFC4" bpy.context.scene.unit_settings.system = "IMPERIAL" bpy.context.scene.unit_settings.length_unit = "FEET" - bpy.context.scene.BIMProperties.area_unit = "square foot" - bpy.context.scene.BIMProperties.volume_unit = "cubic foot" - bpy.context.scene.BIMProjectProperties.template_file = "0" + bim_props.area_unit = "square foot" + bim_props.volume_unit = "cubic foot" + pprops.template_file = "0" elif self.preset == "demo": - bpy.context.scene.BIMProjectProperties.export_schema = "IFC4" + pprops.export_schema = "IFC4" bpy.context.scene.unit_settings.system = "METRIC" bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" - bpy.context.scene.BIMProperties.area_unit = "SQUARE_METRE" - bpy.context.scene.BIMProperties.volume_unit = "CUBIC_METRE" - bpy.context.scene.BIMProjectProperties.template_file = "IFC4 Demo Template.ifc" + bim_props.area_unit = "SQUARE_METRE" + bim_props.volume_unit = "CUBIC_METRE" + pprops.template_file = "IFC4 Demo Template.ifc" if self.preset != "wizard": bpy.ops.bim.create_project() @@ -125,7 +131,7 @@ class CreateProject(bpy.types.Operator): return {"FINISHED"} def _execute(self, context): - props = context.scene.BIMProjectProperties + props = tool.Project.get_project_props() template = None if props.template_file == "0" else props.template_file if tool.Blender.is_default_scene(): for obj in bpy.data.objects: @@ -147,12 +153,11 @@ class CreateProject(bpy.types.Operator): IfcStore.file = data["file"] -class SelectLibraryFile(bpy.types.Operator, IFCFileSelector): +class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_idname = "bim.select_library_file" bl_label = "Select Library File" bl_options = {"REGISTER", "UNDO"} bl_description = "Select an IFC file that can be used as a library" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) append_all: bpy.props.BoolProperty(default=False) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) @@ -184,12 +189,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector): context.area.tag_redraw() if self.append_all: bpy.ops.bim.append_entire_library() + ProjectLibraryData.load() return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - def rollback(self, data): if data["old_filepath"]: IfcStore.library_path = data["old_filepath"] @@ -210,6 +212,7 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector): class RefreshLibrary(bpy.types.Operator): bl_idname = "bim.refresh_library" bl_label = "Refresh Library" + bl_options = {"UNDO"} def execute(self, context): self.props = tool.Project.get_project_props() @@ -217,16 +220,27 @@ class RefreshLibrary(bpy.types.Operator): self.props.library_elements.clear() self.props.library_breadcrumb.clear() - self.props.active_library_element = "" - library_file = IfcStore.library_file assert library_file - condition = tool.Project.get_filter_for_active_library() - for importable_type in sorted(tool.Project.get_appendable_asset_types()): - if (elements := library_file.by_type(importable_type)) and (elements := list(condition(elements))): - elements = self.props.add_library_asset_group(importable_type, len(elements)) + if not self.props.show_library_tree: + for appendable_type in sorted(tool.Project.get_appendable_asset_types()): + elements = library_file.by_type(appendable_type) + self.props.add_library_asset_class(appendable_type, len(elements)) + return {"FINISHED"} + # Library tree. + # Add entry for unassigned elements. + elements = set() + for importable_type in sorted(tool.Project.get_appendable_asset_types()): + elements.update(library_file.by_type(importable_type)) + rels = tool.Project.get_project_library_rels(library_file) + elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)} + self.props.add_library_project_library("Unassigned", len(elements), 0, False) + + ifc_project = library_file.by_type("IfcProject")[0] + hierarchy = tool.Project.get_project_hierarchy(library_file) + tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy) return {"FINISHED"} @@ -234,10 +248,14 @@ class ChangeLibraryElement(bpy.types.Operator): bl_idname = "bim.change_library_element" bl_label = "Change Library Element" bl_options = {"REGISTER", "UNDO"} - element_name: bpy.props.StringProperty(description="IFC class to select") + element_name: bpy.props.StringProperty() + breadcrumb_type: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(BreadcrumbType)]) + library_id: bpy.props.IntProperty() if TYPE_CHECKING: element_name: str + breadcrumb_type: BreadcrumbType + library_id: int def execute(self, context): self.props = tool.Project.get_project_props() @@ -245,43 +263,91 @@ class ChangeLibraryElement(bpy.types.Operator): library_file = IfcStore.library_file assert library_file self.library_file = library_file - self.props.active_library_element = self.element_name crumb = self.props.library_breadcrumb.add() crumb.name = self.element_name + crumb.breadcrumb_type = self.breadcrumb_type + if self.breadcrumb_type == "LIBRARY": + crumb.library_id = self.library_id - filter_elements = tool.Project.get_filter_for_active_library() - elements = self.library_file.by_type(self.element_name) - elements = list(filter_elements(elements)) - ifc_classes_elements: dict[str, list[ifcopenshell.entity_instance]] = defaultdict(list) - for element in elements: - ifc_classes_elements[element.is_a()].append(element) + active_project_library = None + library_elements = None + project_library_rels = None + # Reverse to get last library in hierarchy. + for entry in reversed(self.props.library_breadcrumb): + if entry.breadcrumb_type == "LIBRARY": + if entry.library_id == 0: + # For unassigned elements. + active_project_library = "NO_LIBRARY" + project_library_rels = tool.Project.get_project_library_rels(library_file) + else: + active_project_library = library_file.by_id(entry.library_id) + library_elements = tool.Project.get_project_library_elements(active_project_library) + break + + def filter_elements(elements: list[ifcopenshell.entity_instance]) -> list[ifcopenshell.entity_instance]: + if active_project_library is None: + return elements + elif active_project_library == "NO_LIBRARY": + assert project_library_rels is not None + return [ + element + for element in elements + if not tool.Project.is_element_assigned_to_project_library(element, project_library_rels) + ] + else: + assert library_elements is not None + return [e for e in elements if e in library_elements] self.props.library_elements.clear() - if len(ifc_classes_elements) == 1 and list(ifc_classes_elements)[0] == self.element_name: - for name, ifc_definition_id in sorted( - [(self.get_name(e), e.id()) for e in ifc_classes_elements[self.element_name]] - ): - self.add_library_asset(name, ifc_definition_id) - else: - for ifc_class in sorted(ifc_classes_elements): - if ifc_class == self.element_name: - continue - self.props.add_library_asset_group(ifc_class, len(ifc_classes_elements[ifc_class])) - elements_ = ifc_classes_elements[self.element_name] - for name, ifc_definition_id, ifc_class in sorted([(self.get_name(e), e.id(), e.is_a()) for e in elements_]): - self.add_library_asset(name, ifc_definition_id) + if self.breadcrumb_type == "LIBRARY": + hierarchy = tool.Project.get_project_hierarchy(library_file) + assert active_project_library is not None + if active_project_library != "NO_LIBRARY" and hierarchy[active_project_library]: + tool.Project.load_project_libraries_to_ui(active_project_library, hierarchy) + + for appendable_type in sorted(tool.Project.get_appendable_asset_types()): + elements = library_file.by_type(appendable_type) + if elements := filter_elements(elements): + self.props.add_library_asset_class(appendable_type, len(elements)) + + else: # breadcrumb_type CLASS. + elements = self.library_file.by_type(self.element_name) + elements = list(filter_elements(elements)) + ifc_classes_elements: dict[str, list[ifcopenshell.entity_instance]] = defaultdict(list) + for element in elements: + ifc_classes_elements[element.is_a()].append(element) + + if len(ifc_classes_elements) == 1 and list(ifc_classes_elements)[0] == self.element_name: + for name, ifc_definition_id in sorted( + [(self.get_name(e), e.id()) for e in ifc_classes_elements[self.element_name]] + ): + self.add_library_asset(name, ifc_definition_id) + else: + for ifc_class in sorted(ifc_classes_elements): + if ifc_class == self.element_name: + continue + self.props.add_library_asset_class(ifc_class, len(ifc_classes_elements[ifc_class])) + elements_ = ifc_classes_elements[self.element_name] + for name, ifc_definition_id, ifc_class in sorted( + [(self.get_name(e), e.id(), e.is_a()) for e in elements_] + ): + self.add_library_asset(name, ifc_definition_id) + + # Could occur if all elements were assigned to a different library. + if len(self.props.library_elements) == 0: + bpy.ops.bim.rewind_library() + return {"FINISHED"} def get_name(self, element: ifcopenshell.entity_instance) -> str: - if element.is_a("IfcProfileDef"): - return element.ProfileName or "Unnamed" - return element.Name or "Unnamed" + attr_name = tool.Project.get_library_element_attr_name(element) + return getattr(element, attr_name) or "Unnamed" def add_library_asset(self, name: str, ifc_definition_id: int) -> None: new = self.props.library_elements.add() - new.name = name + new["name"] = name new.ifc_definition_id = ifc_definition_id element = self.library_file.by_id(ifc_definition_id) @@ -299,10 +365,7 @@ class ChangeLibraryElement(bpy.types.Operator): elif has_context := element.HasContext: relating_context: ifcopenshell.entity_instance relating_context = has_context[0].RelatingContext - if selected_library in ("-", "*"): - new.is_declared = relating_context.is_a("IfcProjectLibrary") - else: - new.is_declared = relating_context == self.library_file.by_id(int(selected_library)) + new.is_declared = relating_context == self.library_file.by_id(int(selected_library)) # is_appended. try: @@ -310,6 +373,8 @@ class ChangeLibraryElement(bpy.types.Operator): next(e for e in self.file.by_type("IfcMaterial") if e.Name == name) elif element.is_a("IfcProfileDef"): next(e for e in self.file.by_type("IfcProfileDef") if e.ProfileName == name) + elif element.is_a("IfcPresentationStyle"): + next(e for e in self.file.by_type(element.is_a()) if e.Name == name) else: self.file.by_guid(element.GlobalId) new.is_appended = True @@ -328,10 +393,17 @@ class RewindLibrary(bpy.types.Operator): if total_breadcrumbs < 2: bpy.ops.bim.refresh_library() return {"FINISHED"} - element_name = self.props.library_breadcrumb[total_breadcrumbs - 2].name + current_element = self.props.library_breadcrumb[total_breadcrumbs - 2] + element_name = current_element.name + breadcrumb_type = current_element.breadcrumb_type + library_id = current_element.library_id self.props.library_breadcrumb.remove(total_breadcrumbs - 1) self.props.library_breadcrumb.remove(total_breadcrumbs - 2) - bpy.ops.bim.change_library_element(element_name=element_name) + bpy.ops.bim.change_library_element( + element_name=element_name, + breadcrumb_type=breadcrumb_type, + library_id=library_id, + ) return {"FINISHED"} @@ -359,10 +431,7 @@ class AssignLibraryDeclaration(bpy.types.Operator): library_file = IfcStore.library_file assert library_file - if props.selected_project_library in ("*", "-"): - project_library = library_file.by_type("IfcProjectLibrary")[0] - else: - project_library = library_file.by_id(int(props.selected_project_library)) + project_library = library_file.by_id(int(props.selected_project_library)) ifcopenshell.api.project.assign_declaration( library_file, @@ -435,10 +504,10 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.library = IfcStore.library_file query = ", ".join(tool.Project.get_appendable_asset_types()) @@ -455,10 +524,10 @@ class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() self.library = IfcStore.library_file for element in ifcopenshell.util.selector.filter_elements(self.library, self.query): @@ -488,7 +557,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - poll = bool(IfcStore.get_file()) + poll = bool(tool.Ifc.get()) if not poll: cls.poll_message_set("Please create or load a project first.") return poll @@ -525,7 +594,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): self.import_presentation_style_from_ifc(element, context) try: - context.scene.BIMProjectProperties.library_elements[self.prop_index].is_appended = True + props = tool.Project.get_project_props() + props.library_elements[self.prop_index].is_appended = True except: # TODO Remove this terrible code when I refactor this into the core pass @@ -533,7 +603,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None: - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() logger = logging.getLogger("ImportIFC") ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) ifc_importer = import_ifc.IfcImporter(ifc_import_settings) @@ -551,7 +621,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): ifc_importer.create_style(style) def import_product_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None: - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() logger = logging.getLogger("ImportIFC") ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) ifc_importer = import_ifc.IfcImporter(ifc_import_settings) @@ -564,7 +634,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): ifc_importer.place_objects_in_collections() def import_type_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None: - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() logger = logging.getLogger("ImportIFC") ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger) @@ -579,7 +649,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): def import_materials(self, element: ifcopenshell.entity_instance, ifc_importer: import_ifc.IfcImporter) -> None: for material in ifcopenshell.util.element.get_materials(element): - if IfcStore.get_element(material.id()): + if tool.Ifc.get_object_by_identifier(material.id()): continue self.import_material_styles(material, ifc_importer) @@ -593,7 +663,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not element.is_a("IfcRepresentationItem") or not element.StyledByItem: continue for element2 in self.file.traverse(element.StyledByItem[0]): - if element2.is_a("IfcSurfaceStyle") and not IfcStore.get_element(element2.id()): + if element2.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element2.id()): ifc_importer.create_style(element2) def import_material_styles( @@ -604,7 +674,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not material.HasRepresentation: return for element in self.file.traverse(material.HasRepresentation[0]): - if element.is_a("IfcSurfaceStyle") and not IfcStore.get_element(element.id()): + if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()): ifc_importer.create_style(element) @@ -647,6 +717,7 @@ class EditProjectLibrary(bpy.types.Operator): ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library) props.is_editing_project_library = False + bpy.ops.bim.refresh_library() return {"FINISHED"} def rollback(self, data): @@ -700,14 +771,14 @@ class EnableEditingHeader(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def execute(self, context): - self.file = IfcStore.get_file() - props = context.scene.BIMProjectProperties + self.file = tool.Ifc.get() + props = tool.Project.get_project_props() props.is_editing = True - mvd = "".join(IfcStore.get_file().wrapped_data.header.file_description.description) + mvd = "".join(tool.Ifc.get().wrapped_data.header.file_description.description) if "[" in mvd: props.mvd = mvd.split("[")[1][0:-1] else: @@ -737,7 +808,7 @@ class EditHeader(bpy.types.Operator): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def execute(self, context): IfcStore.begin_transaction(self) @@ -750,8 +821,8 @@ class EditHeader(bpy.types.Operator): return result def _execute(self, context): - self.file = IfcStore.get_file() - props = context.scene.BIMProjectProperties + self.file = tool.Ifc.get() + props = tool.Project.get_project_props() props.is_editing = True self.file.wrapped_data.header.file_description.description = (f"ViewDefinition[{props.mvd}]",) @@ -762,7 +833,7 @@ class EditHeader(bpy.types.Operator): return {"FINISHED"} def record_state(self): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() return { "description": self.file.wrapped_data.header.file_description.description, "author": self.file.wrapped_data.header.file_name.author, @@ -771,14 +842,14 @@ class EditHeader(bpy.types.Operator): } def rollback(self, data): - file = IfcStore.get_file() + file = tool.Ifc.get() file.wrapped_data.header.file_description.description = data["old"]["description"] file.wrapped_data.header.file_name.author = data["old"]["author"] file.wrapped_data.header.file_name.organization = data["old"]["organisation"] file.wrapped_data.header.file_name.authorization = data["old"]["authorisation"] def commit(self, data): - file = IfcStore.get_file() + file = tool.Ifc.get() file.wrapped_data.header.file_description.description = data["new"]["description"] file.wrapped_data.header.file_name.author = data["new"]["author"] file.wrapped_data.header.file_name.organization = data["new"]["organisation"] @@ -792,11 +863,12 @@ class DisableEditingHeader(bpy.types.Operator): bl_description = "Cancel unsaved header information" def execute(self, context): - context.scene.BIMProjectProperties.is_editing = False + props = tool.Project.get_project_props() + props.is_editing = False return {"FINISHED"} -class LoadProject(bpy.types.Operator, IFCFileSelector): +class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_idname = "bim.load_project" bl_label = "Load Project" bl_options = {"REGISTER", "UNDO"} @@ -827,6 +899,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): default=False, ) use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) + filename_ext = ".ifc" @classmethod def description(cls, context, properties): @@ -838,7 +911,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): if not filepath: return tooltip filepath = Path(filepath) - tooltip += f".\n" + tooltip += ".\n" if not filepath.exists(): tooltip += "\nFile does not exist" return tooltip @@ -908,16 +981,17 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): if not self.is_advanced and not self.should_start_fresh_session: bpy.ops.bim.convert_to_blender() - context.scene.BIMProperties.ifc_file = filepath - if not (ifc_file := tool.Ifc.get()): + tool.Ifc.set_path(filepath) + if not tool.Ifc.get(): self.report( {"ERROR"}, f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.", ) return {"CANCELLED"} - context.scene.BIMProjectProperties.is_loading = True - context.scene.BIMProjectProperties.total_elements = len(tool.Ifc.get().by_type("IfcElement")) - context.scene.BIMProjectProperties.use_relative_project_path = self.use_relative_path + props = tool.Project.get_project_props() + props.is_loading = True + props.total_elements = len(tool.Ifc.get().by_type("IfcElement")) + props.use_relative_project_path = self.use_relative_path tool.Blender.register_toolbar() tool.Project.add_recent_ifc_project(self.get_filepath_abs()) @@ -937,8 +1011,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector): def invoke(self, context, event): if self.filepath: return self.execute(context) - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def draw(self, context): if self.use_relative_path: @@ -967,13 +1040,15 @@ class RevertProject(bpy.types.Operator, IFCFileSelector): @classmethod def poll(cls, context): - if not context.scene.BIMProperties.ifc_file: + props = tool.Blender.get_bim_props() + if not props.ifc_file: cls.poll_message_set("IFC project need to be loaded and saved on the disk.") return False return True def execute(self, context): - bpy.ops.bim.load_project(should_start_fresh_session=True, filepath=context.scene.BIMProperties.ifc_file) + props = tool.Blender.get_bim_props() + bpy.ops.bim.load_project(should_start_fresh_session=True, filepath=props.ifc_file) return {"FINISHED"} @@ -983,20 +1058,23 @@ class LoadProjectElements(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - self.props = context.scene.BIMProjectProperties - self.file = IfcStore.get_file() + self.props = tool.Project.get_project_props() + self.file = tool.Ifc.get() bonsai.bim.schema.reload(self.file.schema_identifier) start = time.time() logger = logging.getLogger("ImportIFC") path_log = tool.Blender.get_data_dir_path("process.log") if not os.access(path_log.parent, os.W_OK): - path_log = os.path.join(tempfile.mkdtemp(), "process.log") + path_log = os.path.join( + tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None), "process.log" + ) logging.basicConfig( filename=path_log, filemode="a", level=logging.DEBUG, ) - settings = import_ifc.IfcImportSettings.factory(context, context.scene.BIMProperties.ifc_file, logger) + props = tool.Blender.get_bim_props() + settings = import_ifc.IfcImportSettings.factory(context, props.ifc_file, logger) settings.has_filter = self.props.filter_mode != "NONE" settings.should_filter_spatial_elements = self.props.should_filter_spatial_elements if self.props.filter_mode == "DECOMPOSITION": @@ -1014,7 +1092,8 @@ class LoadProjectElements(bpy.types.Operator): ifc_importer.execute() settings.logger.info("Import finished in {:.2f} seconds".format(time.time() - start)) print("Import finished in {:.2f} seconds".format(time.time() - start)) - context.scene.BIMProjectProperties.is_loading = False + props = tool.Project.get_project_props() + props.is_loading = False tool.Project.load_pset_templates() tool.Project.load_default_thumbnails() @@ -1086,17 +1165,18 @@ class ToggleFilterCategories(bpy.types.Operator): should_select: bpy.props.BoolProperty(name="Should Select", default=True) def execute(self, context): - for filter_category in context.scene.BIMProjectProperties.filter_categories: + props = tool.Project.get_project_props() + for filter_category in props.filter_categories: filter_category.is_selected = self.should_select return {"FINISHED"} -class LinkIfc(bpy.types.Operator): +class LinkIfc(bpy.types.Operator, ImportHelper): bl_idname = "bim.link_ifc" bl_label = "Link IFC" bl_options = {"REGISTER", "UNDO"} bl_description = "Reference in a read-only IFC model in the background" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement) directory: bpy.props.StringProperty(subtype="DIR_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) @@ -1106,6 +1186,7 @@ class LinkIfc(bpy.types.Operator): default=False, ) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + filename_ext = ".ifc" if TYPE_CHECKING: filepath: str @@ -1113,7 +1194,7 @@ class LinkIfc(bpy.types.Operator): directory: str def draw(self, context): - pprops = context.scene.BIMProjectProperties + pprops = tool.Project.get_project_props() row = self.layout.row() row.prop(self, "use_relative_path") row = self.layout.row() @@ -1134,7 +1215,8 @@ class LinkIfc(bpy.types.Operator): if bpy.data.filepath and filepath.samefile(bpy.data.filepath): self.report({"INFO"}, "Can't link the current .blend file") continue - new = context.scene.BIMProjectProperties.links.add() + props = tool.Project.get_project_props() + new = props.links.add() filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path) new.name = filepath status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache) @@ -1150,10 +1232,6 @@ class LinkIfc(bpy.types.Operator): print(f"Finished linking {len(files)} IFCs", time.time() - start) return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class UnlinkIfc(bpy.types.Operator): bl_idname = "bim.unlink_ifc" @@ -1165,9 +1243,10 @@ class UnlinkIfc(bpy.types.Operator): def execute(self, context): filepath = Path(self.filepath).as_posix() bpy.ops.bim.unload_link(filepath=filepath) - index = context.scene.BIMProjectProperties.links.find(filepath) + props = tool.Project.get_project_props() + index = props.links.find(filepath) if index != -1: - context.scene.BIMProjectProperties.links.remove(index) + props.links.remove(index) return {"FINISHED"} @@ -1187,17 +1266,25 @@ class UnloadLink(bpy.types.Operator): if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath: bpy.data.libraries.remove(library) - links = context.scene.BIMProjectProperties.links - link = links.get(self.filepath) + props = tool.Project.get_project_props() + links = props.links + link = links[self.filepath] # Let's assume that user might delete it. if empty_handle := link.empty_handle: bpy.data.objects.remove(empty_handle) + + # following lines removes the library also when use_relative_path=True, otherwise it doesn't + libraries = bpy.data.libraries + for library in libraries: + if library.name == self.filepath + ".cache.blend": + bpy.data.libraries.remove(library) + link.is_loaded = False if not any([l.is_loaded for l in links]): ProjectDecorator.uninstall() # we make sure we don't draw queried object from the file that was just unlinked - elif queried_obj := context.scene.BIMProjectProperties.queried_obj: + elif queried_obj := props.queried_obj: queried_filepath = Path(queried_obj["ifc_filepath"]) if queried_filepath == filepath: ProjectDecorator.uninstall() @@ -1229,7 +1316,7 @@ class LoadLink(bpy.types.Operator): def link_blend(self, filepath: Path) -> None: with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to): data_to.scenes = data_from.scenes - link = bpy.context.scene.BIMProjectProperties.links[self.filepath] + link = tool.Project.get_project_props().links[self.filepath] for scene in bpy.data.scenes: if not scene.library or Path(scene.library.filepath) != filepath: continue @@ -1255,13 +1342,14 @@ class LoadLink(bpy.types.Operator): if not blend_filepath.exists(): pprops = tool.Project.get_project_props() - gprops = bpy.context.scene.BIMGeoreferenceProperties + gprops = tool.Georeference.get_georeference_props() code = f""" import bpy def run(): - gprops = bpy.context.scene.BIMGeoreferenceProperties + import bonsai.tool as tool + gprops = tool.Georeference.get_georeference_props() # Our model origin becomes their host model origin gprops.host_model_origin = "{gprops.model_origin}" gprops.host_model_origin_si = "{gprops.model_origin_si}" @@ -1272,7 +1360,7 @@ def run(): gprops.blender_offset_z = "{gprops.blender_offset_z}" gprops.blender_x_axis_abscissa = "{gprops.blender_x_axis_abscissa}" gprops.blender_x_axis_ordinate = "{gprops.blender_x_axis_ordinate}" - pprops = bpy.context.scene.BIMProjectProperties + pprops = tool.Project.get_project_props() pprops.distance_limit = {pprops.distance_limit} pprops.false_origin_mode = "{pprops.false_origin_mode}" pprops.false_origin = "{pprops.false_origin}" @@ -1327,7 +1415,7 @@ except Exception as e: with open(json_filepath, "r") as f: data = json.load(f) - gprops = bpy.context.scene.BIMGeoreferenceProperties + gprops = tool.Georeference.get_georeference_props() for prop in ("model_origin", "model_origin_si", "model_project_north"): if (value := data.get(prop, None)) is not None: setattr(gprops, prop, value) @@ -1352,6 +1440,12 @@ class ReloadLink(bpy.types.Operator): for library in get_linked_ifcs(): library.reload() + + is_abs = os.path.isabs(Path(self.filepath)) + use_relative_path = not is_abs + bpy.ops.bim.unlink_ifc(filepath=self.filepath) + status = bpy.ops.bim.link_ifc(filepath=self.filepath, use_cache=False, use_relative_path=use_relative_path) + return {"FINISHED"} @@ -1363,8 +1457,8 @@ class ToggleLinkSelectability(bpy.types.Operator): link: bpy.props.StringProperty(name="Linked IFC Filepath") def execute(self, context): - props = context.scene.BIMProjectProperties - link = props.links.get(self.link) + props = tool.Project.get_project_props() + link = props.links[self.link] self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) link.is_selectable = (is_selectable := not link.is_selectable) for collection in self.get_linked_collections(): @@ -1390,8 +1484,8 @@ class ToggleLinkVisibility(bpy.types.Operator): mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE"))) def execute(self, context): - props = context.scene.BIMProjectProperties - link = props.links.get(self.link) + props = tool.Project.get_project_props() + link = props.links[self.link] self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend")) if self.mode == "WIREFRAME": self.toggle_wireframe(link) @@ -1399,19 +1493,15 @@ class ToggleLinkVisibility(bpy.types.Operator): self.toggle_visibility(link) return {"FINISHED"} - def toggle_wireframe(self, link): + def toggle_wireframe(self, link: "Link") -> None: + link.is_wireframe = not link.is_wireframe + display_type = "WIRE" if link.is_wireframe else "TEXTURED" for collection in self.get_linked_collections(): objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects) - for i, obj in enumerate(objs): - if i == 0: - if obj.display_type == "WIRE": - display_type = "TEXTURED" - else: - display_type = "WIRE" + for obj in objs: obj.display_type = display_type - link.is_wireframe = display_type == "WIRE" - def toggle_visibility(self, link): + def toggle_visibility(self, link: "Link") -> None: linked_collections = self.get_linked_collections() link.is_hidden = (is_hidden := not link.is_hidden) @@ -1437,7 +1527,7 @@ class SelectLinkHandle(bpy.types.Operator): index: bpy.props.IntProperty(name="Link Index") def execute(self, context): - props = context.scene.BIMProjectProperties + props = tool.Project.get_project_props() link = props.links[self.index] handle = link.empty_handle if not handle: @@ -1447,13 +1537,14 @@ class SelectLinkHandle(bpy.types.Operator): return {"FINISHED"} -class ExportIFC(bpy.types.Operator): +class ExportIFC(bpy.types.Operator, ExportHelper): bl_idname = "bim.save_project" bl_label = "Save IFC" + # Prevents crash on Blender 4.4.0. + bl_description = "Save active IFC file by the provided filepath." bl_options = {"REGISTER", "UNDO"} filename_ext = ".ifc" filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcjson", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version") json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) @@ -1479,22 +1570,16 @@ class ExportIFC(bpy.types.Operator): bpy.ops.wm.save_mainfile("INVOKE_DEFAULT") return {"FINISHED"} - self.use_relative_path = context.scene.BIMProjectProperties.use_relative_project_path - if (filepath := context.scene.BIMProperties.ifc_file) and not self.should_save_as: + self.use_relative_path = tool.Project.get_project_props().use_relative_project_path + props = tool.Blender.get_bim_props() + if (filepath := props.ifc_file) and not self.should_save_as: self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath))) return self.execute(context) - if not self.filepath: - if bpy.data.is_saved: - self.filepath = Path(bpy.data.filepath).with_suffix(".ifc").__str__() - else: - self.filepath = "untitled.ifc" - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ExportHelper.invoke(self, context, event) def execute(self, context): - project_props = context.scene.BIMProjectProperties + project_props = tool.Project.get_project_props() project_props.use_relative_project_path = self.use_relative_path if project_props.should_disable_undo_on_save: old_history_size = tool.Ifc.get().history_size @@ -1512,7 +1597,9 @@ class ExportIFC(bpy.types.Operator): logger = logging.getLogger("ExportIFC") path_log = tool.Blender.get_data_dir_path("process.log") if not os.access(path_log.parent, os.W_OK): - path_log = os.path.join(tempfile.mkdtemp(), "process.log") + path_log = os.path.join( + tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None), "process.log" + ) logging.basicConfig( filename=path_log, filemode="a", @@ -1525,6 +1612,7 @@ class ExportIFC(bpy.types.Operator): output_file = bpy.path.ensure_ext(self.filepath, ".ifcjson") else: output_file = bpy.path.ensure_ext(self.filepath, ".ifc") + output_file = Path(output_file).as_posix().replace("\\", "/") settings = export_ifc.IfcExportSettings.factory(context, output_file, logger) settings.json_version = self.json_version @@ -1538,18 +1626,20 @@ class ExportIFC(bpy.types.Operator): print("Export finished in {:.2f} seconds".format(time.time() - start)) # New project created in Bonsai should be in recent projects too. tool.Project.add_recent_ifc_project(Path(output_file)) - scene = context.scene - if not scene.DocProperties.ifc_files: - new = scene.DocProperties.ifc_files.add() + props = tool.Drawing.get_document_props() + if not props.ifc_files: + new = props.ifc_files.add() new.name = output_file - if context.scene.BIMProjectProperties.use_relative_project_path and bpy.data.is_saved: + props = tool.Project.get_project_props() + if props.use_relative_project_path and bpy.data.is_saved: output_file = os.path.relpath(output_file, bpy.path.abspath("//")) - if scene.BIMProperties.ifc_file != output_file and extension not in ["ifczip", "ifcjson"]: - scene.BIMProperties.ifc_file = output_file + bim_props = tool.Blender.get_bim_props() + if bim_props.ifc_file != output_file and extension not in ("ifczip", "ifcjson"): + tool.Ifc.set_path(output_file) save_blend_file = bool(bpy.data.is_saved and bpy.data.is_dirty and bpy.data.filepath) if save_blend_file: bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath) - bpy.context.scene.BIMProperties.is_dirty = False + bim_props.is_dirty = False bonsai.bim.handler.refresh_ui_data() self.report( {"INFO"}, @@ -1563,12 +1653,11 @@ class ExportIFC(bpy.types.Operator): return "Save the IFC file. Will save both .IFC/.BLEND files if synced together" -class LoadLinkedProject(bpy.types.Operator): +class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_idname = "bim.load_linked_project" bl_label = "Load Project For Viewing Only" bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty() file: ifcopenshell.file meshes: dict[str, bpy.types.Mesh] @@ -1577,9 +1666,7 @@ class LoadLinkedProject(bpy.types.Operator): def invoke(self, context, event): # Invoke is for debugging purposes, users are not intended to use this method really. - WindowManager = context.window_manager - WindowManager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def execute(self, context): import ifcpatch @@ -1587,8 +1674,8 @@ class LoadLinkedProject(bpy.types.Operator): start = time.time() - pprops = bpy.context.scene.BIMProjectProperties - gprops = bpy.context.scene.BIMGeoreferenceProperties + pprops = tool.Project.get_project_props() + gprops = tool.Georeference.get_georeference_props() self.filepath = Path(self.filepath).as_posix() print("Processing", self.filepath) @@ -1802,7 +1889,7 @@ class LoadLinkedProject(bpy.types.Operator): mesh = bpy.data.meshes.new("Mesh") geometry = shape.geometry - gprops = bpy.context.scene.BIMGeoreferenceProperties + gprops = tool.Georeference.get_georeference_props() if ( gprops.has_blender_offset and geometry.verts @@ -1908,9 +1995,8 @@ class QueryLinkedElement(bpy.types.Operator): from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d LinksData.linked_data = {} - props = context.scene.BIMProjectProperties + props = tool.Project.get_project_props() props.queried_obj = None - props.quried_obj_root = None for area in bpy.context.screen.areas: if area.type == "PROPERTIES": @@ -2046,6 +2132,7 @@ class AppendInspectedLinkedElement(AppendLibraryElement): def _execute(self, context): from bonsai.bim.module.project.data import LinksData + props = tool.Project.get_project_props() if not LinksData.linked_data: self.report({"INFO"}, "No linked element found.") return {"CANCELLED"} @@ -2055,7 +2142,7 @@ class AppendInspectedLinkedElement(AppendLibraryElement): self.report({"INFO"}, "Cannot find Global Id for element.") return {"CANCELLED"} - queried_obj = context.scene.BIMProjectProperties.queried_obj + queried_obj = props.queried_obj ifc_file = tool.Ifc.get() linked_ifc_file: ifcopenshell.file @@ -2087,7 +2174,8 @@ class EnableCulling(bpy.types.Operator): bl_label = "Enable Culling" bl_options = {"REGISTER"} - def __init__(self): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.last_view_corners = None self.total_mousemoves = 0 self.cullable_objects = [] @@ -2193,8 +2281,10 @@ class RefreshClippingPlanes(bpy.types.Operator): bl_label = "Refresh Clipping Planes" bl_options = {"REGISTER"} - def __init__(self): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.total_planes = 0 + self.camera = None def invoke(self, context, event): context.window_manager.modal_handler_add(self) @@ -2202,49 +2292,51 @@ class RefreshClippingPlanes(bpy.types.Operator): def modal(self, context, event): should_refresh = False + props = tool.Project.get_project_props() self.clean_deleted_planes(context) - for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: - if clipping_plane.obj and self.is_moved(clipping_plane.obj): + for clipping_plane in props.clipping_planes: + if clipping_plane.obj and tool.Ifc.is_moved(clipping_plane.obj, ifc_only=False): should_refresh = True break - total_planes = len(context.scene.BIMProjectProperties.clipping_planes) + if (camera := context.scene.camera) and camera.visible_get() and tool.Ifc.get_entity(camera): + camera = context.scene.camera + else: + camera = None + + if self.camera != camera: + should_refresh = True + elif self.camera and tool.Ifc.is_moved(self.camera, ifc_only=False): + should_refresh = True + + total_planes = len(props.clipping_planes) if should_refresh or total_planes != self.total_planes: + self.camera = camera self.refresh_clipping_planes(context) - for clipping_plane in context.scene.BIMProjectProperties.clipping_planes: + for clipping_plane in props.clipping_planes: if clipping_plane.obj: tool.Geometry.record_object_position(clipping_plane.obj) self.total_planes = total_planes return {"PASS_THROUGH"} - def clean_deleted_planes(self, context): + def clean_deleted_planes(self, context: bpy.types.Context) -> None: + props = tool.Project.get_project_props() while True: - for i, clipping_plane in enumerate(context.scene.BIMProjectProperties.clipping_planes): + for i, clipping_plane in enumerate(props.clipping_planes): if clipping_plane.obj: try: clipping_plane.obj.name except: - context.scene.BIMProjectProperties.clipping_planes.remove(i) + props.clipping_planes.remove(i) break else: - context.scene.BIMProjectProperties.clipping_planes.remove(i) + props.clipping_planes.remove(i) break else: break - def is_moved(self, obj): - if not obj.BIMObjectProperties.location_checksum: - return True # Let's be conservative - loc_check = np.frombuffer(eval(obj.BIMObjectProperties.location_checksum)) - rot_check = np.frombuffer(eval(obj.BIMObjectProperties.rotation_checksum)) - loc_real = np.array(obj.matrix_world.translation).flatten() - rot_real = np.array(obj.matrix_world.to_3x3()).flatten() - if np.allclose(loc_check, loc_real, atol=1e-4) and np.allclose(rot_check, rot_real, atol=1e-2): - return False - return True - def refresh_clipping_planes(self, context): import bmesh from itertools import cycle @@ -2253,14 +2345,15 @@ class RefreshClippingPlanes(bpy.types.Operator): region = next(r for r in area.regions if r.type == "WINDOW") data = region.data - if not len(context.scene.BIMProjectProperties.clipping_planes): + props = tool.Project.get_project_props() + if not len(props.clipping_planes) and not self.camera: data.use_clip_planes = False else: with bpy.context.temp_override(area=area, region=region): bpy.ops.view3d.clip_border() clip_planes = [] - for clipping_plane in bpy.context.scene.BIMProjectProperties.clipping_planes: + for clipping_plane in tool.Project.get_project_props().clipping_planes: obj = clipping_plane.obj if not obj: continue @@ -2282,6 +2375,14 @@ class RefreshClippingPlanes(bpy.types.Operator): clip_planes.append(clip_plane) bm.free() + if self.camera: + normal = self.camera.matrix_world.col[2].to_3d() + normal *= -1 + center = self.camera.matrix_world.translation + distance = -center.dot(normal) + clip_plane = (normal.x, normal.y, normal.z, distance) + clip_planes.append(clip_plane) + clip_planes = cycle(clip_planes) data.clip_planes = [tuple(next(clip_planes)) for i in range(0, 6)] data.update() @@ -2299,8 +2400,8 @@ class CreateClippingPlane(bpy.types.Operator): from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d # Clean up deleted planes - - if len(context.scene.BIMProjectProperties.clipping_planes) > 5: + props = tool.Project.get_project_props() + if len(props.clipping_planes) > 5: self.report({"INFO"}, "Maximum of six clipping planes allowed.") return {"FINISHED"} @@ -2340,7 +2441,7 @@ class CreateClippingPlane(bpy.types.Operator): context.scene.cursor.location = location - new = context.scene.BIMProjectProperties.clipping_planes.add() + new = tool.Project.get_project_props().clipping_planes.add() new.obj = plane_obj tool.Blender.set_active_object(plane_obj) @@ -2371,7 +2472,7 @@ class FlipClippingPlane(bpy.types.Operator): def execute(self, context): obj = context.active_object - if obj in context.scene.BIMProjectProperties.clipping_planes_objs: + if obj in tool.Project.get_project_props().clipping_planes_objs: obj.rotation_euler[0] += radians(180) context.view_layer.update() return {"FINISHED"} @@ -2389,14 +2490,15 @@ class BIM_OT_save_clipping_planes(bpy.types.Operator): @classmethod def poll(cls, context): if IfcStore.path: - return context.scene.BIMProjectProperties.clipping_planes + return tool.Project.get_project_props().clipping_planes cls.poll_message_set("Please Save The IFC File") def execute(self, context): clipping_planes_to_serialize = defaultdict(dict) - clipping_planes = context.scene.BIMProjectProperties.clipping_planes + clipping_planes = tool.Project.get_project_props().clipping_planes for clipping_plane in clipping_planes: obj = clipping_plane.obj + assert obj name = obj.name clipping_planes_to_serialize[name]["location"] = obj.location[0:3] clipping_planes_to_serialize[name]["rotation"] = obj.rotation_euler[0:3] @@ -2422,13 +2524,14 @@ class BIM_OT_load_clipping_planes(bpy.types.Operator): cls.poll_message_set("Please Save The IFC File") def execute(self, context): - bpy.data.batch_remove(context.scene.BIMProjectProperties.clipping_planes_objs) - context.scene.BIMProjectProperties.clipping_planes.clear() + props = tool.Project.get_project_props() + bpy.data.batch_remove(props.clipping_planes_objs) + props.clipping_planes.clear() with open(Path(IfcStore.path).with_name(CLIPPING_PLANES_FILE_NAME), "r") as file: clipping_planes_dict = json.load(file) for name, values in clipping_planes_dict.items(): bpy.ops.bim.create_clipping_plane() - obj = context.scene.BIMProjectProperties.clipping_planes_objs[-1] + obj = props.clipping_planes_objs[-1] obj.name = name obj.location = values["location"] obj.rotation_euler = values["rotation"] @@ -2488,13 +2591,11 @@ class MeasureTool(bpy.types.Operator, PolylineOperator): def poll(cls, context): return context.space_data.type == "VIEW_3D" - def __init__(self): - super().__init__() - if self.measure_type == "AREA": - self.input_ui = tool.Polyline.create_input_ui(init_z=True, init_area=True) - else: - self.input_ui = tool.Polyline.create_input_ui(init_z=True) + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) self.input_options = ["D", "A", "X", "Y", "Z"] + self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options) def modal(self, context, event): PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) @@ -2574,6 +2675,106 @@ class MeasureTool(bpy.types.Operator, PolylineOperator): return {"RUNNING_MODAL"} +class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator): + bl_idname = "bim.measure_face_area_tool" + bl_label = "Measure Face Area Tool" + bl_options = {"REGISTER", "UNDO"} + + measure_type: bpy.props.StringProperty() + + @classmethod + def poll(cls, context): + return context.space_data.type == "VIEW_3D" + + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + self.input_options = ["AREA"] + self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options) + self.clicked_faces = [] + self.total_area = 0 + if tool.Ifc.get(): + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + self.unit_scale = tool.Blender.get_unit_scale() + + def modal(self, context, event): + def select_face(mouse_pos): + objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, self.objs_2d_bbox) + obj, _, face_index = tool.Raycast.cast_rays_and_get_best_object( + context, event, objs_to_raycast, include_wireframes=False + ) + if face_index is not None: + return obj, face_index + return None, None + + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + custom_instructions = { + "Select Face": {"icons": True, "keys": ["MOUSE_LMB"]}, + "Deselect Face": {"icons": True, "keys": ["EVENT_SHIFT", "MOUSE_LMB"]}, + } + custom_info = [] + self.handle_instructions(context, custom_instructions, custom_info, overwrite=True) + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_mouse_move(context, event) + + if event.value == "PRESS" and event.type == "LEFTMOUSE": + tool.Blender.update_viewport() + mouse_pos = event.mouse_region_x, event.mouse_region_y + obj, face_index = select_face(mouse_pos) + if face_index is not None: + if obj.data.polygons[face_index] not in self.clicked_faces: + self.clicked_faces.append(obj.data.polygons[face_index]) + self.total_area += obj.data.polygons[face_index].area + self.input_ui.set_value("AREA", self.total_area) + polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline.add() + polyline_data.id = obj.name + str(face_index) + for v_id in obj.data.polygons[face_index].vertices: + vertex = obj.matrix_world @ obj.data.vertices[v_id].co + polyline_point = polyline_data.polyline_points.add() + polyline_point.x = vertex.x + polyline_point.y = vertex.y + polyline_point.z = vertex.z + tool.Blender.update_viewport() + + if event.shift and (event.value == "PRESS" and event.type == "LEFTMOUSE"): + mouse_pos = event.mouse_region_x, event.mouse_region_y + obj, face_index = select_face(mouse_pos) + if face_index: + if obj.data.polygons[face_index] in self.clicked_faces: + self.clicked_faces.remove(obj.data.polygons[face_index]) + self.total_area -= obj.data.polygons[face_index].area + self.input_ui.set_value("AREA", self.total_area) + polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline + for i, polyline in enumerate(polyline_data): + if polyline.id == obj.name + str(face_index): + polyline_data.remove(i) + tool.Blender.update_viewport() + + if event.value == "RELEASE" and event.type in {"ESC", "RIGHTMOUSE"}: + bpy.context.scene.BIMPolylineProperties.insertion_polyline.clear() + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + FaceAreaDecorator.uninstall() + tool.Blender.update_viewport() + return {"CANCELLED"} + + return {"RUNNING_MODAL"} + + def invoke(self, context, event): + super().invoke(context, event) + PolylineDecorator.uninstall() + PolylineDecorator.install(context, ui_only=True) + FaceAreaDecorator.install(context) + return {"RUNNING_MODAL"} + + class ClearMeasurement(bpy.types.Operator): bl_idname = "bim.clear_measurement" bl_label = "Clear measurement from the screen" diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index fe23355846..3d57a7b926 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -35,7 +35,8 @@ from bpy.props import ( IntProperty, StringProperty, ) -from typing import TYPE_CHECKING, Literal, Union +from typing import TYPE_CHECKING, Literal, Union, get_args +from typing_extensions import assert_never def get_export_schema(self: "BIMProjectProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: @@ -67,15 +68,15 @@ def update_library_file(self: "BIMProjectProperties", context: bpy.types.Context bpy.ops.bim.select_library_file(filepath=filepath.__str__()) ProjectLibraryData.load() props = tool.Project.get_project_props() - props.selected_project_library = "*" + library_file = IfcStore.library_file + assert library_file + project_library = next(iter(library_file.by_type("IfcProjectLibrary")), None) + props.selected_project_library = str(project_library.id()) if project_library else "-" def update_selected_project_library(self: "BIMProjectProperties", context: bpy.types.Context) -> None: - if self.filter_by_library: - bpy.ops.bim.refresh_library() - else: - # Ensure `.is_declared` up to date. - tool.Project.update_current_library_page() + # Ensure `.is_declared` up to date. + tool.Project.update_current_library_page() def get_project_libaries(self: "BIMProjectProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: @@ -84,10 +85,7 @@ def get_project_libaries(self: "BIMProjectProperties", context: bpy.types.Contex return ProjectLibraryData.data["project_libraries_enum"] -def filter_by_library_update(self: "BIMProjectProperties", context: bpy.types.Context) -> None: - if self.filter_by_library and self.selected_project_library == "*": - # Filter is toggled from OFF to ON, so it was showing all elements previously either way. - return +def show_library_tree_update(self: "BIMProjectProperties", context: bpy.types.Context) -> None: bpy.ops.bim.refresh_library() @@ -123,7 +121,7 @@ def update_filter_mode(self: "BIMProjectProperties", context: bpy.types.Context) self.filter_categories.clear() if self.filter_mode == "NONE": return - file = IfcStore.get_file() + file = tool.Ifc.get() if self.filter_mode == "DECOMPOSITION": if file.schema == "IFC2X3": elements = file.by_type("IfcSpatialStructureElement") @@ -150,10 +148,40 @@ def update_filter_mode(self: "BIMProjectProperties", context: bpy.types.Context) new.total_elements = len(ifcopenshell.util.element.get_types(ifc_type)) +def update_library_element_name(self: "LibraryElement", context: bpy.types.Context) -> None: + library_file = IfcStore.library_file + assert library_file + + if self.element_type == "CLASS": + raise Exception("Unexpected element type for rename: 'CLASS'.") + + def update_element_name(ifc_definition_id: int, name: str) -> None: + element = library_file.by_id(ifc_definition_id) + attr_name = tool.Project.get_library_element_attr_name(element) + previous_name = getattr(element, attr_name) + if name == previous_name: + return + setattr(element, attr_name, name) + + if self.element_type == "ASSET": + update_element_name(self.ifc_definition_id, self.name) + elif self.element_type == "LIBRARY": + assert self.ifc_definition_id, "Renaming for unassigned elements library is not supported." + update_element_name(self.ifc_definition_id, self.name) + else: + assert_never(self.element_type) + + +LibraryElementType = Literal["ASSET", "CLASS", "LIBRARY"] + + class LibraryElement(PropertyGroup): - name: StringProperty(name="Name") + name: StringProperty(name="Name", update=update_library_element_name) + element_type: EnumProperty(items=[(i, i, "") for i in get_args(LibraryElementType)], name="Element Type") # Asset group. asset_count: IntProperty(name="Asset Count") + # Asset library. + has_sublibraries: BoolProperty(name="Has Sublibraries", default=False) # Asset. ifc_definition_id: IntProperty(name="IFC Definition ID") is_declared: BoolProperty(name="Is Declared", default=False) @@ -166,7 +194,9 @@ class LibraryElement(PropertyGroup): if TYPE_CHECKING: name: str + element_type: LibraryElementType asset_count: int + has_sublibraries: bool ifc_definition_id: int is_declared: bool is_appended: bool @@ -217,6 +247,18 @@ class EditedObj(PropertyGroup): obj: Union[bpy.types.Object, None] +BreadcrumbType = Literal["LIBRARY", "CLASS"] + + +class LibraryBreadcrumb(PropertyGroup): + breadcrumb_type: EnumProperty(items=[(i, i, "") for i in get_args(BreadcrumbType)]) + library_id: IntProperty(description="IFC Definition ID for libraries.") + + if TYPE_CHECKING: + breadcrumb_type: BreadcrumbType + library_id: int + + class BIMProjectProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) is_loading: BoolProperty(name="Is Loading", default=False) @@ -226,8 +268,7 @@ class BIMProjectProperties(PropertyGroup): organisation_name: StringProperty(name="Organisation") organisation_email: StringProperty(name="Organisation Email") authorisation: StringProperty(name="Authoriser") - active_library_element: StringProperty(name="Enable Authoring Mode", default="") - library_breadcrumb: CollectionProperty(name="Library Breadcrumb", type=StrProperty) + library_breadcrumb: CollectionProperty(name="Library Breadcrumb", type=LibraryBreadcrumb) library_elements: CollectionProperty(name="Library Elements", type=LibraryElement) active_library_element_index: IntProperty(name="Active Library Element Index") filter_mode: bpy.props.EnumProperty( @@ -261,7 +302,14 @@ class BIMProjectProperties(PropertyGroup): should_merge_materials_by_colour: BoolProperty(name="Merge Materials by Colour", default=False) should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False) should_load_geometry: BoolProperty(name="Load Geometry", default=True) - should_clean_mesh: BoolProperty(name="Clean Meshes", default=False) + should_clean_mesh: BoolProperty( + name="Clean Meshes", + description=( + "Convert all triangles to quads for meshes. " + "By default Bonsai is importing meshes triangulated (even if they are not stored as triangulated in IFC)." + ), + default=False, + ) should_cache: BoolProperty(name="Cache", default=False) deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.001) angular_tolerance: FloatProperty(name="Angular Tolerance", default=0.5) @@ -270,6 +318,11 @@ class BIMProjectProperties(PropertyGroup): default=30, description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings", ) + style_limit: IntProperty( + name="Style Limit", + default=300, + description="Maxium number of styles before styles are automatically merged", + ) distance_limit: FloatProperty(name="Distance Limit", default=1000, subtype="DISTANCE") false_origin_mode: bpy.props.EnumProperty( items=[ @@ -321,15 +374,15 @@ class BIMProjectProperties(PropertyGroup): library_file: EnumProperty(items=get_library_file, name="Library File", update=update_library_file) selected_project_library: EnumProperty( items=get_project_libaries, - name="Project Library", - description="Project library to display elements from", + name="Selected Project Library", + description="Selected project library to edit or to assign elements to", update=update_selected_project_library, ) - filter_by_library: BoolProperty( - name="Filter by Library", - description="Filter library elements based on selected library. If unselected can be used to assign selected library to library elements.", + show_library_tree: BoolProperty( + name="Show Library Tree", + description="Show project libraries hierarchy or just show the assets classes.", default=True, - update=filter_by_library_update, + update=show_library_tree_update, ) is_editing_project_library: BoolProperty( name="Is Editing Project Library", @@ -357,10 +410,22 @@ class BIMProjectProperties(PropertyGroup): def clipping_planes_objs(self) -> list[bpy.types.Object]: return list({cp.obj for cp in self.clipping_planes if cp.obj}) - def add_library_asset_group(self, name: str, asset_count: int) -> LibraryElement: + def add_library_project_library( + self, name: str, asset_count: int, ifc_definition_id: int, has_sublibraries: bool + ) -> LibraryElement: new = self.library_elements.add() - new.name = name + new["name"] = name new.asset_count = asset_count + new.element_type = "LIBRARY" + new.ifc_definition_id = ifc_definition_id + new.has_sublibraries = has_sublibraries + return new + + def add_library_asset_class(self, name: str, asset_count: int) -> LibraryElement: + new = self.library_elements.add() + new["name"] = name + new.asset_count = asset_count + new.element_type = "CLASS" return new def get_library_element_index(self, lib_element: LibraryElement) -> int: @@ -375,8 +440,7 @@ class BIMProjectProperties(PropertyGroup): organisation_name: str organisation_email: str authorisation: str - active_library_element: str - library_breadcrumb: bpy.types.bpy_prop_collection_idprop[StrProperty] + library_breadcrumb: bpy.types.bpy_prop_collection_idprop[LibraryBreadcrumb] library_elements: bpy.types.bpy_prop_collection_idprop[LibraryElement] active_library_element_index: int filter_mode: Literal["NONE", "DECOMPOSITION", "IFC_CLASS", "IFC_TYPE", "WHITELIST", "BLACKLIST"] @@ -395,6 +459,7 @@ class BIMProjectProperties(PropertyGroup): deflection_tolerance: float angular_tolerance: float void_limit: int + style_limit: int distance_limit: float false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"] false_origin: str @@ -410,8 +475,8 @@ class BIMProjectProperties(PropertyGroup): template_file: str library_file: str - selected_project_library: Union[Literal["*", "-"], str] - filter_by_library: bool + selected_project_library: Union[Literal["-"], str] + show_library_tree: bool is_editing_project_library: bool editing_project_library_id: int project_library_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] @@ -424,12 +489,18 @@ class BIMProjectProperties(PropertyGroup): clipping_planes_active: int edited_objs: bpy.types.bpy_prop_collection_idprop[EditedObj] + def get_active_library_breadcrumb(self) -> Union[LibraryBreadcrumb, None]: + if self.library_breadcrumb: + return self.library_breadcrumb[-1] + return None + class MeasureToolSettings(PropertyGroup): measurement_type_items = [ ("SINGLE", "SINGLE", "Single", "FIXED_SIZE", 1), ("POLYLINE", "POLYLINE", "Polyline", "DRIVER_ROTATIONAL_DIFFERENCE", 2), - ("AREA", "AREA", "Area", "OUTLINER_DATA_LIGHTPROBE", 3), + ("POLY_AREA", "POLY_AREA", "Poyline Area", "OUTLINER_DATA_LIGHTPROBE", 3), + ("FACE_AREA", "FACE_AREA", "Face Area", "FACESEL", 4), ] measurement_type: bpy.props.EnumProperty(items=measurement_type_items, default="POLYLINE") diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 51505ed46e..f066b0c51d 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -28,7 +28,7 @@ from bonsai.bim.module.project.data import ProjectData, LinksData from typing import TYPE_CHECKING if TYPE_CHECKING: - from bonsai.bim.module.project.prop import LibraryElement, BIMProjectProperties + from bonsai.bim.module.project.prop import LibraryElement, BIMProjectProperties, FilterCategory, Link def file_import_menu(self, context): @@ -151,9 +151,9 @@ class BIM_PT_project(Panel): self.layout.use_property_decorate = False self.layout.use_property_split = True - props = context.scene.BIMProperties + props = tool.Blender.get_bim_props() pprops = self.props = tool.Project.get_project_props() - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() if pprops.is_loading: self.draw_advanced_loading_ui(context) elif self.file or props.ifc_file: @@ -213,6 +213,8 @@ class BIM_PT_project(Panel): row = self.layout.row() row.prop(pprops, "void_limit") row = self.layout.row() + row.prop(pprops, "style_limit") + row = self.layout.row() row.prop(pprops, "distance_limit") row = self.layout.row() row.prop(pprops, "false_origin_mode") @@ -247,7 +249,7 @@ class BIM_PT_project(Panel): def draw_editing_buttons(self, context, row): pprops = self.props - if IfcStore.get_file(): + if tool.Ifc.get(): if pprops.is_editing: row.operator("bim.edit_header", icon="CHECKMARK", text="") row.operator("bim.disable_editing_header", icon="CANCEL", text="") @@ -257,10 +259,10 @@ class BIM_PT_project(Panel): def draw_editable_file_info(self, context): pprops = self.props - if IfcStore.get_file(): + if tool.Ifc.get(): row = self.layout.row(align=True) row.label(text="IFC Schema", icon="FILE_CACHE") - row.label(text=IfcStore.get_file().schema) + row.label(text=tool.Ifc.get().schema) if pprops.is_editing: row = self.layout.row(align=True) @@ -281,7 +283,7 @@ class BIM_PT_project(Panel): else: row = self.layout.row(align=True) row.label(text="IFC MVD", icon="FILE_HIDDEN") - mvd = "".join(IfcStore.get_file().wrapped_data.header.file_description.description) + mvd = "".join(tool.Ifc.get().wrapped_data.header.file_description.description) if "[" in mvd: mvd = mvd.split("[")[1][0:-1] row.label(text=mvd) @@ -305,7 +307,7 @@ class BIM_PT_project(Panel): def draw_loaded_project_ui(self, context): # file name row - props = context.scene.BIMProperties + props = tool.Blender.get_bim_props() file_name_row = self.layout.row(align=True) file_name_row.label(text=os.path.basename(props.ifc_file), icon="FILE") self.draw_editing_buttons(context, file_name_row) @@ -315,14 +317,16 @@ class BIM_PT_project(Panel): # file path row and actions section row = self.layout.row(align=True) - if context.scene.BIMProperties.is_dirty: + if props.is_dirty: row.label(text="Saved*", icon="EXPORT") else: row.label(text="Saved", icon="EXPORT") row.label(text=ProjectData.data["last_saved"]) row = self.layout.row(align=True) - row.prop(props, "ifc_file", text="") + col = row.column() + col.enabled = False + col.prop(props, "ifc_file", text="") row.operator("bim.select_ifc_file", icon="FILE_FOLDER", text="") @@ -339,7 +343,7 @@ class BIM_PT_new_project_wizard(Panel): self.layout.use_property_decorate = False self.layout.use_property_split = True - props = context.scene.BIMProperties + props = tool.Blender.get_bim_props() pprops = tool.Project.get_project_props() prop_with_search(self.layout, pprops, "export_schema") row = self.layout.row() @@ -392,7 +396,7 @@ class BIM_PT_project_library(Panel): library_file = IfcStore.library_file assert library_file - library_is_selected = props.selected_project_library not in ("*", "-") + library_is_selected = props.selected_project_library != "-" row = layout.row(align=True) row.prop(self.props, "selected_project_library", text="") @@ -403,7 +407,7 @@ class BIM_PT_project_library(Panel): if library_is_selected and not props.is_editing_project_library: row.prop(props, "is_editing_project_library", text="", icon="GREASEPENCIL") - row.prop(self.props, "filter_by_library", text="", icon="FILTER") + row.prop(self.props, "show_library_tree", text="", icon="OUTLINER") if props.is_editing_project_library: row = layout.row(align=True) @@ -420,8 +424,9 @@ class BIM_PT_project_library(Panel): return row = self.layout.row(align=True) - row.label(text=self.props.active_library_element or "Top Level Assets") - if self.props.active_library_element: + active_library_element = self.props.get_active_library_breadcrumb() + row.label(text=(active_library_element.name if active_library_element else "Top Level Assets")) + if active_library_element: row.operator("bim.rewind_library", icon="FRAME_PREV", text="") row.operator("bim.refresh_library", icon="FILE_REFRESH", text="") self.layout.template_list( @@ -511,10 +516,15 @@ class BIM_UL_library(UIList): ): if item: row = layout.row(align=True) - if not item.ifc_definition_id: + if item.element_type != "ASSET" and (item.asset_count > 0 or item.has_sublibraries): op = row.operator("bim.change_library_element", text="", icon="DISCLOSURE_TRI_RIGHT", emboss=False) op.element_name = item.name - row.label(text=item.name) + op.breadcrumb_type = item.element_type + op.library_id = item.ifc_definition_id + if item.ifc_definition_id: + row.prop(item, "name", text="", emboss=False) + else: + row.label(text=item.name) if item.ifc_definition_id and item.is_declarable: if item.is_declared: op = row.operator("bim.unassign_library_declaration", text="", icon="KEYFRAME_HLT", emboss=False) @@ -522,7 +532,7 @@ class BIM_UL_library(UIList): else: op = row.operator("bim.assign_library_declaration", text="", icon="KEYFRAME", emboss=False) op.definition = item.ifc_definition_id - if item.ifc_definition_id: + if item.element_type == "ASSET": if item.is_appended: row.label(text="", icon="CHECKMARK") else: @@ -536,7 +546,16 @@ class BIM_UL_library(UIList): class BIM_UL_filter_categories(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMProjectProperties, + item: FilterCategory, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) row.label(text=f"{item.name} ({item.total_elements})") @@ -550,7 +569,17 @@ class BIM_UL_filter_categories(UIList): class BIM_UL_links(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMProjectProperties, + item: Link, + icon, + active_data, + active_propname, + index, + ): if item: row = layout.row(align=True) if item.is_loaded: diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index 39187a3fae..d58ad55adb 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -74,8 +74,8 @@ class ExploreTool(bpy.types.WorkSpaceTool): class ExploreHotkey(bpy.types.Operator): bl_idname = "bim.explore_hotkey" - bl_label = "Explore Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -106,4 +106,7 @@ class ExploreHotkey(bpy.types.Operator): for obj in tool.Blender.get_selected_objects(): obj.select_set(False) measure_type = bpy.context.scene.MeasureToolSettings.measurement_type - bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type=measure_type) + if measure_type == "FACE_AREA": + bpy.ops.bim.measure_face_area_tool("INVOKE_DEFAULT") + else: + bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type=measure_type) diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index ab89d67b31..0c015f784a 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -175,7 +175,7 @@ class MaterialPsetsData(Data): @classmethod def load(cls): ifc_definition_id = None - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.materials and props.active_material_index < len(props.materials): ifc_definition_id = props.materials[props.active_material_index].ifc_definition_id @@ -188,7 +188,7 @@ class MaterialPsetsData(Data): @classmethod def pset_name(cls): - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.materials and props.active_material_index < len(props.materials): material = props.materials[props.active_material_index] if material.ifc_definition_id: @@ -212,7 +212,9 @@ class MaterialSetItemPsetsData(Data): @classmethod def load(cls): psets = {} - ifc_definition_id = bpy.context.active_object.BIMObjectMaterialProperties.active_material_set_item_id + obj = bpy.context.active_object + assert obj + ifc_definition_id = tool.Material.get_object_material_props(obj).active_material_set_item_id if ifc_definition_id: psets = cls.psetqtos(tool.Ifc.get().by_id(ifc_definition_id)) cls.data = {"psets": psets} diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index 4165951a53..e7a1301c6f 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -94,7 +94,7 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): properties: bpy.props.StringProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() props = tool.Pset.get_pset_props(self.obj, self.obj_type) ifc_definition_id = tool.Blender.get_obj_ifc_definition_id(self.obj, self.obj_type, context) element = tool.Ifc.get().by_id(ifc_definition_id) @@ -226,7 +226,7 @@ class AddQto(bpy.types.Operator, tool.Ifc.Operator): obj_type: bpy.props.StringProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() qto_name = tool.Pset.get_pset_name(self.obj, self.obj_type, pset_type="QTO") bpy.ops.bim.enable_pset_editing( pset_id=0, pset_name=qto_name, pset_type="QTO", obj=self.obj, obj_type=self.obj_type @@ -314,7 +314,7 @@ class BIM_OT_rename_parameters(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props_to_map = context.scene.RenameProperties - ifc_file = IfcStore.get_file() + ifc_file = tool.Ifc.get() all_ifc_elements = ifc_file.by_type("IfcElement") for ifc_element in all_ifc_elements: @@ -347,14 +347,13 @@ class BIM_OT_add_edit_custom_property(bpy.types.Operator, tool.Ifc.Operator): index: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() props = context.scene.AddEditProperties for obj in tool.Blender.get_selected_objects(): - ifc_definition_id = obj.BIMObjectProperties.ifc_definition_id - if not ifc_definition_id: + ifc_element = tool.Ifc.get_entity(obj) + if not ifc_element: continue - ifc_element = tool.Ifc.get().by_id(ifc_definition_id) for prop in props: value = getattr(prop, prop.get_value_name()) @@ -402,27 +401,23 @@ class BIM_OT_bulk_remove_psets(bpy.types.Operator, tool.Ifc.Operator): index: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() props = context.scene.DeletePsets for obj in tool.Blender.get_selected_objects(): - ifc_definition_id = obj.BIMObjectProperties.ifc_definition_id - if not ifc_definition_id: + ifc_element = tool.Ifc.get_entity(obj) + if not ifc_element: continue - ifc_element = tool.Ifc.get().by_id(ifc_definition_id) psets = ifcopenshell.util.element.get_psets(ifc_element) for prop in props: pset = prop.pset_name if pset in psets: try: - ifcopenshell.api.run( - "pset.remove_pset", + ifcopenshell.api.pset.remove_pset( self.file, - **{ - "product": self.file.by_id(ifc_definition_id), - "pset": self.file.by_id(psets[pset]["id"]), - }, + product=ifc_element, + pset=self.file.by_id(psets[pset]["id"]), ) except KeyError: pass # Sometimes the pset id is not found, I'm not sure why this happens though. - vulevukusej diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 2014ff1bd6..81768c6f95 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -26,7 +26,6 @@ import bonsai.tool as tool from bonsai.bim.prop import Attribute, StrProperty from bonsai.bim.module.pset.data import AddEditCustomPropertiesData, ObjectPsetsData, MaterialPsetsData from bonsai.bim.module.material.data import ObjectMaterialData -from bonsai.bim.ifc import IfcStore from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -110,10 +109,12 @@ def get_material_set_pset_names(self, context): return psetnames[ifc_class] -def get_material_set_item_pset_names(self, context): +def get_material_set_item_pset_names(self, context) -> list[tuple[str, str, str]]: global psetnames - ifc_definition_id = context.active_object.BIMObjectMaterialProperties.active_material_set_item_id - if not ifc_definition_id: + obj = context.active_object + assert obj + omprops = tool.Material.get_object_material_props(obj) + if not omprops.active_material_set_item_id: return [] ifc_class = tool.Ifc.get().by_id(ifc_definition_id).is_a() if ifc_class not in psetnames: @@ -135,7 +136,7 @@ def get_resource_pset_names(self, context): global psetnames rprops = context.scene.BIMResourceProperties rtprops = context.scene.BIMResourceTreeProperties - ifc_class = IfcStore.get_file().by_id(rtprops.resources[rprops.active_resource_index].ifc_definition_id).is_a() + ifc_class = tool.Ifc.get().by_id(rtprops.resources[rprops.active_resource_index].ifc_definition_id).is_a() if ifc_class not in psetnames: psets = bonsai.bim.schema.ifc.psetqto.get_applicable(ifc_class, pset_only=True, schema=tool.Ifc.get_schema()) psetnames[ifc_class] = blender_formatted_enum_from_psets(psets) @@ -146,7 +147,7 @@ def get_resource_qto_names(self, context): global qtonames rprops = context.scene.BIMResourceProperties rtprops = context.scene.BIMResourceTreeProperties - ifc_class = IfcStore.get_file().by_id(rtprops.resources[rprops.active_resource_index].ifc_definition_id).is_a() + ifc_class = tool.Ifc.get().by_id(rtprops.resources[rprops.active_resource_index].ifc_definition_id).is_a() if ifc_class not in qtonames: psets = bonsai.bim.schema.ifc.psetqto.get_applicable(ifc_class, qto_only=True, schema=tool.Ifc.get_schema()) qtonames[ifc_class] = blender_formatted_enum_from_psets(psets) @@ -173,8 +174,8 @@ def get_group_qto_names(self, context): def get_profile_pset_names(self, context): global psetnames - pprops = context.scene.BIMProfileProperties - ifc_class = IfcStore.get_file().by_id(pprops.profiles[pprops.active_profile_index].ifc_definition_id).is_a() + pprops = tool.Profile.get_profile_props() + ifc_class = tool.Ifc.get().by_id(pprops.profiles[pprops.active_profile_index].ifc_definition_id).is_a() if ifc_class not in psetnames: psets = bonsai.bim.schema.ifc.psetqto.get_applicable(ifc_class, pset_only=True, schema=tool.Ifc.get_schema()) psetnames[ifc_class] = blender_formatted_enum_from_psets(psets) @@ -290,7 +291,8 @@ class AddEditProperties(PropertyGroup): enum_values: CollectionProperty(name="Enum Values", type=Attribute) def get_value_name(self) -> Union[Literal["string_value", "bool_value", "int_value", "float_value"], None]: - ifc_data_type = IfcStore.get_schema().declaration_by_name(self.primary_measure_type) + schema = tool.Ifc.schema() + ifc_data_type = schema.declaration_by_name(self.primary_measure_type) data_type = ifcopenshell.util.attribute.get_primitive_type(ifc_data_type) if data_type == "string": return "string_value" diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index 8db5c36389..62fd6366c9 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -20,7 +20,6 @@ from __future__ import annotations import bpy import bonsai.tool as tool from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import prop_with_search, get_display_value from bonsai.bim.module.pset.data import ( ObjectPsetsData, @@ -238,16 +237,17 @@ class BIM_PT_object_psets(Panel): def draw_header(self, context): row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row - row.prop(context.scene.GlobalPsetProperties, "pset_filter", text="", icon="VIEWZOOM") + global_props = tool.Pset.get_global_pset_props() + row.prop(global_props, "pset_filter", text="", icon="VIEWZOOM") @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False return True @@ -262,6 +262,7 @@ class BIM_PT_object_psets(Panel): op.obj = context.active_object.name op.obj_type = "Object" + global_props = tool.Pset.get_global_pset_props() if not props.active_pset_id and props.active_pset_name and props.active_pset_type == "PSET": draw_psetqto_ui( context, @@ -270,7 +271,7 @@ class BIM_PT_object_psets(Panel): props, self.layout, "Object", - filter_keyword=context.scene.GlobalPsetProperties.pset_filter, + filter_keyword=global_props.pset_filter, ) if ObjectPsetsData.data["psets"]: @@ -286,7 +287,7 @@ class BIM_PT_object_psets(Panel): props, self.layout, "Object", - filter_keyword=context.scene.GlobalPsetProperties.pset_filter, + filter_keyword=global_props.pset_filter, ) if ObjectPsetsData.data["inherited_psets"]: @@ -300,7 +301,7 @@ class BIM_PT_object_psets(Panel): self.layout, "Object", allow_removing=False, - filter_keyword=context.scene.GlobalPsetProperties.pset_filter, + filter_keyword=global_props.pset_filter, ) @@ -316,16 +317,17 @@ class BIM_PT_object_qtos(Panel): def draw_header(self, context): row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row - row.prop(context.scene.GlobalPsetProperties, "qto_filter", text="", icon="VIEWZOOM") + global_props = tool.Pset.get_global_pset_props() + row.prop(global_props, "qto_filter", text="", icon="VIEWZOOM") @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False return True @@ -340,6 +342,7 @@ class BIM_PT_object_qtos(Panel): op.obj = context.active_object.name op.obj_type = "Object" + global_props = tool.Pset.get_global_pset_props() if not props.active_pset_id and props.active_pset_name and props.active_pset_type == "QTO": draw_psetqto_ui( context, @@ -348,7 +351,7 @@ class BIM_PT_object_qtos(Panel): props, self.layout, "Object", - filter_keyword=context.scene.GlobalPsetProperties.qto_filter, + filter_keyword=global_props.qto_filter, ) if ObjectQtosData.data["qtos"]: @@ -364,7 +367,7 @@ class BIM_PT_object_qtos(Panel): props, self.layout, "Object", - filter_keyword=context.scene.GlobalPsetProperties.qto_filter, + filter_keyword=global_props.qto_filter, ) if ObjectQtosData.data["inherited_qsets"]: @@ -378,10 +381,10 @@ class BIM_PT_object_qtos(Panel): self.layout, "Object", allow_removing=False, - filter_keyword=context.scene.GlobalPsetProperties.qto_filter, + filter_keyword=global_props.qto_filter, ) layout = self.layout - qtoprops = context.scene.BIMQtoProperties + qtoprops = tool.Qto.get_qto_props() row = layout.row(align=True) row.prop(qtoprops, "qto_rule", text="") # A bit confusing as we typically use this icon for is_null. @@ -403,13 +406,13 @@ class BIM_PT_material_psets(Panel): ifc_file = tool.Ifc.get() if not ifc_file or ifc_file.schema == "IFC2X3": return False # We don't support material psets in IFC2X3 because they suck - props = context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.is_editing and (material := props.active_material) and material.ifc_definition_id: return True return False def draw(self, context): - props = context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if props.materials and props.active_material_index < len(props.materials): ifc_definition_id = props.materials[props.active_material_index].ifc_definition_id @@ -460,7 +463,8 @@ class BIM_PT_material_set_item_psets(Panel): obj = context.active_object assert obj - if not obj.BIMObjectMaterialProperties.active_material_set_item_id: + omprops = tool.Material.get_object_material_props(obj) + if not omprops.active_material_set_item_id: self.layout.label(text="No Material Set Item Edited.") return @@ -663,10 +667,10 @@ class BIM_PT_profile_psets(Panel): @classmethod def poll(cls, context): - props = context.scene.BIMProfileProperties + props = tool.Profile.get_profile_props() if not props.is_editing: return False - total_profiles = len(context.scene.BIMProfileProperties.profiles) + total_profiles = len(props.profiles) if total_profiles > 0 and props.active_profile_index < total_profiles: return True return False diff --git a/src/bonsai/bonsai/bim/module/pset_template/data.py b/src/bonsai/bonsai/bim/module/pset_template/data.py index ccf589b463..8eae1fdc42 100644 --- a/src/bonsai/bonsai/bim/module/pset_template/data.py +++ b/src/bonsai/bonsai/bim/module/pset_template/data.py @@ -56,7 +56,7 @@ class PsetTemplatesData: ifc_file = IfcStore.pset_template_file if not ifc_file: return [] - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema) + schema = ifcopenshell.schema_by_name(ifc_file.schema) version = ifc_file.schema enum_items = [ (t, t, ifcopenshell.util.doc.get_type_doc(version, t).get("description", "")) @@ -97,7 +97,7 @@ class PsetTemplatesData: # If mixed typed are used (e.g. during transition), assume type is not specified. pset_type = next(iter(pset_types)) if len(pset_types) == 1 else None - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema) + schema = ifcopenshell.schema_by_name(ifc_file.schema) attribute = schema.declaration_by_name("IfcSimplePropertyTemplate").attributes()[0] enum_items = [ a diff --git a/src/bonsai/bonsai/bim/module/pset_template/prop.py b/src/bonsai/bonsai/bim/module/pset_template/prop.py index 66cd7e3fea..fdd6188a76 100644 --- a/src/bonsai/bonsai/bim/module/pset_template/prop.py +++ b/src/bonsai/bonsai/bim/module/pset_template/prop.py @@ -21,6 +21,7 @@ import bpy import ifcopenshell import ifcopenshell.util.attribute from ifcopenshell.util.doc import get_attribute_doc +import bonsai.tool as tool from bonsai.bim.module.pset_template.data import PsetTemplatesData from bonsai.bim.prop import StrProperty, Attribute from bonsai.bim.ifc import IfcStore @@ -183,7 +184,8 @@ class PropTemplate(PropertyGroup): def get_value_name(self) -> str: if self.primary_measure_type == "-": return "string_value" - ifc_data_type = IfcStore.get_schema().declaration_by_name(self.primary_measure_type) + schema = tool.Ifc.schema() + ifc_data_type = schema.declaration_by_name(self.primary_measure_type) data_type = ifcopenshell.util.attribute.get_primitive_type(ifc_data_type) if data_type == "string": return "string_value" diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index e4ba0580a7..264a8fbf6b 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -24,11 +24,13 @@ import bonsai.tool as tool import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.element +import ifc5d.qto from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree from shapely.geometry import Polygon from shapely.ops import unary_union from typing import Literal, Union, Optional +from typing_extensions import assert_never AxisType = Literal["x", "y", "z"] @@ -36,18 +38,22 @@ VectorTuple = tuple[float, float, float] def get_x(o: bpy.types.Object) -> float: + """Calculate the length along the local X axis.""" return o.bound_box[6][0] - o.bound_box[0][0] def get_y(o: bpy.types.Object) -> float: + """Calculate the length along the local Y axis.""" return o.bound_box[6][1] - o.bound_box[0][1] def get_z(o: bpy.types.Object) -> float: + """Calculate the length along the local Z axis.""" return o.bound_box[6][2] - o.bound_box[0][2] def get_units(o: bpy.types.Object, vg_index: int) -> int: + assert isinstance(o.data, bpy.types.Mesh) return len([v for v in o.data.vertices if vg_index in [g.group for g in v.groups]]) @@ -63,19 +69,13 @@ def get_linear_length(o: bpy.types.Object) -> float: return max(x, y, z) -def get_length(o: bpy.types.Object, vg_index: Optional[int] = None, main_axis: str = "x") -> float: +def get_length(o: bpy.types.Object, vg_index: Optional[int] = None) -> float: + """Calculate the object length trying to guess the main axis.""" if vg_index is None: - x = get_x(o) - y = get_y(o) - z = get_z(o) - if get_object_main_axis(o) == "x" or main_axis == "x": - return max(x, y) - if get_object_main_axis(o) == "z": - return max(z, x) - if get_object_main_axis(o) == "y": - return max(y, z) + return get_linear_length(o) length = 0 + assert isinstance(o.data, bpy.types.Mesh) edges = [ e for e in o.data.edges @@ -109,7 +109,9 @@ def get_gross_stair_area(obj: bpy.types.Object) -> float: def get_parametric_axis(obj: bpy.types.Object) -> Literal["AXIS2", "AXIS3", None]: - relating_type = ifcopenshell.util.element.get_type(tool.Ifc.get_entity(obj)) + element = tool.Ifc.get_entity(obj) + assert element + relating_type = ifcopenshell.util.element.get_type(element) if relating_type: parametric = ifcopenshell.util.element.get_psets(relating_type).get("EPset_Parametric") if parametric: @@ -132,6 +134,8 @@ def get_covering_gross_area(obj: bpy.types.Object) -> float: return get_gross_side_area(obj) elif parametrix_axis == "AXIS3": return get_gross_footprint_area(obj) + else: + assert_never(parametrix_axis) def get_covering_net_area(obj: bpy.types.Object) -> float: @@ -142,6 +146,8 @@ def get_covering_net_area(obj: bpy.types.Object) -> float: return get_net_side_area(obj) elif parametrix_axis == "AXIS3": return get_net_footprint_area(obj) + else: + assert_never(parametrix_axis) def get_covering_width(obj: bpy.types.Object) -> float: @@ -152,11 +158,15 @@ def get_covering_width(obj: bpy.types.Object) -> float: return get_width(obj) elif parametrix_axis == "AXIS3": return get_height(obj) + else: + assert_never(parametrix_axis) def get_width(o: bpy.types.Object) -> float: """_summary_: Returns the width of the object bounding box + Min value between X and Y axes lengths. + :param blender-object o: blender object :return float: width """ @@ -168,6 +178,8 @@ def get_width(o: bpy.types.Object) -> float: def get_height(o: bpy.types.Object) -> float: """_summary_: Returns the height of the object bounding box + Based on the the length along the local Z axis. + :param blender-object o: blender object :return float: height """ @@ -216,6 +228,7 @@ def get_finish_floor_height(obj: bpy.types.Object) -> float: space_min_z_value = get_min_global_z(obj) element = tool.Ifc.get_entity(obj) + assert element decompositions = ifcopenshell.util.element.get_decomposition(element) flooring_max_z_value = space_min_z_value for decomposition in decompositions: @@ -224,6 +237,7 @@ def get_finish_floor_height(obj: bpy.types.Object) -> float: and ifcopenshell.util.element.get_predefined_type(decomposition) == "FLOORING" ): flooring_obj = tool.Ifc.get_object(decomposition) + assert isinstance(flooring_obj, bpy.types.Object) flooring_z_value = get_max_global_z(flooring_obj) if flooring_z_value > space_min_z_value: flooring_max_z_value = flooring_z_value @@ -236,6 +250,7 @@ def get_ceiling_height(obj: bpy.types.Object) -> float: space_max_z_value = get_max_global_z(obj) element = tool.Ifc.get_entity(obj) + assert element decompositions = ifcopenshell.util.element.get_decomposition(element) ceiling_min_z_value = space_max_z_value for decomposition in decompositions: @@ -244,6 +259,7 @@ def get_ceiling_height(obj: bpy.types.Object) -> float: and ifcopenshell.util.element.get_predefined_type(decomposition) == "CEILING" ): ceiling_obj = tool.Ifc.get_object(decomposition) + assert isinstance(ceiling_obj, bpy.types.Object) ceiling_z_value = get_min_global_z(ceiling_obj) if ceiling_z_value < space_max_z_value: ceiling_min_z_value = ceiling_z_value @@ -269,6 +285,7 @@ def get_net_perimeter(o: bpy.types.Object) -> float: def get_gross_perimeter(o: bpy.types.Object) -> float: element = tool.Ifc.get_entity(o) + assert element mesh = get_gross_element_mesh(element) gross_obj = bpy.data.objects.new("GrossObj", mesh) gross_perimeter = get_net_perimeter(gross_obj) @@ -281,7 +298,8 @@ def get_space_net_perimeter(obj: bpy.types.Object) -> float: def get_rectangular_perimeter(obj: bpy.types.Object) -> float: - length = get_length(obj, main_axis="x") + """Get object perimeter in XZ plane.""" + length = get_x(obj) height = get_height(obj) return (length + height) * 2 @@ -289,6 +307,7 @@ def get_rectangular_perimeter(obj: bpy.types.Object) -> float: def get_lowest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: lowest_polygons = [] lowest_z = None + assert isinstance(o.data, bpy.types.Mesh) for polygon in o.data.polygons: z = round(polygon.center[2], 3) if lowest_z is None: @@ -306,6 +325,7 @@ def get_lowest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: def get_highest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: highest_polygons = [] highest_z = None + assert isinstance(o.data, bpy.types.Mesh) for polygon in o.data.polygons: z = round(polygon.center[2], 3) if highest_z is None: @@ -321,10 +341,12 @@ def get_highest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: def get_edge_key_distance(obj: bpy.types.Object, edge_key: tuple[int, int]) -> float: + assert isinstance(obj.data, bpy.types.Mesh) return (obj.data.vertices[edge_key[1]].co - obj.data.vertices[edge_key[0]].co).length def get_edge_distance(obj: bpy.types.Object, edge: bpy.types.MeshEdge) -> float: + assert isinstance(obj.data, bpy.types.Mesh) return (obj.data.vertices[edge.vertices[1]].co - obj.data.vertices[edge.vertices[0]].co).length @@ -339,6 +361,7 @@ def get_net_floor_area(obj: bpy.types.Object) -> float: decomposition_type = decomposition.get_info()["type"] if decomposition_type == "IfcColumn" or decomposition_type == "IfcColumn": decomposition_obj = tool.Ifc.get_object(decomposition) + assert isinstance(decomposition_obj, bpy.types.Object) net_footprint_obj_area = get_net_footprint_area(decomposition_obj) total_net_floor_area -= net_footprint_obj_area @@ -357,6 +380,7 @@ def get_gross_ceiling_area(obj: bpy.types.Object) -> float: decomposition_predefined_type = ifcopenshell.util.element.get_predefined_type(decomposition) if decomposition_class == "IfcCovering" and decomposition_predefined_type == "CEILING": decomposition_obj = tool.Ifc.get_object(decomposition) + assert isinstance(decomposition_obj, bpy.types.Object) total_gross_ceiling_area += get_gross_footprint_area(decomposition_obj) return total_gross_ceiling_area @@ -374,10 +398,12 @@ def get_net_ceiling_area(obj: bpy.types.Object) -> float: decomposition_predefined_type = ifcopenshell.util.element.get_predefined_type(decomposition) if decomposition_class == "IfcCovering" and decomposition_predefined_type == "CEILING": decomposition_obj = tool.Ifc.get_object(decomposition) + assert isinstance(decomposition_obj, bpy.types.Object) total_net_ceiling_area += get_net_footprint_area(decomposition_obj) if decomposition_class == "IfcWall" or decomposition_class == "IfcColumn": decomposition_obj = tool.Ifc.get_object(decomposition) + assert isinstance(decomposition_obj, bpy.types.Object) total_net_ceiling_area -= get_net_roofprint_area(decomposition_obj) return total_net_ceiling_area @@ -394,6 +420,7 @@ def get_space_net_volume(obj: bpy.types.Object) -> float: decomposition_type = decomposition.get_info()["type"] if decomposition_type == "IfcWall" or decomposition_type == "IfcColumn": decomposition_obj = tool.Ifc.get_object(decomposition) + assert isinstance(decomposition_obj, bpy.types.Object) total_space_net_volume -= get_net_volume(decomposition_obj) return total_space_net_volume @@ -420,6 +447,7 @@ def get_gross_footprint_area(o: bpy.types.Object) -> float: return get_net_footprint_area(o) element = tool.Ifc.get_entity(o) + assert element mesh = get_gross_element_mesh(element) gross_obj = bpy.data.objects.new("GrossObj", mesh) gross_footprint_area = get_net_footprint_area(gross_obj) @@ -451,7 +479,8 @@ def get_side_area(o: bpy.types.Object) -> float: def get_cross_section_area(obj: bpy.types.Object) -> float: - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + representation = tool.Geometry.get_active_representation(obj) + assert representation item = representation.Items[0] while True: if item.is_a("IfcExtrudedAreaSolid"): @@ -473,12 +502,14 @@ def get_gross_surface_area(o: bpy.types.Object, vg_index: Optional[int] = None) return get_net_surface_area(o) element = tool.Ifc.get_entity(o) + assert element mesh = get_gross_element_mesh(element) area = get_mesh_area(mesh) bpy.data.meshes.remove(mesh) return area area = 0 + assert isinstance(o.data, bpy.types.Mesh) vertices_in_vg = [v.index for v in o.data.vertices if vg_index in [g.group for g in v.groups]] for polygon in o.data.polygons: if is_polygon_in_vg(polygon, vertices_in_vg): @@ -487,6 +518,7 @@ def get_gross_surface_area(o: bpy.types.Object, vg_index: Optional[int] = None) def get_net_surface_area(obj: bpy.types.Object) -> float: + assert isinstance(obj.data, bpy.types.Mesh) return get_mesh_area(obj.data) @@ -497,7 +529,7 @@ def get_mesh_area(mesh: bpy.types.Mesh) -> float: return area -def is_polygon_in_vg(polygon: bpy.types.MeshPolygon, vertices_in_vg: list[bpy.types.MeshVertex]) -> bool: +def is_polygon_in_vg(polygon: bpy.types.MeshPolygon, vertices_in_vg: list[int]) -> bool: for v in polygon.vertices: if v not in vertices_in_vg: return False @@ -505,6 +537,7 @@ def is_polygon_in_vg(polygon: bpy.types.MeshPolygon, vertices_in_vg: list[bpy.ty def get_net_volume(o: bpy.types.Object) -> float: + assert isinstance(o.data, bpy.types.Mesh) o_mesh = bmesh.new() o_mesh.from_mesh(o.data) volume = o_mesh.calc_volume() @@ -517,6 +550,7 @@ def get_gross_volume(o: bpy.types.Object) -> float: return get_net_volume(o) element = tool.Ifc.get_entity(o) + assert element mesh = get_gross_element_mesh(element) bm = get_bmesh_from_mesh(mesh) @@ -532,78 +566,70 @@ def has_openings(obj: bpy.types.Object) -> list[ifcopenshell.entity_instance]: element = tool.Ifc.get_entity(obj) if not element: return [] - return [o for o in tool.Geometry.get_openings(element)] + return [o for o in ifcopenshell.util.element.get_openings(element)] def get_obj_decompositions(obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]: element = tool.Ifc.get_entity(obj) + assert element decompositions = ifcopenshell.util.element.get_decomposition(element) return decompositions def get_gross_weight(obj: bpy.types.Object) -> Union[float, None]: + """Get gross weight of the object. + + Based on gross volume and Pset_MaterialCommon.MassDensity + or Pset_ProfileMechanical.MassPerLength and extrusion depth if it's profile based. + """ + + weight = get_profile_obj_weight(obj) + if weight is not None: + return weight + obj_mass_density = get_obj_mass_density(obj) if not obj_mass_density: return + gross_volume = get_gross_volume(obj) gross_weight = obj_mass_density * gross_volume return gross_weight def get_net_weight(obj: bpy.types.Object) -> Union[float, None]: + """Get net weight of the object. + + Based on Pset_ProfileMechanical.MassPerLength and extrusion depth + (for profile based objects, though objects with openings are not supported) + or object's net volume and Pset_MaterialCommon.MassDensity. + """ + + if not has_openings(obj): + weight = get_profile_obj_weight(obj) + if weight is not None: + return weight + obj_mass_density = get_obj_mass_density(obj) if not obj_mass_density: return + net_volume = get_net_volume(obj) net_weight = obj_mass_density * net_volume return net_weight def get_obj_mass_density(obj: bpy.types.Object) -> Union[float, None]: + """Calculate object mass density based on Pset_MaterialCommon.MassDensity.""" entity = tool.Ifc.get_entity(obj) - material = ifcopenshell.util.element.get_material(entity) - if material is None: - return + assert entity + return ifcopenshell.util.element.get_element_mass_density(entity) - if ( - material.is_a("IfcMaterialLayerSet") - or material.is_a("IfcMaterialProfileSet") - or material.is_a("IfcMaterialConstituentSet") - ): - return - if material.is_a("IfcMaterial"): - material_mass_density = ifcopenshell.util.element.get_pset(material, "Pset_MaterialCommon", "MassDensity") - return material_mass_density - - if material.is_a("IfcMaterialLayerSetUsage"): - material_layers = material.ForLayerSet.MaterialLayers - densities = [] - thicknesses = [] - obj_mass_density = 0 - for material_layer in material_layers: - material_mass_density = ifcopenshell.util.element.get_pset( - material_layer.Material, "Pset_MaterialCommon", "MassDensity" - ) - if material_mass_density is None: - return - densities.append(material_mass_density) - thickness = material_layer.LayerThickness - thicknesses.append(thickness) - obj_mass_density = obj_mass_density + (material_mass_density * thickness) - total_thickness = sum(thicknesses) - obj_mass_density = obj_mass_density / total_thickness - return obj_mass_density - - if material.is_a("IfcMaterialProfileSetUsage"): - material_profiles = material.ForProfileSet.MaterialProfiles - if len(material_profiles) == 1: - material_mass_density = ifcopenshell.util.element.get_pset( - material_profiles[0].Material, "Pset_MaterialCommon", "MassDensity" - ) - return material_mass_density - else: - return +def get_profile_obj_weight(obj: bpy.types.Object) -> Union[float, None]: + element = tool.Ifc.get_entity(obj) + assert element + weight = ifc5d.qto.IfcOpenShell.get_weight_profile_based(element) + return weight def get_opening_type(opening: bpy.types.Object, obj: bpy.types.Object) -> Literal["OPENING", "RECESS"]: @@ -613,6 +639,7 @@ def get_opening_type(opening: bpy.types.Object, obj: bpy.types.Object) -> Litera :param blender-object obj: blender object :return string: "OPENING" or "RECESS" """ + assert isinstance(opening.data, bpy.types.Mesh) polygons = opening.data.polygons ray_intersections = 0 @@ -647,7 +674,8 @@ def get_opening_area( """ total_opening_area = 0 ifc = tool.Ifc.get() - ifc_element = ifc.by_id(obj.BIMObjectProperties.ifc_definition_id) + ifc_element = tool.Ifc.get_entity(obj) + assert ifc_element if len(openings := ifc_element.HasOpenings) != 0: for opening in openings: opening_id = opening.RelatedOpeningElement.GlobalId @@ -708,21 +736,25 @@ def get_lateral_area( y_axis = [0, 1, 0] z_axis = [0, 0, 1] - if get_object_main_axis(obj) == "x" or main_axis == "x": - main_axis = x_axis + main_axis_guess = get_object_main_axis(obj) + if main_axis_guess == "x" or main_axis == "x": + main_axis_v = x_axis side_axis = y_axis top_axis = z_axis - elif get_object_main_axis(obj) == "z": - main_axis = z_axis + elif main_axis_guess == "z": + main_axis_v = z_axis side_axis = x_axis top_axis = y_axis - elif get_object_main_axis(obj) == "y": - main_axis = y_axis + elif main_axis_guess == "y": + main_axis_v = y_axis side_axis = z_axis top_axis = x_axis + else: + assert_never(main_axis_guess) area = 0 total_opening_area = 0 if subtract_openings else get_opening_area(obj, angle_z1=angle_z1, angle_z2=angle_z2) + assert isinstance(obj.data, bpy.types.Mesh) polygons = obj.data.polygons for polygon in polygons: @@ -730,7 +762,7 @@ def get_lateral_area( if angle_to_top_axis < angle_z1 or angle_to_top_axis > angle_z2: continue if exclude_end_areas: - angle_to_main_axis = math.degrees(polygon.normal.rotation_difference(Vector(main_axis)).angle) + angle_to_main_axis = math.degrees(polygon.normal.rotation_difference(Vector(main_axis_v)).angle) if angle_to_main_axis < 45 or angle_to_main_axis > 135: continue if exclude_side_areas: @@ -762,6 +794,7 @@ def get_outer_surface_area(obj: bpy.types.Object) -> float: def get_end_area(obj: bpy.types.Object) -> float: element = tool.Ifc.get_entity(obj) + assert element gross_mesh = get_gross_element_mesh(element) gross_obj = bpy.data.objects.new("MyObject", gross_mesh) @@ -799,6 +832,7 @@ def get_gross_top_area(obj: bpy.types.Object, angle: float = 45) -> float: entity = ifc.by_guid(opening_id) open_obj = tool.Ifc.get_object(entity) + assert isinstance(open_obj, bpy.types.Object) opening_area += get_net_top_area(open_obj, angle=angle) else: continue @@ -886,7 +920,7 @@ def get_OBB_object(obj: bpy.types.Object) -> bpy.types.Object: :param blender-object obj: Blender Object :return blender-object: OBB of the Object """ - ifc_id = obj.BIMObjectProperties.ifc_definition_id + ifc_id = tool.Blender.get_ifc_definition_id(obj) bbox = obj.bound_box # matrix transformation to go from obj coordinates to world coordinates: obb = [Vector(v) for v in bbox] @@ -928,9 +962,10 @@ def get_AABB_object(obj: bpy.types.Object) -> bpy.types.Object: :param blender-object obj: Blender Object :return blender-object: AABB of the Object """ - ifc_id = obj.BIMObjectProperties.ifc_definition_id + ifc_id = tool.Blender.get_ifc_definition_id(obj) aabb_mesh = bpy.data.meshes.new(f"OBB_{ifc_id}") + assert isinstance(obj.data, bpy.types.Mesh) x = [v.co.x for v in obj.data.vertices] y = [v.co.y for v in obj.data.vertices] z = [v.co.z for v in obj.data.vertices] @@ -993,9 +1028,10 @@ def get_bisected_obj( :param tuple(x,y,z) plane_no_neg: Tuple describing the normal vector of the lower bisection plane. Example: (0,0,-1) :return _type_: _description_ """ - ifc_id = obj.BIMObjectProperties.ifc_definition_id + ifc_id = tool.Blender.get_ifc_definition_id(obj) bis_obj = obj.copy() + assert isinstance(obj.data, bpy.types.Mesh) bis_obj.data = obj.data.copy() bis_obj.name = f"Bisected_{ifc_id}" @@ -1053,6 +1089,7 @@ def get_touching_objects(obj: bpy.types.Object, class_filter: list[str]) -> list obj.rotation_euler[1] += math.radians(0.001) bpy.context.evaluated_depsgraph_get().update() + assert isinstance(obj.data, bpy.types.Mesh) obj_mesh = bmesh.new() obj_mesh.from_mesh(obj.data) obj_mesh.transform(obj.matrix_world) @@ -1065,11 +1102,14 @@ def get_touching_objects(obj: bpy.types.Object, class_filter: list[str]) -> list for f in class_filter: filtered_objects += ifc.by_type(f) + blender_o = None for o in filtered_objects: blender_o = tool.Ifc.get_object(o) if blender_o == obj: continue o_mesh = bmesh.new() + assert isinstance(blender_o, bpy.types.Object) + assert isinstance(blender_o.data, bpy.types.Mesh) try: o_mesh.from_mesh(blender_o.data) except: @@ -1082,6 +1122,7 @@ def get_touching_objects(obj: bpy.types.Object, class_filter: list[str]) -> list touching_objects.append(blender_o) # return the objects to their original states + assert isinstance(blender_o, bpy.types.Object) blender_o.rotation_euler[0] -= math.radians(0.001) blender_o.rotation_euler[1] -= math.radians(0.001) bpy.context.evaluated_depsgraph_get().update() @@ -1099,6 +1140,8 @@ def get_contact_area(object1: bpy.types.Object, object2: bpy.types.Object) -> fl # list of tuples, each tuple containing the index of the polygon in object1 and object2 that are touching total_area = 0 + assert isinstance(object1.data, bpy.types.Mesh) + assert isinstance(object2.data, bpy.types.Mesh) for poly1 in object1.data.polygons: for poly2 in object2.data.polygons: total_area += get_intersection_between_polygons(object1, poly1, object2, poly2) @@ -1164,6 +1207,7 @@ def create_shapely_polygon(obj: bpy.types.Object, polygon: bpy.types.MeshPolygon """ polygon_tuples = [] odata = obj.data + assert isinstance(odata, bpy.types.Mesh) for loop_index in polygon.loop_indices: loop = odata.loops[loop_index] coords = obj.matrix_world @ odata.vertices[loop.vertex_index].co @@ -1200,21 +1244,18 @@ def get_bmesh_from_mesh(mesh: bpy.types.Mesh) -> bmesh.types.BMesh: def get_object_main_axis(o: bpy.types.Object) -> AxisType: """_summary_: Returns the main object axis. Useful for profile-defined objects. + Main axis is the axis with the largest dimension. + :param blender-object o: Blender Object :return str: main axis x or y or z """ - x = get_x(o) - y = get_y(o) - z = get_z(o) - if x >= y and x > z: - return "x" - if y > z and y > x: - return "y" - if z > x and z > y: - return "z" - else: - return "x" + axes: list[tuple[AxisType, float]] = [ + ("x", get_x(o)), + ("y", get_y(o)), + ("z", get_z(o)), + ] + return max(axes, key=lambda x: x[1])[0] def is_opening_horizontal(o: bpy.types.Object) -> bool: diff --git a/src/bonsai/bonsai/bim/module/qto/helper.py b/src/bonsai/bonsai/bim/module/qto/helper.py index 7265aa4400..a796d0a164 100644 --- a/src/bonsai/bonsai/bim/module/qto/helper.py +++ b/src/bonsai/bonsai/bim/module/qto/helper.py @@ -18,6 +18,7 @@ import bpy import bmesh +import bonsai.tool as tool from typing import Callable @@ -50,6 +51,7 @@ def calculate_mesh_quantity( result = 0 edit_mode = context.active_object.mode == "EDIT" for obj in objs: + assert isinstance(obj.data, bpy.types.Mesh) if edit_mode: bm = bmesh.from_edit_mesh(obj.data) result += operation(bm) @@ -86,7 +88,7 @@ def calculate_formwork_area(objs: list[bpy.types.Object], context: bpy.types.Con bpy.ops.object.modifier_apply(modifier="Boolean") copied_obj.name = "Formwork" - copied_obj.BIMObjectProperties.ifc_definition_id = 0 + tool.Blender.get_object_bim_props(copied_obj).ifc_definition_id = 0 modifier = copied_obj.modifiers.new("Formwork", "REMESH") assert isinstance(modifier, bpy.types.RemeshModifier) modifier.mode = "SHARP" diff --git a/src/bonsai/bonsai/bim/module/qto/operator.py b/src/bonsai/bonsai/bim/module/qto/operator.py index fe1574d1fa..eb2ccda282 100644 --- a/src/bonsai/bonsai/bim/module/qto/operator.py +++ b/src/bonsai/bonsai/bim/module/qto/operator.py @@ -21,13 +21,13 @@ import ifcopenshell import ifcopenshell.api import bonsai.tool as tool import bonsai.core.qto as core -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.qto import helper class CalculateCircleRadius(bpy.types.Operator): bl_idname = "bim.calculate_circle_radius" bl_label = "Calculate Circle Radius" + bl_description = "Calculate circle radius for the selected object's selected vertices." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -42,6 +42,7 @@ class CalculateCircleRadius(bpy.types.Operator): class CalculateEdgeLengths(bpy.types.Operator): bl_idname = "bim.calculate_edge_lengths" bl_label = "Calculate Edge Lengths" + bl_description = "Calculate edge lengths for the selected mesh objects." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -50,13 +51,14 @@ class CalculateEdgeLengths(bpy.types.Operator): def execute(self, context): result = helper.calculate_edges_lengths([o for o in context.selected_objects if o.type == "MESH"], context) - context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + tool.Qto.set_qto_result(result) return {"FINISHED"} class CalculateFaceAreas(bpy.types.Operator): bl_idname = "bim.calculate_face_areas" bl_label = "Calculate Face Areas" + bl_description = "Calculate face areas for the selected mesh objects." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -65,13 +67,14 @@ class CalculateFaceAreas(bpy.types.Operator): def execute(self, context): result = helper.calculate_faces_areas([o for o in context.selected_objects if o.type == "MESH"], context) - context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + tool.Qto.set_qto_result(result) return {"FINISHED"} class CalculateObjectVolumes(bpy.types.Operator): bl_idname = "bim.calculate_object_volumes" bl_label = "Calculate Object Volumes" + bl_description = "Calculate volumes for the selected mesh objects." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -80,13 +83,14 @@ class CalculateObjectVolumes(bpy.types.Operator): def execute(self, context): result = helper.calculate_volumes([o for o in context.selected_objects if o.type == "MESH"], context) - context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + tool.Qto.set_qto_result(result) return {"FINISHED"} class CalculateFormworkArea(bpy.types.Operator): bl_idname = "bim.calculate_formwork_area" bl_label = "Calculate Formwork Area" + bl_description = "Calculate formwork area for the selected mesh objects." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -95,13 +99,14 @@ class CalculateFormworkArea(bpy.types.Operator): def execute(self, context): result = helper.calculate_formwork_area([o for o in context.selected_objects if o.type == "MESH"], context) - context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + tool.Qto.set_qto_result(result) return {"FINISHED"} class CalculateSideFormworkArea(bpy.types.Operator): bl_idname = "bim.calculate_side_formwork_area" bl_label = "Calculate Side Formwork Area" + bl_description = "Calculate side formwork area for the selected mesh objects." bl_options = {"REGISTER", "UNDO"} @classmethod @@ -110,7 +115,7 @@ class CalculateSideFormworkArea(bpy.types.Operator): def execute(self, context): result = helper.calculate_side_formwork_area([o for o in context.selected_objects if o.type == "MESH"], context) - context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + tool.Qto.set_qto_result(result) return {"FINISHED"} @@ -127,9 +132,9 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): import ifc5d.qto - props = context.scene.BIMQtoProperties - elements = set() - for obj in context.selected_objects: + props = tool.Qto.get_qto_props() + elements: set[ifcopenshell.entity_instance] = set() + for obj in tool.Blender.get_selected_objects(include_active=False): element = tool.Ifc.get_entity(obj) if element: elements.add(element) @@ -145,6 +150,10 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator): ifc_file = tool.Ifc.get() results = ifc5d.qto.quantify(ifc_file, elements, rules) ifc5d.qto.edit_qtos(ifc_file, results) + + not_quantified_elements = elements - set(results.keys()) + not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements) + self.report({"INFO"}, f"Quantity was calculated for {len(elements)} elements.{not_quantified_message}") return {"FINISHED"} @@ -164,7 +173,7 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): import ifc5d.qto - props = context.scene.BIMQtoProperties + props = tool.Qto.get_qto_props() elements: set[ifcopenshell.entity_instance] if context.selected_objects: @@ -188,17 +197,9 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator): not_quantified_elements = run_quantification(props.qto_rule, elements) if props.fallback and not_quantified_elements: - alternative_rules = next(rule for rule in ifc5d.qto.rules if rule != props.qto_rule) + alternative_rules = next(rule for rule in tool.Qto.get_qto_rules() if rule != props.qto_rule) not_quantified_elements = run_quantification(alternative_rules, not_quantified_elements) - not_quantified_message = "" - if not_quantified_elements: - print("Elements that were not quantified:") - for element in not_quantified_elements: - print(f"- {element}") - not_quantified_message = ( - f" {len(not_quantified_elements)} of them were not quantified, see system console for the details." - ) - + not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements) self.report({"INFO"}, f"Quantities are calculated for {len(elements)} elements.{not_quantified_message}") return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/qto/prop.py b/src/bonsai/bonsai/bim/module/qto/prop.py index 3d9664ec73..57e04d4cc1 100644 --- a/src/bonsai/bonsai/bim/module/qto/prop.py +++ b/src/bonsai/bonsai/bim/module/qto/prop.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool import ifc5d.qto from bonsai.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup @@ -30,32 +31,46 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Union -def get_qto_rule(self, context): - results = [] - for rule_id, rule in ifc5d.qto.rules.items(): +CALCULATOR_FUNCTION_ENUM_ITEMS: list[Union[tuple[str, str, str], None]] = [] + + +def get_qto_rule(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: + results: list[tuple[str, str, str]] = [] + for rule_id, rule in tool.Qto.get_qto_rules().items(): results.append((rule_id, rule["name"], rule["description"])) return results -def get_calculator(self, context): - results = [] +def get_calculator(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: + results: list[tuple[str, str, str]] = [] for name, calculator in ifc5d.qto.calculators.items(): - results.append((name, name, calculator.__doc__)) + results.append((name, name, calculator.__doc__ or "")) + # Make IfcOpenShell appear first. + results.sort(key=lambda x: x[0] == "IfcOpenShell", reverse=True) return results -def get_calculator_function(self, context): +def get_calculator_function( + self: "BIMQtoProperties", context: bpy.types.Context +) -> list[Union[tuple[str, str, str], None]]: global CALCULATOR_FUNCTION_ENUM_ITEMS calculator = ifc5d.qto.calculators[self.calculator] + + if calculator is ifc5d.qto.Blender: + calculator.populate_descriptions() + CALCULATOR_FUNCTION_ENUM_ITEMS = [] previous_measure = None for function_id, function in calculator.functions.items(): measure = function.measure.split("Measure")[0][3:] if previous_measure is not None and measure != previous_measure: CALCULATOR_FUNCTION_ENUM_ITEMS.append(None) - CALCULATOR_FUNCTION_ENUM_ITEMS.append((function_id, f"{measure}: {function.name}", function.description)) + description = function.description + description += f"\n\nInternal function id: '{function_id}'." + CALCULATOR_FUNCTION_ENUM_ITEMS.append((function_id, f"{measure}: {function.name}", description)) previous_measure = measure return CALCULATOR_FUNCTION_ENUM_ITEMS @@ -63,7 +78,14 @@ def get_calculator_function(self, context): class BIMQtoProperties(PropertyGroup): qto_rule: EnumProperty(items=get_qto_rule, name="Qto Rule") calculator: EnumProperty(items=get_calculator, name="Calculator") - calculator_function: EnumProperty(items=get_calculator_function, name="Calculator Function") + calculator_function: EnumProperty( + items=get_calculator_function, + name="Calculator Function", + description=( + "Gross functions calculate the measure for the original element's geometry, without openings.\n" + "Net functions include the openings substractions.\n\nCurrently selected function" + ), + ) qto_result: StringProperty(default="", name="Qto Result") qto_name: StringProperty(name="Qto Name", default="My_Qto") prop_name: StringProperty(name="Prop Name", default="MyDimension") @@ -71,7 +93,16 @@ class BIMQtoProperties(PropertyGroup): name="Fallback To Other Calculators", description=( "If currently selected calculator does not support quantification " - "of some class/property, to try other available calculators." + "of some class/quantity set, to try other available calculators." ), default=False, ) + + if TYPE_CHECKING: + qto_rule: str + calculator: str + calculator_function: str + qto_result: str + qto_name: str + prop_name: str + fallback: bool diff --git a/src/bonsai/bonsai/bim/module/qto/ui.py b/src/bonsai/bonsai/bim/module/qto/ui.py index 49840ba4be..9029a63066 100644 --- a/src/bonsai/bonsai/bim/module/qto/ui.py +++ b/src/bonsai/bonsai/bim/module/qto/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool from bonsai.bim.module.qto.data import QtoData @@ -32,7 +33,7 @@ class BIM_PT_qto(bpy.types.Panel): def draw(self, context): layout = self.layout - props = context.scene.BIMQtoProperties + props = tool.Qto.get_qto_props() row = layout.row() if context.selected_objects: @@ -57,7 +58,7 @@ class BIM_PT_qto_manual(bpy.types.Panel): def draw(self, context): layout = self.layout - props = context.scene.BIMQtoProperties + props = tool.Qto.get_qto_props() row = layout.row() row.prop(props, "calculator") @@ -83,7 +84,7 @@ class BIM_PT_qto_simple(bpy.types.Panel): def draw(self, context): layout = self.layout - props = context.scene.BIMQtoProperties + props = tool.Qto.get_qto_props() row = layout.row() row.prop(props, "qto_result", text="Results") @@ -98,6 +99,8 @@ class BIM_PT_qto_simple(bpy.types.Panel): row.operator("bim.calculate_object_volumes") row = layout.row() row.operator("bim.calculate_formwork_area") + row = layout.row() + row.operator("bim.calculate_side_formwork_area") class BIM_PT_qto_cost(bpy.types.Panel): diff --git a/src/bonsai/bonsai/bim/module/resource/prop.py b/src/bonsai/bonsai/bim/module/resource/prop.py index 8613f44d1a..b5f1360c4e 100644 --- a/src/bonsai/bonsai/bim/module/resource/prop.py +++ b/src/bonsai/bonsai/bim/module/resource/prop.py @@ -19,7 +19,6 @@ import bpy import ifcopenshell.api import ifcopenshell.util.resource -from bonsai.bim.ifc import IfcStore import bonsai.tool as tool import bonsai.bim.module.pset.data import bonsai.bim.module.resource.data diff --git a/src/bonsai/bonsai/bim/module/resource/ui.py b/src/bonsai/bonsai/bim/module/resource/ui.py index 5ffdac8881..898749ff67 100644 --- a/src/bonsai/bonsai/bim/module/resource/ui.py +++ b/src/bonsai/bonsai/bim/module/resource/ui.py @@ -17,9 +17,9 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.resource.data import ResourceData from typing import Any @@ -35,7 +35,7 @@ class BIM_PT_resources(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/root/__init__.py b/src/bonsai/bonsai/bim/module/root/__init__.py index daba174815..3e6f280eb2 100644 --- a/src/bonsai/bonsai/bim/module/root/__init__.py +++ b/src/bonsai/bonsai/bim/module/root/__init__.py @@ -24,7 +24,6 @@ classes = ( operator.AssignClass, operator.DisableReassignClass, operator.EnableReassignClass, - operator.LaunchAddElement, operator.ReassignClass, operator.UnlinkObject, prop.BIMRootProperties, diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index 10c50839ec..4f6b234a19 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -54,32 +54,14 @@ class IfcClassData: @classmethod def ifc_products(cls): - products = [ - "IfcElementType", - "IfcElement", - "IfcFeatureElement", - "IfcSpatialElement", - "IfcSpatialElementType", - "IfcStructuralItem", - "IfcAnnotation", - "IfcRelSpaceBoundary", - ] + products = tool.Root.get_ifc_products() version = tool.Ifc.get_schema() - if version == "IFC2X3": - products = [ - "IfcElementType", - "IfcElement", - "IfcFeatureElement", - "IfcSpatialStructureElement", - "IfcStructuralItem", - "IfcAnnotation", - "IfcRelSpaceBoundary", - ] return [(e, e, (get_entity_doc(version, e) or {}).get("description", "")) for e in products] @classmethod def ifc_classes(cls): - ifc_product = bpy.context.scene.BIMRootProperties.ifc_product + rprops = tool.Root.get_root_props() + ifc_product = rprops.ifc_product declaration = tool.Ifc.schema().declaration_by_name(ifc_product) declarations = ifcopenshell.util.schema.get_subtypes(declaration) names = [d.name() for d in declarations] @@ -99,7 +81,8 @@ class IfcClassData: @classmethod def ifc_predefined_types(cls): types_enum = [] - ifc_class = bpy.context.scene.BIMRootProperties.ifc_class + rprops = tool.Root.get_root_props() + ifc_class = rprops.ifc_class declaration = tool.Ifc.schema().declaration_by_name(ifc_class) version = tool.Ifc.get_schema() for attribute in declaration.attributes(): @@ -114,7 +97,7 @@ class IfcClassData: return types_enum @classmethod - def ifc_classes_suggestions(cls): + def ifc_classes_suggestions(cls) -> dict[str, list[dict[str, Union[str, None]]]]: # suggestions : dict[class_name: list[dict[predefined_type, name(optional)]]] suggestions = defaultdict(list) version = tool.Ifc.get_schema() @@ -133,7 +116,8 @@ class IfcClassData: @classmethod def representation_template(cls): - ifc_class = bpy.context.scene.BIMRootProperties.ifc_class + rprops = tool.Root.get_root_props() + ifc_class = rprops.ifc_class templates = [ ("EMPTY", "No Geometry", "Start with an empty object"), None, @@ -186,7 +170,7 @@ class IfcClassData: templates.extend([None, ("STAIR", "Stair", "Parametric stair")]) elif ifc_class in ("IfcRailingType", "IfcRailing"): templates.extend([None, ("RAILING", "Railing", "Parametric railing")]) - elif ifc_class in ("IfcRoofType", "IfcRoof"): + elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"): templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")]) elif ifc_class and "Segment" in ifc_class: templates.extend( diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 0ba93cc800..f4ac9dafc2 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -21,6 +21,7 @@ import bmesh import ifcopenshell import ifcopenshell.api import ifcopenshell.api.geometry +import ifcopenshell.api.root import ifcopenshell.util.schema import ifcopenshell.util.element import ifcopenshell.util.shape_builder @@ -42,32 +43,34 @@ class EnableReassignClass(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): + rprops = tool.Root.get_root_props() obj = context.active_object self.file = tool.Ifc.get() - ifc_class = obj.name.split("/")[0] - context.active_object.BIMObjectProperties.is_reassigning_class = True - ifc_products = [ - "IfcElement", - "IfcElementType", - "IfcSpatialElement", - "IfcGroup", - "IfcStructural", - "IfcPositioningElement", - "IfcContext", - "IfcAnnotation", - "IfcRelSpaceBoundary", - ] + element = tool.Ifc.get_entity(obj) + assert element + ifc_class = element.is_a() + props = tool.Blender.get_object_bim_props(obj) + props.is_reassigning_class = True + ifc_products = tool.Root.get_ifc_products() + schema = tool.Ifc.schema() + declaration = schema.declaration_by_name(ifc_class) for ifc_product in ifc_products: - if ifcopenshell.util.schema.is_a(IfcStore.get_schema().declaration_by_name(ifc_class), ifc_product): - context.scene.BIMRootProperties.ifc_product = ifc_product - element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) - context.scene.BIMRootProperties.ifc_class = element.is_a() - context.scene.BIMRootProperties.relating_class_object = None + if ifcopenshell.util.schema.is_a(declaration, ifc_product): + rprops.ifc_product = ifc_product + break + else: + self.report({"ERROR"}, f"Couldn't find matching IFC product for the selected object: '{element}'.") + props.is_reassigning_class = False + return {"CANCELLED"} + + element = self.file.by_id(tool.Blender.get_ifc_definition_id(obj)) + rprops.ifc_class = element.is_a() + rprops.relating_class_object = None if hasattr(element, "PredefinedType"): if element.PredefinedType: - context.scene.BIMRootProperties.ifc_predefined_type = element.PredefinedType + rprops.ifc_predefined_type = element.PredefinedType userdefined_type = ifcopenshell.util.element.get_predefined_type(element) - context.scene.BIMRootProperties.ifc_userdefined_type = userdefined_type or "" + rprops.ifc_userdefined_type = userdefined_type or "" return {"FINISHED"} @@ -77,7 +80,8 @@ class DisableReassignClass(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.active_object.BIMObjectProperties.is_reassigning_class = False + props = tool.Blender.get_object_bim_props(context.active_object) + props.is_reassigning_class = False return {"FINISHED"} @@ -94,9 +98,9 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator): else: objects = set(context.selected_objects + [context.active_object]) self.file = tool.Ifc.get() - root_props = context.scene.BIMRootProperties - ifc_product: str = root_props.ifc_product - ifc_class: str = root_props.ifc_class + root_props = tool.Root.get_root_props() + ifc_product = root_props.ifc_product + ifc_class = root_props.ifc_class type_ifc_class = next(iter(ifcopenshell.util.type.get_applicable_types(ifc_class, self.file.schema)), None) predefined_type = root_props.ifc_predefined_type @@ -126,7 +130,8 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator): ) return {"CANCELLED"} - obj.BIMObjectProperties.is_reassigning_class = False + props = tool.Blender.get_object_bim_props(obj) + props.is_reassigning_class = False if element.is_a("IfcTypeObject"): elements_to_reassign[element] = ifc_class elements_to_update.update(ifcopenshell.util.element.get_types(element)) @@ -145,14 +150,16 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator): elements_to_update = elements_to_update | set(elements_to_reassign) objects_to_update = set(o for e in elements_to_update if (o := tool.Ifc.get_object(e))) - reassigned_elements = set() + reassigned_elements: set[ifcopenshell.entity_instance] = set() for element, ifc_class_ in elements_to_reassign.items(): - element = ifcopenshell.api.run( - "root.reassign_class", + element = ifcopenshell.api.root.reassign_class( self.file, product=element, ifc_class=ifc_class_, predefined_type=predefined_type, + # Provide occurrence class in all cases as it won't really matter + # for non-IfcTypeProducts. + occurrence_class=ifc_class, ) reassigned_elements.add(element) @@ -176,7 +183,7 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): ifc_representation_class: bpy.props.StringProperty() def _execute(self, context): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() objects: list[bpy.types.Object] = [] if self.obj: objects = [bpy.data.objects[self.obj]] @@ -355,10 +362,10 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): ifc_class: bpy.props.StringProperty(options={"SKIP_SAVE"}) def invoke(self, context, event): - return IfcStore.execute_ifc_operator(self, context, is_invoke=True) + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") def _invoke(self, context, event): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() # For convenience, preselect OBJs if applicable if props.ifc_product == "IfcFeatureElement": if (obj := tool.Blender.get_active_object(is_selected=True)) and obj.type == "MESH": @@ -376,7 +383,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): return context.window_manager.invoke_props_dialog(self) def _execute(self, context): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() predefined_type = ( props.ifc_userdefined_type if props.ifc_predefined_type == "USERDEFINED" else props.ifc_predefined_type ) @@ -500,7 +507,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): else: material = ifcopenshell.api.run("material.add_material", tool.Ifc.get(), name="Unknown") if representation_template == "PROFILESET": - profile_id = tool.Blender.get_enum_safe(context.scene.BIMRootProperties, "profile") + profile_id = tool.Blender.get_enum_safe(props, "profile") if profile_id in ("-", None): profile = next((p for p in ifc_file.by_type("IfcProfileDef") if p.ProfileName), None) if profile is None: @@ -589,7 +596,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.set_active_object(obj) def draw(self, context): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() self.layout.use_property_split = True self.layout.use_property_decorate = False row = self.layout.row() @@ -600,7 +607,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): if not self.ifc_product: prop_with_search(self.layout, props, "ifc_product", text="Definition", should_click_ok=True) prop_with_search(self.layout, props, "ifc_class", should_click_ok=True) - ifc_predefined_types = root_prop.get_ifc_predefined_types(context.scene.BIMRootProperties, context) + ifc_predefined_types = root_prop.get_ifc_predefined_types(props, context) if ifc_predefined_types: prop_with_search(self.layout, props, "ifc_predefined_type", should_click_ok=True) if props.ifc_predefined_type == "USERDEFINED": @@ -618,15 +625,3 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): row.prop(props, "profile", text="Profile") if props.representation_template != "EMPTY": prop_with_search(self.layout, props, "contexts", should_click_ok=True) - - -class LaunchAddElement(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.launch_add_element" - bl_label = "Add Element" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Add an IFC physical product, construction type, and more" - - def execute(self, context): - # This stub operator is needed because operators from menu skip the invoke call - bpy.ops.bim.add_element("INVOKE_DEFAULT") - return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/root/prop.py b/src/bonsai/bonsai/bim/module/root/prop.py index 5243d95c03..e2d6cea330 100644 --- a/src/bonsai/bonsai/bim/module/root/prop.py +++ b/src/bonsai/bonsai/bim/module/root/prop.py @@ -34,28 +34,27 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Union -def get_ifc_predefined_types(self, context): +def get_ifc_predefined_types(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["ifc_predefined_types"] -def get_representation_template(self, context): +def get_representation_template(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["representation_template"] -def refresh_classes(self, context): - old_class = context.scene.BIMRootProperties.ifc_class - old_predefined_type = ( - context.scene.BIMRootProperties.ifc_predefined_type if get_ifc_predefined_types(self, context) else "" - ) +def refresh_classes(self: "BIMRootProperties", context: bpy.types.Context) -> None: + old_class = self.ifc_class + old_predefined_type = self.ifc_predefined_type if get_ifc_predefined_types(self, context) else "" enum = get_ifc_classes(self, context) - context.scene.BIMRootProperties.ifc_class = enum[0][0] + self.ifc_class = enum[0][0] IfcClassData.load() if self.ifc_product == "IfcFeatureElement": @@ -81,48 +80,44 @@ def refresh_classes(self, context): self.ifc_predefined_type = old_predefined_type -def refresh_predefined_types(self, context): +def refresh_predefined_types(self: "BIMRootProperties", context: bpy.types.Context) -> None: IfcClassData.load() enum = get_ifc_predefined_types(self, context) if enum: - context.scene.BIMRootProperties.ifc_predefined_type = enum[0][0] + self.ifc_predefined_type = enum[0][0] -def update_class_enum(self, context): - self.ifc_class = self.ifc_class_filter_enum - - -def get_ifc_products(self, context): +def get_ifc_products(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["ifc_products"] -def get_ifc_classes(self, context): +def get_ifc_classes(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["ifc_classes"] -def get_ifc_classes_suggestions(): +def get_ifc_classes_suggestions() -> dict[str, list[dict[str, Union[str, None]]]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["ifc_classes_suggestions"] -def get_contexts(self, context): +def get_contexts(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["contexts"] -def get_profile(self, context): +def get_profile(self: "BIMRootProperties", context: bpy.types.Context) -> list[tuple[str, str]]: if not IfcClassData.is_loaded: IfcClassData.load() return IfcClassData.data["profile"] -def update_relating_class_from_object(self, context): +def update_relating_class_from_object(self: "BIMRootProperties", context: bpy.types.Context) -> None: if self.relating_class_object is None: return element = tool.Ifc.get_entity(self.relating_class_object) @@ -139,7 +134,7 @@ def update_relating_class_from_object(self, context): bpy.ops.bim.reassign_class() -def is_object_class_applicable(self, obj): +def is_object_class_applicable(self: "BIMRootProperties", obj: bpy.types.Object) -> bool: element = tool.Ifc.get_entity(obj) if not element: return False @@ -149,11 +144,11 @@ def is_object_class_applicable(self, obj): return element.is_a("IfcTypeObject") == active_element.is_a("IfcTypeObject") -def poll_representation_obj(self, obj): +def poll_representation_obj(self: "BIMRootProperties", obj: bpy.types.Object) -> bool: return obj.type == "MESH" and obj.data.polygons -def poll_featured_obj(self, obj): +def poll_featured_obj(self: "BIMRootProperties", obj: bpy.types.Object) -> bool: return tool.Ifc.get_entity(obj) @@ -192,3 +187,16 @@ class BIMRootProperties(PropertyGroup): getter_enum_suggestions = { "ifc_class": get_ifc_classes_suggestions, } + + if TYPE_CHECKING: + contexts: str + description: str + ifc_product: str + ifc_class: str + ifc_predefined_type: str + ifc_userdefined_type: str + featured_obj: Union[bpy.types.Object, None] + representation_template: str + representation_obj: Union[bpy.types.Object, None] + profile: str + relating_class_object: Union[bpy.types.Object, None] diff --git a/src/bonsai/bonsai/bim/module/root/ui.py b/src/bonsai/bonsai/bim/module/root/ui.py index 809230656f..5e9fa73eb0 100644 --- a/src/bonsai/bonsai/bim/module/root/ui.py +++ b/src/bonsai/bonsai/bim/module/root/ui.py @@ -17,9 +17,9 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool import bonsai.bim.module.root.prop as root_prop from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import prop_with_search from bonsai.bim.module.root.data import IfcClassData from bonsai.bim.module.model.data import AuthoringData @@ -38,12 +38,15 @@ class BIM_PT_class(Panel): def poll(cls, context): if not context.active_object: return False - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not IfcClassData.is_loaded: IfcClassData.load() - props = context.active_object.BIMObjectProperties + obj = context.active_object + assert obj + props = tool.Blender.get_object_bim_props(obj) + rprops = tool.Root.get_root_props() if props.ifc_definition_id: if not IfcClassData.data["has_entity"]: row = self.layout.row(align=True) @@ -58,10 +61,10 @@ class BIM_PT_class(Panel): row.operator("bim.disable_reassign_class", icon="CANCEL", text="") self.draw_class_dropdowns( context, - root_prop.get_ifc_predefined_types(context.scene.BIMRootProperties, context), + root_prop.get_ifc_predefined_types(rprops, context), is_reassigning_class=True, ) - self.layout.prop(context.scene.BIMRootProperties, "relating_class_object", icon="COPYDOWN") + self.layout.prop(rprops, "relating_class_object", icon="COPYDOWN") else: row = self.layout.row(align=True) row.label( @@ -78,16 +81,16 @@ class BIM_PT_class(Panel): if AuthoringData.data["is_representation_item_active"]: return - ifc_predefined_types = root_prop.get_ifc_predefined_types(context.scene.BIMRootProperties, context) + ifc_predefined_types = root_prop.get_ifc_predefined_types(rprops, context) self.draw_class_dropdowns(context, ifc_predefined_types) row = self.layout.row(align=True) op = row.operator("bim.assign_class") - op.ifc_class = context.scene.BIMRootProperties.ifc_class - op.predefined_type = context.scene.BIMRootProperties.ifc_predefined_type if ifc_predefined_types else "" - op.userdefined_type = context.scene.BIMRootProperties.ifc_userdefined_type + op.ifc_class = rprops.ifc_class + op.predefined_type = rprops.ifc_predefined_type if ifc_predefined_types else "" + op.userdefined_type = rprops.ifc_userdefined_type def draw_class_dropdowns(self, context, ifc_predefined_types, is_reassigning_class=False): - props = context.scene.BIMRootProperties + props = tool.Root.get_root_props() layout = self.layout prop_with_search(layout, props, "ifc_product") prop_with_search(layout, props, "ifc_class") diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index be4f2e1e9e..e1090b6cf1 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -39,6 +39,7 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Literal, get_args class AddFilterGroup(Operator): @@ -144,13 +145,19 @@ class Search(Operator): bl_idname = "bim.search" bl_label = "Search" - property_group: bpy.props.StringProperty(name="Property Group", default="") + PropertyGroupType = Literal["CsvProperties", "BIMSearchProperties"] + property_group: bpy.props.EnumProperty( + name="Property Group", items=[(i, i, "") for i in get_args(PropertyGroupType)] + ) + + if TYPE_CHECKING: + property_group: PropertyGroupType def execute(self, context): if self.property_group == "CsvProperties": - props = context.scene.CsvProperties + props = tool.Blender.get_csv_props() elif self.property_group == "BIMSearchProperties": - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() else: raise Exception(f"bim.search - unexpected property group name '{self.property_group}'.") @@ -213,11 +220,12 @@ class LoadSearch(Operator, tool.Ifc.Operator): def _execute(self, context): filter_groups = tool.Search.get_filter_groups(self.module) - group = tool.Ifc.get().by_id(int(context.scene.BIMSearchProperties.saved_searches)) + props = tool.Search.get_search_props() + group = tool.Ifc.get().by_id(int(props.saved_searches)) tool.Search.import_filter_query(tool.Search.get_group_query(group), filter_groups) def draw(self, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() row = self.layout.row() row.prop(props, "saved_searches", text="") @@ -239,7 +247,7 @@ class ColourByProperty(Operator): return result def _execute(self, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() query = props.colourscheme_query if props.colourscheme_key == "QUERY" else props.colourscheme_key if not query: @@ -358,11 +366,11 @@ class SelectByProperty(Operator): @classmethod def poll(cls, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() return props.active_colourscheme_index < len(props.colourscheme) def execute(self, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() query = props.colourscheme_query if props.colourscheme_key == "QUERY" else props.colourscheme_key if not query: @@ -420,7 +428,7 @@ class SaveColourscheme(Operator, tool.Ifc.Operator): if not self.name: return - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() query = props.colourscheme_query group = [g for g in tool.Ifc.get().by_type("IfcGroup") if g.Name == self.name] @@ -446,7 +454,7 @@ class LoadColourscheme(Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() group = tool.Ifc.get().by_id(int(props.saved_colourschemes)) description = json.loads(group.Description) props.colourscheme_query = description.get("colourscheme_query") @@ -458,7 +466,7 @@ class LoadColourscheme(Operator, tool.Ifc.Operator): new.colour = data["colour"] def draw(self, context): - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() row = self.layout.row() row.prop(props, "saved_colourschemes", text="") @@ -549,19 +557,20 @@ class ResetObjectColours(Operator): def execute(self, context): for obj in context.visible_objects: obj.color = (1, 1, 1, 1) - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() props.colourscheme.clear() return {"FINISHED"} class ToggleFilterSelection(Operator): "Click to select/deselect current selection" + bl_idname = "bim.toggle_filter_selection" bl_label = "Toggle Filter Selection" action: EnumProperty(items=(("SELECT", "Select", ""), ("DESELECT", "Deselect", ""))) def execute(self, context): - props = bpy.context.scene.BIMSearchProperties + props = tool.Search.get_search_props() self.selecting_actionbool = self.action == "SELECT" if props.filter_type == "CLASSES": for ifc_class in props.filter_classes: @@ -586,7 +595,7 @@ class ActivateIfcClassFilter(Operator): return True def invoke(self, context, event): - props = bpy.context.scene.BIMSearchProperties + props = tool.Search.get_search_props() props.filter_classes.clear() ifc_types = {} for obj in context.selected_objects: @@ -605,18 +614,20 @@ class ActivateIfcClassFilter(Operator): return context.window_manager.invoke_props_dialog(self, width=250) def execute(self, context): - bpy.context.scene.BIMSearchProperties.filter_classes.clear() + props = tool.Search.get_search_props() + props.filter_classes.clear() return {"FINISHED"} def draw(self, context): + props = tool.Search.get_search_props() self.layout.template_list( "BIM_UL_ifc_class_filter", "", - context.scene.BIMSearchProperties, + props, "filter_classes", - context.scene.BIMSearchProperties, + props, "filter_classes_index", - rows=min(len(bpy.context.scene.BIMSearchProperties.filter_classes), 20), + rows=min(len(props.filter_classes), 20), ) row = self.layout.row(align=True) row.operator("bim.toggle_filter_selection", text="Select All").action = "SELECT" @@ -637,7 +648,7 @@ class ActivateContainerFilter(Operator): return True def invoke(self, context, event): - props = bpy.context.scene.BIMSearchProperties + props = tool.Search.get_search_props() props.filter_container.clear() containers = {} @@ -660,18 +671,20 @@ class ActivateContainerFilter(Operator): return context.window_manager.invoke_props_dialog(self, width=250) def execute(self, context): - bpy.context.scene.BIMSearchProperties.filter_container.clear() + props = tool.Search.get_search_props() + props.filter_container.clear() return {"FINISHED"} def draw(self, context): + props = tool.Search.get_search_props() self.layout.template_list( "BIM_UL_ifc_building_storey_filter", "", - context.scene.BIMSearchProperties, + props, "filter_container", - context.scene.BIMSearchProperties, + props, "filter_container_index", - rows=min(len(bpy.context.scene.BIMSearchProperties.filter_container), 20), + rows=min(len(props.filter_container), 20), ) row = self.layout.row(align=True) row.operator("bim.toggle_filter_selection", text="Select All").action = "SELECT" @@ -710,14 +723,14 @@ class SelectSimilar(Operator, tool.Ifc.Operator): return self.execute(context) def _execute(self, context): - props = context.scene.BIMSearchProperties obj = context.active_object element = tool.Ifc.get_entity(obj) key = self.key if key == "PredefinedType": key = "predefined_type" value = ifcopenshell.util.selector.get_element_value(element, key) - tolerance = bpy.context.scene.DocProperties.tolerance + dprops = tool.Drawing.get_document_props() + tolerance = dprops.tolerance # Determine the number of decimal places based on the magnitude of the rounding value if tolerance < 1: diff --git a/src/bonsai/bonsai/bim/module/search/prop.py b/src/bonsai/bonsai/bim/module/search/prop.py index 08cfb46d8f..61cff222d3 100644 --- a/src/bonsai/bonsai/bim/module/search/prop.py +++ b/src/bonsai/bonsai/bim/module/search/prop.py @@ -33,33 +33,34 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING -def get_element_key(self, context): +def get_element_key(self: "BIMSearchProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not SelectSimilarData.is_loaded: SelectSimilarData.load() return SelectSimilarData.data["element_key"] -def get_colourscheme_key(self, context): +def get_colourscheme_key(self: "BIMSearchProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not ColourByPropertyData.is_loaded: ColourByPropertyData.load() return ColourByPropertyData.data["colourscheme_key"] -def get_saved_searches(self, context): +def get_saved_searches(self: "BIMSearchProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not SearchData.is_loaded: SearchData.load() return SearchData.data["saved_searches"] -def get_saved_colourschemes(self, context): +def get_saved_colourschemes(self: "BIMSearchProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not ColourByPropertyData.is_loaded: ColourByPropertyData.load() return ColourByPropertyData.data["saved_colourschemes"] -def update_is_class_selected(self, context): +def update_is_class_selected(self: "BIMFilterClasses", context: bpy.types.Context) -> None: if self.is_selected: for obj in self.unselected_objects: obj.obj.select_set(True) @@ -73,7 +74,7 @@ def update_is_class_selected(self, context): new.obj = obj -def update_is_container_selected(self, context): +def update_is_container_selected(self: "BIMFilterBuildingStoreys", context: bpy.types.Context) -> None: if self.is_selected: for obj in self.unselected_objects: obj.obj.select_set(True) @@ -87,15 +88,15 @@ def update_is_container_selected(self, context): new.obj = obj -def update_show_flat_colours(self, context): +def update_show_flat_colours(self: "BIMSearchProperties", context: bpy.types.Context) -> None: + space = tool.Blender.get_view3d_space() + assert space if self.show_flat_colours: - space = tool.Blender.get_view3d_space() space.shading.light = "FLAT" space.shading.color_type = "OBJECT" space.shading.show_object_outline = True space.shading.show_cavity = True else: - space = tool.Blender.get_view3d_space() space.shading.type = "SOLID" space.shading.light = "STUDIO" space.shading.show_object_outline = True @@ -108,6 +109,11 @@ class BIMFilterClasses(PropertyGroup): total: IntProperty(name="Total") unselected_objects: CollectionProperty(type=ObjProperty, name="Unfiltered Objects") + if TYPE_CHECKING: + is_selected: bool + total: int + unselected_objects: bpy.types.bpy_prop_collection_idprop[ObjProperty] + class BIMFilterBuildingStoreys(PropertyGroup): name: StringProperty(name="Name") @@ -115,12 +121,21 @@ class BIMFilterBuildingStoreys(PropertyGroup): total: IntProperty(name="Total") unselected_objects: CollectionProperty(type=ObjProperty, name="Unfiltered Objects") + if TYPE_CHECKING: + is_selected: bool + total: int + unselected_objects: bpy.types.bpy_prop_collection_idprop[ObjProperty] + class BIMColour(PropertyGroup): name: StringProperty(name="Name") total: IntProperty(name="Total") colour: FloatVectorProperty(name="Colour", subtype="COLOR", default=(1, 0, 0), min=0.0, max=1.0) + if TYPE_CHECKING: + total: int + colour: tuple[float, float, float] + class BIMSearchProperties(PropertyGroup): element_key: EnumProperty(items=get_element_key, name="Element Key") @@ -189,6 +204,29 @@ class BIMSearchProperties(PropertyGroup): filter_container_index: IntProperty(name="Filter Level Index") show_flat_colours: BoolProperty(name="Flat Colours", default=False, update=update_show_flat_colours) + if TYPE_CHECKING: + element_key: str + filter_query: str + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + facet: str + saved_searches: str + saved_colourschemes: str + colourscheme_key: str + colourscheme_query: str + palette: str + min_mode: Literal["AUTO", "MANUAL"] + max_mode: Literal["AUTO", "MANUAL"] + min_value: float + max_value: float + colourscheme: bpy.types.bpy_prop_collection_idprop[BIMColour] + active_colourscheme_index: int + filter_type: str + filter_classes: bpy.types.bpy_prop_collection_idprop[BIMFilterClasses] + filter_classes_index: int + filter_container: bpy.types.bpy_prop_collection_idprop[BIMFilterBuildingStoreys] + filter_container_index: int + show_flat_colours: bool + def get_classes(self, ifc_product): declaration = tool.Ifc.schema().declaration_by_name(ifc_product) diff --git a/src/bonsai/bonsai/bim/module/search/ui.py b/src/bonsai/bonsai/bim/module/search/ui.py index a18a7e2409..a9254415e7 100644 --- a/src/bonsai/bonsai/bim/module/search/ui.py +++ b/src/bonsai/bonsai/bim/module/search/ui.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . import bpy +import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel from bonsai.bim.module.search.data import SearchData, ColourByPropertyData, SelectSimilarData @@ -34,7 +35,7 @@ class BIM_PT_search(Panel): if not SearchData.is_loaded: SearchData.load() - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() bonsai.bim.helper.draw_filter(self.layout, props.filter_groups, SearchData, "search") @@ -73,7 +74,7 @@ class BIM_PT_colour_by_property(Panel): if not ColourByPropertyData.is_loaded: ColourByPropertyData.load() - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() row = self.layout.row(align=True) row.label(text=f"{len(ColourByPropertyData.data['saved_colourschemes'])} Saved Colourschemes") @@ -127,7 +128,7 @@ class BIM_PT_select_similar(Panel): if not SelectSimilarData.is_loaded: SelectSimilarData.load() - props = context.scene.BIMSearchProperties + props = tool.Search.get_search_props() if SelectSimilarData.data["element_key"]: row = self.layout.row(align=True) @@ -141,12 +142,12 @@ class BIM_PT_select_similar(Panel): class BIM_UL_colourscheme(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - props = context.scene.BIMWorkScheduleProperties if not item: return row = layout.row(align=True) - row.label(text=f"{item.name} ({item.total})") - row.prop(item, "colour", text="") + split = row.split(factor=0.85) + split.label(text=f"{item.name} ({item.total})") + split.prop(item, "colour", text="") class BIM_UL_ifc_class_filter(bpy.types.UIList): diff --git a/src/bonsai/bonsai/bim/module/sequence/operator.py b/src/bonsai/bonsai/bim/module/sequence/operator.py index 6730353b00..98e7ae6bce 100644 --- a/src/bonsai/bonsai/bim/module/sequence/operator.py +++ b/src/bonsai/bonsai/bim/module/sequence/operator.py @@ -19,6 +19,7 @@ # pyright: reportUnnecessaryTypeIgnoreComment=error import os + import bpy import json import time @@ -31,8 +32,7 @@ import ifcopenshell.util.sequence import ifcopenshell.util.selector from datetime import datetime from dateutil import parser, relativedelta -from bonsai.bim.ifc import IfcStore -from bpy_extras.io_utils import ImportHelper +from bpy_extras.io_utils import ImportHelper, ExportHelper from typing import get_args, TYPE_CHECKING from typing_extensions import assert_never @@ -700,7 +700,7 @@ class ImportP6(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): from ifc4d.p62ifc import P62Ifc - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() p62ifc = P62Ifc() p62ifc.xml = self.filepath @@ -728,7 +728,7 @@ class ImportP6XER(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): from ifc4d.p6xer2ifc import P6XER2Ifc - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() p6xer2ifc = P6XER2Ifc() p6xer2ifc.xer = self.filepath @@ -756,7 +756,7 @@ class ImportPP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): from ifc4d.pp2ifc import PP2Ifc - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() pp2ifc = PP2Ifc() pp2ifc.pp = self.filepath @@ -784,7 +784,7 @@ class ImportMSP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): from ifc4d.msp2ifc import MSP2Ifc - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() msp2ifc = MSP2Ifc() msp2ifc.xml = self.filepath @@ -794,7 +794,7 @@ class ImportMSP(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): self.report({"INFO"}, "Import finished in {:.2f} seconds".format(time.time() - start)) -class ExportMSP(bpy.types.Operator, ImportHelper): +class ExportMSP(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_msp" bl_label = "Export MSP" bl_options = {"REGISTER", "UNDO"} @@ -814,7 +814,7 @@ class ExportMSP(bpy.types.Operator, ImportHelper): def execute(self, context): from ifc4d.ifc2msp import Ifc2Msp - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() ifc2msp = Ifc2Msp() ifc2msp.work_schedule = self.file.by_type("IfcWorkSchedule")[0] @@ -827,7 +827,7 @@ class ExportMSP(bpy.types.Operator, ImportHelper): return {"FINISHED"} -class ExportP6(bpy.types.Operator, ImportHelper): +class ExportP6(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_p6" bl_label = "Export P6" bl_options = {"REGISTER", "UNDO"} @@ -847,7 +847,7 @@ class ExportP6(bpy.types.Operator, ImportHelper): def execute(self, context): from ifc4d.ifc2p6 import Ifc2P6 - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() start = time.time() ifc2p6 = Ifc2P6() ifc2p6.xml = bpy.path.ensure_ext(self.filepath, ".xml") @@ -1450,15 +1450,17 @@ class LoadProductTasks(bpy.types.Operator): @classmethod def poll(cls, context): - if not tool.Ifc.get() or not (obj := context.active_object) or not (obj.BIMObjectProperties.ifc_definition_id): + if not tool.Ifc.get() or not (obj := context.active_object) or not (tool.Blender.get_ifc_definition_id(obj)): cls.poll_message_set("No IFC object is active.") return False return True def execute(self, context): - result = core.load_product_related_tasks( - tool.Sequence, product=tool.Ifc.get().by_id(context.active_object.BIMObjectProperties.ifc_definition_id) - ) + obj = context.active_object + assert obj + product = tool.Ifc.get_entity(obj) + assert product + result = core.load_product_related_tasks(tool.Sequence, product=product) if isinstance(result, str): self.report({"INFO"}, result) else: diff --git a/src/bonsai/bonsai/bim/module/sequence/prop.py b/src/bonsai/bonsai/bim/module/sequence/prop.py index d2e550138d..a63a4928c0 100644 --- a/src/bonsai/bonsai/bim/module/sequence/prop.py +++ b/src/bonsai/bonsai/bim/module/sequence/prop.py @@ -23,7 +23,6 @@ import ifcopenshell.util.attribute import ifcopenshell.util.date import bonsai.tool as tool import bonsai.core.sequence as core -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.sequence.data import SequenceData, AnimationColorSchemeData, refresh as refresh_sequence_data import bonsai.bim.module.resource.data import bonsai.bim.module.pset.data @@ -272,10 +271,10 @@ def update_sort_reversed(self, context): def update_filter_by_active_schedule(self, context): - if context.active_object: - core.load_product_related_tasks( - tool.Sequence, product=tool.Ifc.get().by_id(context.active_object.BIMObjectProperties.ifc_definition_id) - ) + if obj := context.active_object: + product = tool.Ifc.get_entity(obj) + assert product + core.load_product_related_tasks(tool.Sequence, product=product) def switch_options(self, context): diff --git a/src/bonsai/bonsai/bim/module/sequence/ui.py b/src/bonsai/bonsai/bim/module/sequence/ui.py index 2320bb80b4..21ed962852 100644 --- a/src/bonsai/bonsai/bim/module/sequence/ui.py +++ b/src/bonsai/bonsai/bim/module/sequence/ui.py @@ -22,7 +22,6 @@ import isodate import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes from bonsai.bim.module.sequence.data import ( WorkPlansData, @@ -45,7 +44,7 @@ class BIM_PT_status(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): self.props = context.scene.BIMStatusProperties @@ -77,7 +76,7 @@ class BIM_PT_work_plans(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and file.schema != "IFC2X3" def draw(self, context): @@ -147,7 +146,7 @@ class BIM_PT_work_schedules(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): @@ -592,7 +591,7 @@ class BIM_PT_animation_Color_Scheme(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): @@ -980,7 +979,7 @@ class BIM_PT_work_calendars(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/spatial/__init__.py b/src/bonsai/bonsai/bim/module/spatial/__init__.py index 3194d18f3a..ee40768f08 100644 --- a/src/bonsai/bonsai/bim/module/spatial/__init__.py +++ b/src/bonsai/bonsai/bim/module/spatial/__init__.py @@ -26,7 +26,6 @@ classes = ( operator.DeleteContainer, operator.DereferenceStructure, operator.DisableEditingContainer, - operator.EditContainerAttributes, operator.EnableEditingContainer, operator.ExpandContainer, operator.ImportSpatialDecomposition, diff --git a/src/bonsai/bonsai/bim/module/spatial/data.py b/src/bonsai/bonsai/bim/module/spatial/data.py index eb8e6585b4..b3e863a329 100644 --- a/src/bonsai/bonsai/bim/module/spatial/data.py +++ b/src/bonsai/bonsai/bim/module/spatial/data.py @@ -58,7 +58,7 @@ class SpatialData: @classmethod def default_container(cls) -> str | None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() if props.default_container: try: return tool.Ifc.get().by_id(props.default_container).Name @@ -102,7 +102,7 @@ class SpatialDecompositionData: @classmethod def default_container(cls) -> str | None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() if props.default_container: try: return tool.Ifc.get().by_id(props.default_container).Name @@ -112,7 +112,7 @@ class SpatialDecompositionData: @classmethod def subelement_class(cls) -> list[tuple[str, str, str]]: results = [] - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() if not (container := props.active_container): return results container_class = tool.Ifc.get().by_id(container.ifc_definition_id).is_a() diff --git a/src/bonsai/bonsai/bim/module/spatial/decorator.py b/src/bonsai/bonsai/bim/module/spatial/decorator.py index b977210805..a7c1059fc7 100644 --- a/src/bonsai/bonsai/bim/module/spatial/decorator.py +++ b/src/bonsai/bonsai/bim/module/spatial/decorator.py @@ -66,7 +66,8 @@ class GridDecorator: blf.size(font_id, 12) blf.enable(font_id, blf.SHADOW) - for axis in context.scene.BIMGridProperties.grid_axes: + grid_props = tool.Spatial.get_grid_props() + for axis in grid_props.grid_axes: if not (obj := axis.obj) or obj.hide_get() == True: continue if obj.select_get() and context.mode != "OBJECT": @@ -120,7 +121,8 @@ class GridDecorator: selected_edges = [] unselected_verts = [] unselected_edges = [] - for axis in context.scene.BIMGridProperties.grid_axes: + grid_props = tool.Spatial.get_grid_props() + for axis in grid_props.grid_axes: if (obj := axis.obj) and obj.hide_get() == False: if obj.select_get(): if context.mode != "OBJECT": diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index dc68d95a8a..61fc4863b2 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -96,7 +96,7 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): container = tool.Ifc.get().by_id(self.container) elif ( (obj := tool.Blender.get_active_object()) - and (props := obj.BIMObjectSpatialProperties) + and (props := tool.Spatial.get_object_spatial_props(obj)) and (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)) ): @@ -242,17 +242,6 @@ class ImportSpatialDecomposition(bpy.types.Operator): return {"FINISHED"} -class EditContainerAttributes(bpy.types.Operator): - bl_idname = "bim.edit_container_attributes" - bl_label = "Edit container attributes" - bl_options = {"REGISTER", "UNDO"} - container: bpy.props.IntProperty() - - def execute(self, context): - core.edit_container_attributes(tool.Spatial, entity=tool.Ifc.get().by_id(self.container)) - return {"FINISHED"} - - class ContractContainer(bpy.types.Operator): bl_idname = "bim.contract_container" bl_label = "Contract Container" @@ -345,6 +334,7 @@ class SetDefaultContainer(bpy.types.Operator): def execute(self, context): core.set_default_container(tool.Spatial, container=tool.Ifc.get().by_id(self.container)) + core.set_orientation_slot(tool.Spatial, container=tool.Ifc.get().by_id(self.container)) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index 1017d8f069..910bb2e28c 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -36,15 +36,18 @@ import bonsai.core.geometry import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.unit +from typing import TYPE_CHECKING, Union, Literal -def get_subelement_class(self, context): +def get_subelement_class( + self: "BIMSpatialDecompositionProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not SpatialDecompositionData.is_loaded: SpatialDecompositionData.load() return SpatialDecompositionData.data["subelement_class"] -def update_elevation(self, context): +def update_elevation(self: "BIMContainer", context: bpy.types.Context) -> None: try: elevation = float(self.elevation) if self.elevation != str(elevation): @@ -91,7 +94,7 @@ def update_element_mode(self: "BIMSpatialDecompositionProperties", context: bpy. tool.Spatial.load_contained_elements() -def update_grid_is_locked(self, context): +def update_grid_is_locked(self: "BIMGridProperties", context: bpy.types.Context) -> None: if not tool.Ifc.get(): return if tool.Ifc.get().schema in ("IFC2X3", "IFC4"): @@ -108,7 +111,7 @@ def update_grid_is_locked(self, context): bonsai.bim.handler.refresh_ui_data() -def update_spatial_is_locked(self, context): +def update_spatial_is_locked(self: "BIMSpatialDecompositionProperties", context: bpy.types.Context) -> None: if not tool.Ifc.get(): return if tool.Ifc.get().schema == "IFC2X3": @@ -150,6 +153,10 @@ class BIMObjectSpatialProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") container_obj: PointerProperty(type=bpy.types.Object, name="Container", poll=poll_container_obj) + if TYPE_CHECKING: + is_editing: bool + container_obj: Union[bpy.types.Object, None] + class BIMContainer(PropertyGroup): name: StringProperty(name="Name", update=update_name) @@ -162,6 +169,16 @@ class BIMContainer(PropertyGroup): is_expanded: BoolProperty(name="Is Expanded") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + ifc_class: str + description: str + long_name: str + elevation: str + level_index: int + has_children: bool + is_expanded: bool + ifc_definition_id: int + class Element(PropertyGroup): name: StringProperty(name="Name") @@ -185,6 +202,16 @@ class Element(PropertyGroup): ), ) + if TYPE_CHECKING: + ifc_class: str + identification: str + ifc_definition_id: int + level: int + has_children: bool + total: int + is_expanded: bool + type: Literal["CLASS", "TYPE", "CLASSIFICATION", "OCCURRENCE"] + class BIMSpatialDecompositionProperties(PropertyGroup): is_locked: BoolProperty( @@ -223,6 +250,23 @@ class BIMSpatialDecompositionProperties(PropertyGroup): name="Should Include Children", default=True, update=update_should_include_children ) + if TYPE_CHECKING: + is_locked: bool + is_visible: bool + container_filter: str + containers: bpy.types.bpy_prop_collection_idprop[BIMContainer] + contracted_containers: str + active_container_index: int + element_filter: str + elements: bpy.types.bpy_prop_collection_idprop[Element] + expanded_elements: str + active_element_index: int + total_elements: int + element_mode: Literal["TYPE", "DECOMPOSITION", "CLASSIFICATION"] + subelement_class: str + default_container: int + should_include_children: bool + @property def active_container(self): if self.containers and self.active_container_index < len(self.containers): @@ -248,3 +292,8 @@ class BIMGridProperties(PropertyGroup): update=update_grid_is_visible, ) grid_axes: CollectionProperty(name="Grid Axes", type=ObjProperty) + + if TYPE_CHECKING: + is_locked: bool + is_visible: bool + grid_axes: bpy.types.bpy_prop_collection_idprop[ObjProperty] diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 0569504e25..42b5a3cadc 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -16,10 +16,15 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy from bpy.types import Panel, UIList from bonsai.bim.module.spatial.data import SpatialData, SpatialDecompositionData import bonsai.tool as tool +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.spatial.prop import BIMSpatialDecompositionProperties, BIMContainer, Element class BIM_PT_spatial(Panel): @@ -40,7 +45,9 @@ class BIM_PT_spatial(Panel): if not SpatialData.is_loaded: SpatialData.load() - osprops = context.active_object.BIMObjectSpatialProperties + obj = context.active_object + assert obj + osprops = tool.Spatial.get_object_spatial_props(obj) if osprops.is_editing: if SpatialData.data["default_container"]: @@ -103,7 +110,7 @@ class BIM_PT_spatial_decomposition(Panel): return tool.Ifc.get() def draw_header(self, context): - props = context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row icon = "HIDE_OFF" if props.is_visible else "HIDE_ON" @@ -114,7 +121,7 @@ class BIM_PT_spatial_decomposition(Panel): def draw(self, context): if not SpatialDecompositionData.is_loaded: SpatialDecompositionData.load() - self.props = context.scene.BIMSpatialDecompositionProperties + self.props = tool.Spatial.get_spatial_props() if SpatialDecompositionData.data["default_container"]: row = self.layout.row(align=True) @@ -233,7 +240,7 @@ class BIM_PT_grids(Panel): self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids") def draw_header(self, context): - props = context.scene.BIMGridProperties + props = tool.Spatial.get_grid_props() row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row icon = "HIDE_OFF" if props.is_visible else "HIDE_ON" @@ -243,25 +250,37 @@ class BIM_PT_grids(Panel): class BIM_UL_containers_manager(UIList): - def __init__(self): + icon_by_class = { + "IfcProject": "FILE", + "IfcSite": "WORLD", + "IfcBuilding": "HOME", + "IfcBuildingStorey": "LINENUMBERS_OFF", + "IfcSpace": "ANTIALIASED", + "IfcFacilityPart": "MOD_FLUID", + "IfcBridgePart": "MOD_FLUID", + "IfcFacilityPartCommon": "MOD_FLUID", + "IfcMarinePart": "MOD_FLUID", + "IfcRailwayPart": "MOD_FLUID", + "IfcRoadPart": "MOD_FLUID", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.use_filter_show = True - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMSpatialDecompositionProperties, + item: BIMContainer, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) - icon = { - "IfcProject": "FILE", - "IfcSite": "WORLD", - "IfcBuilding": "HOME", - "IfcBuildingStorey": "LINENUMBERS_OFF", - "IfcSpace": "ANTIALIASED", - "IfcFacilityPart": "MOD_FLUID", - "IfcBridgePart": "MOD_FLUID", - "IfcFacilityPartCommon": "MOD_FLUID", - "IfcMarinePart": "MOD_FLUID", - "IfcRailwayPart": "MOD_FLUID", - "IfcRoadPart": "MOD_FLUID", - }.get(item.ifc_class, "META_PLANE") + icon = self.icon_by_class.get(item.ifc_class, "META_PLANE") split = row.split(factor=0.85) if item.long_name: split2 = split.split(factor=0.7) @@ -275,7 +294,7 @@ class BIM_UL_containers_manager(UIList): row.prop(item, "name", emboss=False, text="", icon=icon) split.prop(item, "elevation", emboss=False, text="") - def draw_hierarchy(self, row, item): + def draw_hierarchy(self, row: bpy.types.UILayout, item: BIMContainer) -> None: if item.level_index: for i in range(0, item.level_index - 1): row.label(text="", icon="BLANK1") @@ -293,11 +312,12 @@ class BIM_UL_containers_manager(UIList): def draw_filter(self, context, layout): row = layout.row() - row.prop(context.scene.BIMSpatialDecompositionProperties, "container_filter", text="", icon="VIEWZOOM") + props = tool.Spatial.get_spatial_props() + row.prop(props, "container_filter", text="", icon="VIEWZOOM") - def filter_items(self, context, data, propname): + def filter_items(self, context: bpy.types.Context, data: BIMSpatialDecompositionProperties, propname: str): items = getattr(data, propname) - filter_name = context.scene.BIMSpatialDecompositionProperties.container_filter.lower() + filter_name = data.container_filter.lower() filter_flags = [self.bitflag_filter_item] * len(items) for idx, item in enumerate(items): @@ -313,20 +333,32 @@ class BIM_UL_containers_manager(UIList): return filter_flags, [] items = getattr(data, propname) - filter_name = context.scene.BIMSpatialDecompositionProperties.container_filter + filter_name = data.container_filter filtered = bpy.types.UI_UL_list.filter_items_by_name(filter_name, self.bitflag_filter_item, items, "name") return filtered, [] class BIM_UL_elements(UIList): - def __init__(self): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.use_filter_show = True def draw_toggle(self, row: bpy.types.UILayout, is_expanded: bool, index: int): icon_id = "DISCLOSURE_TRI_DOWN" if is_expanded else "DISCLOSURE_TRI_RIGHT" row.operator("bim.toggle_container_element", text="", emboss=False, icon=icon_id).element_index = index - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, fit_flag): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMSpatialDecompositionProperties, + item: Element, + icon, + active_data, + active_propname, + index, + fit_flag, + ): if item: row = layout.row(align=True) for _ in range(item.level): @@ -341,11 +373,12 @@ class BIM_UL_elements(UIList): def draw_filter(self, context, layout): row = layout.row() - row.prop(context.scene.BIMSpatialDecompositionProperties, "element_filter", text="", icon="VIEWZOOM") + props = tool.Spatial.get_spatial_props() + row.prop(props, "element_filter", text="", icon="VIEWZOOM") - def filter_items(self, context, data, propname): + def filter_items(self, context: bpy.types.Context, data: BIMSpatialDecompositionProperties, propname: str): items = getattr(data, propname) - filter_name = context.scene.BIMSpatialDecompositionProperties.element_filter + filter_name = data.element_filter filtered = bpy.types.UI_UL_list.filter_items_by_name(filter_name, self.bitflag_filter_item, items, "name") return filtered, [] diff --git a/src/bonsai/bonsai/bim/module/spatial/workspace.py b/src/bonsai/bonsai/bim/module/spatial/workspace.py index 7853f3a4af..aa57b03631 100644 --- a/src/bonsai/bonsai/bim/module/spatial/workspace.py +++ b/src/bonsai/bonsai/bim/module/spatial/workspace.py @@ -23,6 +23,7 @@ import bonsai.tool as tool from bonsai.bim.module.model.data import AuthoringData from bpy.types import WorkSpaceTool import bonsai.core.spatial +from functools import partial class SpatialTool(WorkSpaceTool): @@ -49,9 +50,7 @@ class SpatialTool(WorkSpaceTool): SpatialToolUI.draw(context, layout) -def add_layout_hotkey(layout: bpy.types.UILayout, text: str, hotkey: str, description: str) -> None: - args = ("spatial", layout, text, hotkey, description) - tool.Blender.add_layout_hotkey_operator(*args) +add_layout_hotkey = partial(tool.Blender.add_layout_hotkey_operator, tool_name="spatial", module_name=__name__) class SpatialToolUI: @@ -125,8 +124,8 @@ class SpatialToolUI: class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.spatial_hotkey" - bl_label = "Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() diff --git a/src/bonsai/bonsai/bim/module/structural/data.py b/src/bonsai/bonsai/bim/module/structural/data.py index 5c98eadbe8..d2cf1f3fbd 100644 --- a/src/bonsai/bonsai/bim/module/structural/data.py +++ b/src/bonsai/bonsai/bim/module/structural/data.py @@ -59,7 +59,7 @@ class LoadGroupDecorationData: return ret m = models[0] - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() if props.activity_type == "Action": groups = m.LoadedBy or [] for g in groups: @@ -128,7 +128,8 @@ class ConnectedStructuralMembersData: if not element: return [] results = [] - props = bpy.context.active_object.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) for rel in element.ConnectsStructuralMembers or []: condition = rel.AppliedCondition if condition: @@ -257,7 +258,7 @@ class StructuralLoadCasesData: @classmethod def applicable_structural_loads(cls): - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() results = [] for load in tool.Ifc.get().by_type("IfcStructuralLoad"): if not load.Name or not load.is_a(props.applicable_structural_load_types): diff --git a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py index 8230c55c66..f3e7f267cb 100644 --- a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py +++ b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py @@ -24,9 +24,9 @@ from mathutils import Vector import ifcopenshell import ifcopenshell.api import ifcopenshell.util.attribute +import ifcopenshell.util.placement import ifcopenshell.util.unit as ifcunit import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.structural.shader import DecorationShader from typing import Literal, TypedDict, Iterable @@ -305,9 +305,9 @@ class ShaderInfo: populate_members_dict("surface_members", element, activity, factor) recursive_subgroups(subgorups, rec_limit - 1, activity_type, factor=factor) - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() group_definition_id = int(props.load_group_to_show) - file = IfcStore.get_file() + file = tool.Ifc.get() groups = [file.by_id(group_definition_id)] recursive_subgroups(groups, 10, props.activity_type) @@ -328,12 +328,12 @@ class ShaderInfo: maximum = max([abs(float(i)) for i in values]) if maximum == 0: continue - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() reference_frame = props.reference_frame orientation = np.eye(3) if reference_frame == "LOCAL_COORDS": orientation = rotation - blender_object: bpy.types.Object = IfcStore.get_element(getattr(surf, "GlobalId", None)) + blender_object: bpy.types.Object = tool.Ifc.get_object_by_identifier(getattr(surf, "GlobalId", None)) mat = blender_object.matrix_world mesh: bpy.types.Mesh = blender_object.data @@ -436,7 +436,7 @@ class ShaderInfo: ) -> np.ndarray: "provides the transformation matrix to convert between reference frames" global_or_local = activity.GlobalOrLocal - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() reference_frame = props.reference_frame transform_matrix = np.eye(3) if reference_frame == "LOCAL_COORDS" and global_or_local != reference_frame: @@ -454,7 +454,7 @@ class ShaderInfo: activity_list = value["activities"] if len(activity_list) == 0: continue - blender_object = IfcStore.get_element(getattr(conn, "GlobalId", None)) + blender_object = tool.Ifc.get_object_by_identifier(getattr(conn, "GlobalId", None)) if blender_object.type == "MESH": conn_location = blender_object.matrix_world @ blender_object.data.vertices[0].co rotation = self.get_point_connection_rotation(conn) @@ -474,7 +474,7 @@ class ShaderInfo: "mz": (np.array((1, 0, 0)), np.array((0, 1, 0))), } keys = ["fx", "fy", "fz", "mx", "my", "mz"] - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() reference_frame = props.reference_frame if reference_frame == "LOCAL_COORDS": for key in keys: @@ -580,7 +580,7 @@ class ShaderInfo: if len(activity_list) == 0: continue - blender_object = IfcStore.get_element(getattr(member, "GlobalId", None)) + blender_object = tool.Ifc.get_object_by_identifier(getattr(member, "GlobalId", None)) start_co = blender_object.matrix_world @ blender_object.data.vertices[0].co end_co = blender_object.matrix_world @ blender_object.data.vertices[1].co @@ -592,7 +592,7 @@ class ShaderInfo: z_axis = x_axis.cross(y_axis).normalized() rotation = self.get_curve_member_rotation(member) - props = bpy.context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() reference_frame = props.reference_frame is_local = reference_frame == "LOCAL_COORDS" x_match = abs(Vector((1, 0, 0)).dot(x_axis)) > 0.99 diff --git a/src/bonsai/bonsai/bim/module/structural/operator.py b/src/bonsai/bonsai/bim/module/structural/operator.py index 646e5ad294..6836633d54 100644 --- a/src/bonsai/bonsai/bim/module/structural/operator.py +++ b/src/bonsai/bonsai/bim/module/structural/operator.py @@ -20,13 +20,14 @@ import bpy import json import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.group +import ifcopenshell.api.structural import ifcopenshell.util.attribute import bonsai.bim.helper import bonsai.core.structural as core import bonsai.tool as tool from math import degrees from mathutils import Vector, Matrix -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.structural.decorator import LoadsDecorator @@ -35,6 +36,7 @@ class ShowLoads(bpy.types.Operator): bl_idname = "bim.show_loads" bl_label = "Show Loads in 3D View" + bl_options = {"REGISTER", "UNDO"} def modal(self, context, event): if event.type == "F5": @@ -51,7 +53,11 @@ class ShowLoads(bpy.types.Operator): return {"PASS_THROUGH"} def invoke(self, context, event): - collection = bpy.data.collections["IfcStructuralItem"] + collection = bpy.data.collections.get("IfcStructuralItem") + if collection is None: + self.report({"ERROR"}, "No IfcStructuralItems found.") + return {"CANCELLED"} + collection.hide_viewport = False context.window.cursor_modal_set("WAIT") try: @@ -75,15 +81,16 @@ class AddStructuralMemberConnection(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties - file = IfcStore.get_file() + assert obj + oprops = tool.Blender.get_object_bim_props(obj) + props = tool.Structural.get_object_structural_props(obj) + file = tool.Ifc.get() related_structural_connection = file.by_id(oprops.ifc_definition_id) - relating_structural_member = file.by_id(props.relating_structural_member.BIMObjectProperties.ifc_definition_id) + relating_structural_member = tool.Ifc.get_entity(props.relating_structural_member) + assert relating_structural_member if not relating_structural_member.is_a("IfcStructuralMember"): return {"FINISHED"} - ifcopenshell.api.run( - "structural.add_structural_member_connection", + ifcopenshell.api.structural.add_structural_member_connection( file, relating_structural_member=relating_structural_member, related_structural_connection=related_structural_connection, @@ -100,8 +107,8 @@ class EnableEditingStructuralConnectionCondition(bpy.types.Operator): def execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) props.active_connects_structural_member = self.connects_structural_member return {"FINISHED"} @@ -113,7 +120,8 @@ class DisableEditingStructuralConnectionCondition(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) props.active_connects_structural_member = 0 return {"FINISHED"} @@ -125,7 +133,7 @@ class RemoveStructuralConnectionCondition(bpy.types.Operator, tool.Ifc.Operator) connects_structural_member: bpy.props.IntProperty() def _execute(self, context): - file = IfcStore.get_file() + file = tool.Ifc.get() relation = file.by_id(self.connects_structural_member) connection = relation.RelatedStructuralConnection ifcopenshell.api.run("structural.remove_structural_connection_condition", file, **{"relation": relation}) @@ -139,7 +147,7 @@ class AddStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): connection: bpy.props.IntProperty() def _execute(self, context): - file = IfcStore.get_file() + file = tool.Ifc.get() connection = file.by_id(self.connection) ifcopenshell.api.run("structural.add_structural_boundary_condition", file, **{"connection": connection}) return {"FINISHED"} @@ -152,7 +160,7 @@ class RemoveStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): connection: bpy.props.IntProperty() def _execute(self, context): - file = IfcStore.get_file() + file = tool.Ifc.get() connection = file.by_id(self.connection) ifcopenshell.api.run("structural.remove_structural_boundary_condition", file, **{"connection": connection}) return {"FINISHED"} @@ -166,12 +174,14 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) props.boundary_condition_attributes.clear() condition = tool.Ifc.get().by_id(self.boundary_condition) + schema = tool.Ifc.schema() - for attribute in IfcStore.get_schema().declaration_by_name(condition.is_a()).all_attributes(): + for attribute in schema.declaration_by_name(condition.is_a()).all_attributes(): value = getattr(condition, attribute.name(), None) data_type = ifcopenshell.util.attribute.get_primitive_type(attribute) new = props.boundary_condition_attributes.add() @@ -205,9 +215,10 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) - file = IfcStore.get_file() + file = tool.Ifc.get() connection = file.by_id(self.connection) condition = connection.AppliedCondition @@ -235,7 +246,10 @@ class DisableEditingStructuralBoundaryCondition(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.active_object.BIMStructuralProperties.active_boundary_condition = 0 + obj = context.active_object + assert obj + props = tool.Structural.get_object_structural_props(obj) + props.active_boundary_condition = 0 return {"FINISHED"} @@ -350,10 +364,11 @@ class EnableEditingStructuralItemAxis(bpy.types.Operator): def execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties + assert obj + oprops = tool.Blender.get_object_bim_props(obj) + props = tool.Structural.get_object_structural_props(obj) - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() item = self.file.by_id(oprops.ifc_definition_id) z_axis = Vector(item.Axis.DirectionRatios).normalized() @ obj.matrix_world if item.Axis else None x_axis = (obj.data.vertices[1].co - obj.data.vertices[0].co).normalized() @@ -390,7 +405,8 @@ class DisableEditingStructuralItemAxis(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) props.is_editing_axis = False if props.axis_empty: bpy.data.objects.remove(props.axis_empty) @@ -403,11 +419,12 @@ class EditStructuralItemAxis(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties + assert obj + oprops = tool.Blender.get_object_bim_props(obj) + props = tool.Structural.get_object_structural_props(obj) relative_matrix = props.axis_empty.matrix_world @ obj.matrix_world.inverted() z_axis = relative_matrix.col[2][0:3] - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "structural.edit_structural_item_axis", self.file, @@ -425,11 +442,12 @@ class EnableEditingStructuralConnectionCS(bpy.types.Operator): def execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) - self.file = IfcStore.get_file() - item = self.file.by_id(oprops.ifc_definition_id) + self.file = tool.Ifc.get() + item = tool.Ifc.get_entity(obj) + assert item location = obj.data.vertices[0].co empty = bpy.data.objects.new("Item Connection CS", None) @@ -477,7 +495,8 @@ class DisableEditingStructuralConnectionCS(bpy.types.Operator): def execute(self, context): obj = context.active_object - props = obj.BIMStructuralProperties + assert obj + props = tool.Structural.get_object_structural_props(obj) props.is_editing_connection_cs = False if props.ccs_empty: bpy.data.objects.remove(props.ccs_empty) @@ -491,16 +510,17 @@ class EditStructuralConnectionCS(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj = context.active_object - oprops = obj.BIMObjectProperties - props = obj.BIMStructuralProperties + assert obj + item = tool.Ifc.get_entity(obj) + assert item + props = tool.Structural.get_object_structural_props(obj) relative_matrix = props.ccs_empty.matrix_world @ obj.matrix_world.inverted() x_axis = relative_matrix.col[0][0:3] z_axis = relative_matrix.col[2][0:3] - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.edit_structural_connection_cs", + self.file = tool.Ifc.get() + ifcopenshell.api.structural.edit_structural_connection_cs( self.file, - structural_item=self.file.by_id(oprops.ifc_definition_id), + structural_item=item, axis=z_axis, ref_direction=x_axis, ) @@ -515,7 +535,7 @@ class AssignStructuralLoadCase(bpy.types.Operator, tool.Ifc.Operator): load_case: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "aggregate.assign_object", self.file, @@ -534,7 +554,7 @@ class UnassignStructuralLoadCase(bpy.types.Operator, tool.Ifc.Operator): load_case: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() ifcopenshell.api.run( "aggregate.unassign_object", self.file, @@ -552,7 +572,7 @@ class AddStructuralLoadCase(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - ifcopenshell.api.run("structural.add_structural_load_case", IfcStore.get_file()) + ifcopenshell.api.run("structural.add_structural_load_case", tool.Ifc.get()) return {"FINISHED"} @@ -562,13 +582,13 @@ class EditStructuralLoadCase(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() attributes = bonsai.bim.helper.export_attributes(props.load_case_attributes) - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.edit_structural_load_case", + self.file = tool.Ifc.get() + ifcopenshell.api.structural.edit_structural_load_case( self.file, - **{"load_case": self.file.by_id(props.active_load_case_id), "attributes": attributes}, + load_case=self.file.by_id(props.active_load_case_id), + attributes=attributes, ) bpy.ops.bim.disable_editing_structural_load_case() return {"FINISHED"} @@ -581,10 +601,8 @@ class RemoveStructuralLoadCase(bpy.types.Operator, tool.Ifc.Operator): load_case: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.remove_structural_load_case", self.file, load_case=self.file.by_id(self.load_case) - ) + self.file = tool.Ifc.get() + ifcopenshell.api.structural.remove_structural_load_case(self.file, load_case=self.file.by_id(self.load_case)) return {"FINISHED"} @@ -595,7 +613,7 @@ class EnableEditingStructuralLoadCase(bpy.types.Operator): load_case: bpy.props.IntProperty() def execute(self, context): - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() self.props.active_load_case_id = self.load_case self.props.load_case_editing_type = "ATTRIBUTES" self.props.load_case_attributes.clear() @@ -615,7 +633,8 @@ class DisableEditingStructuralLoadCase(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMStructuralProperties.active_load_case_id = 0 + props = tool.Structural.get_structural_props() + props.active_load_case_id = 0 return {"FINISHED"} @@ -626,9 +645,9 @@ class EnableEditingStructuralLoadCaseGroups(bpy.types.Operator): load_case: bpy.props.IntProperty() def execute(self, context): - self.props = context.scene.BIMStructuralProperties - self.props.active_load_case_id = self.load_case - self.props.load_case_editing_type = "GROUPS" + props = tool.Structural.get_structural_props() + props.active_load_case_id = self.load_case + props.load_case_editing_type = "GROUPS" return {"FINISHED"} @@ -639,11 +658,9 @@ class AddStructuralLoadGroup(bpy.types.Operator, tool.Ifc.Operator): load_case: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() - load_group = ifcopenshell.api.run("structural.add_structural_load_group", self.file) - ifcopenshell.api.run( - "group.assign_group", self.file, products=[load_group], group=self.file.by_id(self.load_case) - ) + self.file = tool.Ifc.get() + load_group = ifcopenshell.api.structural.add_structural_load_group(self.file) + ifcopenshell.api.group.assign_group(self.file, products=[load_group], group=self.file.by_id(self.load_case)) return {"FINISHED"} @@ -654,10 +671,8 @@ class RemoveStructuralLoadGroup(bpy.types.Operator, tool.Ifc.Operator): load_group: bpy.props.IntProperty() def _execute(self, context): - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.remove_structural_load_group", self.file, load_group=self.file.by_id(self.load_group) - ) + self.file = tool.Ifc.get() + ifcopenshell.api.structural.remove_structural_load_group(self.file, load_group=self.file.by_id(self.load_group)) return {"FINISHED"} @@ -668,16 +683,16 @@ class EnableEditingStructuralLoadGroupActivities(bpy.types.Operator): load_group: bpy.props.IntProperty() def execute(self, context): - self.file = IfcStore.get_file() - self.props = context.scene.BIMStructuralProperties + self.file = tool.Ifc.get() + self.props = tool.Structural.get_structural_props() self.props.active_load_group_id = self.load_group self.props.load_group_editing_type = "ACTIVITY" self.load_structural_activities() return {"FINISHED"} - def load_structural_activities(self): + def load_structural_activities(self) -> None: self.props.load_group_activities.clear() - for rel in tool.Ifc.get().by_id(self.load_group).IsGroupedBy: + for rel in self.file.by_id(self.load_group).IsGroupedBy: for activity in rel.RelatedObjects: new = self.props.load_group_activities.add() new.ifc_definition_id = activity.id() @@ -692,12 +707,12 @@ class AddStructuralActivity(bpy.types.Operator, tool.Ifc.Operator): load_group: bpy.props.IntProperty() def _execute(self, context): - self.props = context.scene.BIMStructuralProperties - self.file = IfcStore.get_file() + self.props = tool.Structural.get_structural_props() + self.file = tool.Ifc.get() for obj in context.selected_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + element = tool.Ifc.get_entity(obj) + if not element: continue - element = self.file.by_id(obj.BIMObjectProperties.ifc_definition_id) applied_load_class = self.props.applicable_structural_load_types allowed_load_classes = { @@ -721,16 +736,13 @@ class AddStructuralActivity(bpy.types.Operator, tool.Ifc.Operator): ifc_class = applicable_activity_class[element.is_a()] - activity = ifcopenshell.api.run( - "structural.add_structural_activity", + activity = ifcopenshell.api.structural.add_structural_activity( self.file, ifc_class=ifc_class, applied_load=self.file.by_id(int(self.props.applicable_structural_loads)), structural_member=element, ) - ifcopenshell.api.run( - "group.assign_group", self.file, products=[activity], group=self.file.by_id(self.load_group) - ) + ifcopenshell.api.group.assign_group(self.file, products=[activity], group=self.file.by_id(self.load_group)) bpy.ops.bim.enable_editing_structural_load_group_activities(load_group=self.load_group) return {"FINISHED"} @@ -741,8 +753,8 @@ class LoadStructuralLoads(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - self.file = IfcStore.get_file() - props = context.scene.BIMStructuralProperties + self.file = tool.Ifc.get() + props = tool.Structural.get_structural_props() props.structural_loads.clear() loads = tool.Ifc.get().by_type("IfcStructuralLoad") if props.filtered_structural_loads: @@ -750,7 +762,7 @@ class LoadStructuralLoads(bpy.types.Operator): for structural_load in loads: if ( names.count(structural_load.Name or "Unnamed") > 1 - and len(self.file.get_inverse(structural_load)) < 2 + and self.file.get_total_inverses(structural_load) < 2 ): continue new = props.structural_loads.add() @@ -775,7 +787,8 @@ class DisableStructuralLoadEditingUI(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMStructuralProperties.is_editing_loads = False + props = tool.Structural.get_structural_props() + props.is_editing_loads = False return {"FINISHED"} @@ -787,7 +800,7 @@ class AddStructuralLoad(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): result = ifcopenshell.api.run( - "structural.add_structural_load", IfcStore.get_file(), name="New Load", ifc_class=self.ifc_class + "structural.add_structural_load", tool.Ifc.get(), name="New Load", ifc_class=self.ifc_class ) bpy.ops.bim.load_structural_loads() bpy.ops.bim.enable_editing_structural_load(structural_load=result.id()) @@ -801,7 +814,7 @@ class EnableEditingStructuralLoad(bpy.types.Operator): structural_load: bpy.props.IntProperty() def execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() props.structural_load_attributes.clear() bonsai.bim.helper.import_attributes2( tool.Ifc.get().by_id(self.structural_load), props.structural_load_attributes @@ -816,7 +829,8 @@ class DisableEditingStructuralLoad(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMStructuralProperties.active_structural_load_id = 0 + props = tool.Structural.get_structural_props() + props.active_structural_load_id = 0 return {"FINISHED"} @@ -827,12 +841,10 @@ class RemoveStructuralLoad(bpy.types.Operator, tool.Ifc.Operator): structural_load: bpy.props.IntProperty() def _execute(self, context): - props = context.scene.BIMStructuralProperties - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.remove_structural_load", + self.file = tool.Ifc.get() + ifcopenshell.api.structural.remove_structural_load( self.file, - **{"structural_load": self.file.by_id(self.structural_load)}, + structural_load=self.file.by_id(self.structural_load), ) bpy.ops.bim.load_structural_loads() return {"FINISHED"} @@ -844,16 +856,13 @@ class EditStructuralLoad(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() attributes = bonsai.bim.helper.export_attributes(props.structural_load_attributes) - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.edit_structural_load", + self.file = tool.Ifc.get() + ifcopenshell.api.structural.edit_structural_load( self.file, - **{ - "structural_load": self.file.by_id(props.active_structural_load_id), - "attributes": attributes, - }, + structural_load=self.file.by_id(props.active_structural_load_id), + attributes=attributes, ) bpy.ops.bim.load_structural_loads() return {"FINISHED"} @@ -865,7 +874,7 @@ class ToggleFilterStructuralLoads(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() props.filtered_structural_loads = not props.filtered_structural_loads bpy.ops.bim.load_structural_loads() return {"FINISHED"} @@ -877,8 +886,8 @@ class LoadBoundaryConditions(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - self.file = IfcStore.get_file() - props = context.scene.BIMStructuralProperties + self.file = tool.Ifc.get() + props = tool.Structural.get_structural_props() props.boundary_conditions.clear() conditions = tool.Ifc.get().by_type("IfcBoundaryCondition") if props.filtered_boundary_conditions: @@ -911,7 +920,7 @@ class ToggleFilterBoundaryConditions(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() props.filtered_boundary_conditions = not props.filtered_boundary_conditions bpy.ops.bim.load_boundary_conditions() return {"FINISHED"} @@ -923,7 +932,8 @@ class DisableBoundaryConditionEditingUI(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMStructuralProperties.is_editing_boundary_conditions = False + props = tool.Structural.get_structural_props() + props.is_editing_boundary_conditions = False return {"FINISHED"} @@ -934,9 +944,8 @@ class AddBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): ifc_class: bpy.props.StringProperty() def _execute(self, context): - result = ifcopenshell.api.run( - "structural.add_structural_boundary_condition", - IfcStore.get_file(), + result = ifcopenshell.api.structural.add_structural_boundary_condition( + tool.Ifc.get(), name="New Load", ifc_class=self.ifc_class, ) @@ -952,12 +961,13 @@ class EnableEditingBoundaryCondition(bpy.types.Operator): boundary_condition: bpy.props.IntProperty() def execute(self, context): - props = context.scene.BIMStructuralProperties + props = tool.Structural.get_structural_props() props.boundary_condition_attributes.clear() boundary_condition = tool.Ifc.get().by_id(self.boundary_condition) # bonsai.bim.helper.import_attributes(data["type"], props.boundary_condition_attributes, data) - for attribute in IfcStore.get_schema().declaration_by_name(boundary_condition.is_a()).all_attributes(): + schema = tool.Ifc.schema() + for attribute in schema.declaration_by_name(boundary_condition.is_a()).all_attributes(): value = getattr(boundary_condition, attribute.name(), None) data_type = ifcopenshell.util.attribute.get_primitive_type(attribute) new = props.boundary_condition_attributes.add() @@ -988,7 +998,8 @@ class DisableEditingBoundaryCondition(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.BIMStructuralProperties.active_boundary_condition_id = 0 + props = tool.Structural.get_structural_props() + props.active_boundary_condition_id = 0 return {"FINISHED"} @@ -999,12 +1010,10 @@ class RemoveBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): boundary_condition: bpy.props.IntProperty() def _execute(self, context): - props = context.scene.BIMStructuralProperties - self.file = IfcStore.get_file() - ifcopenshell.api.run( - "structural.remove_structural_boundary_condition", + self.file = tool.Ifc.get() + ifcopenshell.api.structural.remove_structural_boundary_condition( self.file, - **{"boundary_condition": self.file.by_id(self.boundary_condition)}, + boundary_condition=self.file.by_id(self.boundary_condition), ) bpy.ops.bim.load_boundary_conditions() return {"FINISHED"} @@ -1016,8 +1025,8 @@ class EditBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = context.scene.BIMStructuralProperties - self.file = IfcStore.get_file() + props = tool.Structural.get_structural_props() + self.file = tool.Ifc.get() # attributes = bonsai.bim.helper.export_attributes(props.boundary_condition_attributes) attributes = {} for attribute in props.boundary_condition_attributes: @@ -1029,10 +1038,10 @@ class EditBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): attributes[attribute.name] = {"value": attribute.bool_value, "type": attribute.enum_value} else: attributes[attribute.name] = {"value": attribute.float_value, "type": attribute.enum_value} - ifcopenshell.api.run( - "structural.edit_structural_boundary_condition", + ifcopenshell.api.structural.edit_structural_boundary_condition( self.file, - **{"condition": self.file.by_id(props.active_boundary_condition_id), "attributes": attributes}, + condition=self.file.by_id(props.active_boundary_condition_id), + attributes=attributes, ) bpy.ops.bim.load_boundary_conditions() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/structural/prop.py b/src/bonsai/bonsai/bim/module/structural/prop.py index 740c97f1e6..c0db44fac6 100644 --- a/src/bonsai/bonsai/bim/module/structural/prop.py +++ b/src/bonsai/bonsai/bim/module/structural/prop.py @@ -19,7 +19,6 @@ from math import radians import bpy import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute from bonsai.bim.module.structural.data import ( StructuralLoadCasesData, @@ -38,52 +37,62 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING, Union -def get_load_groups_to_show(self, context): +def get_load_groups_to_show(self: "BIMStructuralProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not LoadGroupDecorationData.is_loaded: LoadGroupDecorationData.load() return LoadGroupDecorationData.data["load_groups_to_show"] -def update_activity_type(self, context): +def update_activity_type(self: "BIMStructuralProperties", context: bpy.types.Context) -> None: LoadGroupDecorationData.is_loaded = False -def get_applicable_structural_load_types(self, context): +def get_applicable_structural_load_types( + self: "BIMStructuralProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not StructuralLoadCasesData.is_loaded: StructuralLoadCasesData.load() return StructuralLoadCasesData.data["applicable_structural_load_types"] -def updateApplicableStructuralLoadTypes(self, context): +def updateApplicableStructuralLoadTypes(self: "BIMStructuralProperties", context: bpy.types.Context) -> None: StructuralLoadCasesData.data["applicable_structural_load_types"] = ( StructuralLoadCasesData.applicable_structural_load_types() ) -def get_applicable_structural_loads(self, context): +def get_applicable_structural_loads( + self: "BIMStructuralProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not StructuralLoadCasesData.is_loaded: StructuralLoadCasesData.load() return StructuralLoadCasesData.data["applicable_structural_loads"] -def get_structural_load_types(self, context): +def get_structural_load_types( + self: "BIMStructuralProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not StructuralLoadsData.is_loaded: StructuralLoadsData.load() return StructuralLoadsData.data["structural_load_types"] -def get_boundary_condition_types(self, context): +def get_boundary_condition_types( + self: "BIMStructuralProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: if not BoundaryConditionsData.is_loaded: BoundaryConditionsData.load() return BoundaryConditionsData.data["boundary_condition_types"] -def updateAxisAngle(self, context): +def updateAxisAngle(self: "BIMObjectStructuralProperties", context: bpy.types.Context) -> None: if not self.axis_empty: return obj = context.active_object + assert obj and isinstance(obj.data, bpy.types.Mesh) empty = self.axis_empty x_axis = obj.data.vertices[1].co - obj.data.vertices[0].co empty.location = obj.data.vertices[0].co @@ -93,10 +102,11 @@ def updateAxisAngle(self, context): empty.rotation_euler[0] = radians(self.axis_angle) -def updateConnectionCS(self, context): +def updateConnectionCS(self: "BIMObjectStructuralProperties", context: bpy.types.Context) -> None: if not self.ccs_empty: return obj = context.active_object + assert obj and isinstance(obj.data, bpy.types.Mesh) empty = self.ccs_empty empty.location = obj.data.vertices[0].co empty.rotation_mode = "XYZ" @@ -109,24 +119,39 @@ class StructuralAnalysisModel(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + ifc_definition_id: int + class StructuralActivity(PropertyGroup): name: StringProperty(name="Name") applied_load_class: StringProperty(name="Applied Load Class") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + applied_load_class: str + ifc_definition_id: int + class StructuralLoad(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") number_of_inverse_references: IntProperty(name="Number of Inverse References") + if TYPE_CHECKING: + ifc_definition_id: int + number_of_inverse_references: int + class BoundaryCondition(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") number_of_inverse_references: IntProperty(name="Number of Inverse References") + if TYPE_CHECKING: + ifc_definition_id: int + number_of_inverse_references: int + class BIMStructuralProperties(PropertyGroup): structural_analysis_model_attributes: CollectionProperty( @@ -188,6 +213,45 @@ class BIMStructuralProperties(PropertyGroup): ) load_group_to_show: EnumProperty(items=get_load_groups_to_show, name="Load Groups") + if TYPE_CHECKING: + structural_analysis_model_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + is_editing: bool + structural_analysis_models: bpy.types.bpy_prop_collection_idprop[StructuralAnalysisModel] + active_structural_analysis_model_index: int + active_structural_analysis_model_id: int + load_case_editing_type: str + load_case_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + active_load_case_id: int + load_group_editing_type: str + active_load_group_id: int + applicable_structural_load_types: str + applicable_structural_loads: str + load_group_activities: bpy.types.bpy_prop_collection_idprop[StructuralActivity] + active_load_group_activity_index: int + + structural_loads: bpy.types.bpy_prop_collection_idprop[StructuralLoad] + active_structural_load_index: int + active_structural_load_id: int + is_editing_loads: bool + structural_load_types: str + structural_load_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + filtered_structural_loads: bool + + boundary_conditions: bpy.types.bpy_prop_collection_idprop[BoundaryCondition] + active_boundary_condition_index: int + active_boundary_condition_id: int + is_editing_boundary_conditions: bool + boundary_condition_types: str + boundary_condition_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + filtered_boundary_conditions: bool + + show_loads: bool + update_load_repr: bool + enable_repr_auto_update: bool + reference_frame: str + activity_type: str + load_group_to_show: str + class BIMObjectStructuralProperties(PropertyGroup): boundary_condition_attributes: CollectionProperty(name="Boundary Condition Attributes", type=Attribute) @@ -204,3 +268,19 @@ class BIMObjectStructuralProperties(PropertyGroup): ccs_y_angle: FloatProperty(name="Connection CS Y Angle", update=updateConnectionCS) ccs_z_angle: FloatProperty(name="Connection CS Z Angle", update=updateConnectionCS) ccs_empty: PointerProperty(name="CCS Empty", type=bpy.types.Object) + + if TYPE_CHECKING: + boundary_condition_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + active_boundary_condition: int + active_connects_structural_member: int + relating_structural_member: Union[bpy.types.Object, None] + is_editing_axis: bool + axis_angle: float + axis_empty: Union[bpy.types.Object, None] + + # relating_structural_activity: Union[bpy.types.Object, None] + is_editing_connection_cs: bool + ccs_x_angle: float + ccs_y_angle: float + ccs_z_angle: float + ccs_empty: Union[bpy.types.Object, None] diff --git a/src/bonsai/bonsai/bim/module/structural/ui.py b/src/bonsai/bonsai/bim/module/structural/ui.py index 767724bdd5..ea359e4ca2 100644 --- a/src/bonsai/bonsai/bim/module/structural/ui.py +++ b/src/bonsai/bonsai/bim/module/structural/ui.py @@ -16,10 +16,11 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy +import bonsai.tool as tool import bonsai.bim.helper from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import draw_attributes, prop_with_search from bonsai.bim.module.structural.data import ( StructuralBoundaryConditionsData, @@ -31,9 +32,25 @@ from bonsai.bim.module.structural.data import ( StructuralConnectionData, BoundaryConditionsData, ) +from typing import TYPE_CHECKING, Any, Union + +if TYPE_CHECKING: + from bonsai.bim.module.structural.prop import ( + BIMStructuralProperties, + BIMObjectStructuralProperties, + BoundaryCondition, + StructuralLoad, + StructuralActivity, + StructuralAnalysisModel, + ) -def draw_boundary_condition_ui(layout, boundary_condition, connection_id, props): +def draw_boundary_condition_ui( + layout: bpy.types.UILayout, + boundary_condition: dict[str, Any], + connection_id: int, + props: BIMObjectStructuralProperties, +) -> None: row = layout.row(align=True) if not boundary_condition: row.label(text="No Boundary Condition Found", icon="CON_TRACKTO") @@ -59,11 +76,13 @@ def draw_boundary_condition_ui(layout, boundary_condition, connection_id, props) draw_boundary_condition_read_only_ui(layout, boundary_condition) -def draw_boundary_condition_editable_ui(layout: bpy.types.UILayout, props: bpy.types.PropertyGroup) -> None: +def draw_boundary_condition_editable_ui( + layout: bpy.types.UILayout, props: Union[BIMStructuralProperties, BIMObjectStructuralProperties] +) -> None: draw_attributes(props.boundary_condition_attributes, layout) -def draw_boundary_condition_read_only_ui(layout, boundary_condition): +def draw_boundary_condition_read_only_ui(layout: bpy.types.UILayout, boundary_condition: dict[str, Any]) -> None: for attribute in boundary_condition["attributes"]: row = layout.row(align=True) row.label(text=attribute["name"]) @@ -85,14 +104,14 @@ class BIM_PT_structural_boundary_conditions(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not (tool.Ifc.get_object_by_identifier(ifc_id)): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcStructuralConnection"): + if not tool.Ifc.get().by_id(ifc_id).is_a("IfcStructuralConnection"): return False return True @@ -100,11 +119,15 @@ class BIM_PT_structural_boundary_conditions(Panel): if not StructuralBoundaryConditionsData.is_loaded: StructuralBoundaryConditionsData.load() + obj = context.active_object + assert obj + self.props = tool.Structural.get_object_structural_props(obj) + draw_boundary_condition_ui( self.layout, StructuralBoundaryConditionsData.data["boundary_condition"], StructuralBoundaryConditionsData.data["connection_id"], - context.active_object.BIMStructuralProperties, + self.props, ) @@ -120,14 +143,14 @@ class BIM_PT_connected_structural_members(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not (tool.Ifc.get_object_by_identifier(ifc_id)): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcStructuralConnection"): + if not tool.Ifc.get().by_id(ifc_id).is_a("IfcStructuralConnection"): return False return True @@ -135,7 +158,9 @@ class BIM_PT_connected_structural_members(Panel): if not ConnectedStructuralMembersData.is_loaded: ConnectedStructuralMembersData.load() - self.props = context.active_object.BIMStructuralProperties + obj = context.active_object + assert obj + self.props = tool.Structural.get_object_structural_props(obj) row = self.layout.row(align=True) row.prop(self.props, "relating_structural_member", text="", icon="CON_TRACKTO") @@ -173,14 +198,14 @@ class BIM_PT_structural_member(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcStructuralMember"): + if not tool.Ifc.get().by_id(ifc_id).is_a("IfcStructuralMember"): return False return True @@ -188,7 +213,9 @@ class BIM_PT_structural_member(Panel): if not StructuralMemberData.is_loaded: StructuralMemberData.load() - self.props = context.active_object.BIMStructuralProperties + obj = context.active_object + assert obj + self.props = tool.Structural.get_object_structural_props(obj) if StructuralMemberData.data["active_object_class"] == "IfcStructuralCurveMember": if self.props.is_editing_axis: @@ -216,14 +243,14 @@ class BIM_PT_structural_connection(Panel): @classmethod def poll(cls, context): - if not context.active_object: + if not (obj := context.active_object): return False - props = context.active_object.BIMObjectProperties - if not props.ifc_definition_id: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id: return False - if not IfcStore.get_element(props.ifc_definition_id): + if not tool.Ifc.get_object_by_identifier(ifc_id): return False - if not IfcStore.get_file().by_id(props.ifc_definition_id).is_a("IfcStructuralConnection"): + if not tool.Ifc.get().by_id(ifc_id).is_a("IfcStructuralConnection"): return False return True @@ -231,7 +258,9 @@ class BIM_PT_structural_connection(Panel): if not StructuralConnectionData.is_loaded: StructuralConnectionData.load() - self.props = context.active_object.BIMStructuralProperties + obj = context.active_object + assert obj + self.props = tool.Structural.get_object_structural_props(obj) if StructuralConnectionData.data["active_object_class"] == "IfcStructuralCurveConnection": if self.props.is_editing_axis: @@ -274,14 +303,14 @@ class BIM_PT_structural_analysis_models(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): if not StructuralAnalysisModelsData.is_loaded: StructuralAnalysisModelsData.load() - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() row = self.layout.row(align=True) row.label( @@ -309,13 +338,21 @@ class BIM_PT_structural_analysis_models(Panel): class BIM_UL_structural_analysis_models(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + data: BIMStructuralProperties, + item: StructuralAnalysisModel, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) row.label(text=item.name) if context.active_object: - oprops = context.active_object.BIMObjectProperties if item.ifc_definition_id in StructuralAnalysisModelsData.data["active_model_ids"]: op = row.operator( "bim.unassign_structural_analysis_model", text="", icon="KEYFRAME_HLT", emboss=False @@ -325,10 +362,10 @@ class BIM_UL_structural_analysis_models(UIList): op = row.operator("bim.assign_structural_analysis_model", text="", icon="KEYFRAME", emboss=False) op.structural_analysis_model = item.ifc_definition_id - if context.scene.BIMStructuralProperties.active_structural_analysis_model_id == item.ifc_definition_id: + if data.active_structural_analysis_model_id == item.ifc_definition_id: row.operator("bim.edit_structural_analysis_model", text="", icon="CHECKMARK") row.operator("bim.disable_editing_structural_analysis_model", text="", icon="CANCEL") - elif context.scene.BIMStructuralProperties.active_structural_analysis_model_id: + elif data.active_structural_analysis_model_id: op = row.operator("bim.remove_structural_analysis_model", text="", icon="X") op.structural_analysis_model = item.ifc_definition_id else: @@ -348,14 +385,14 @@ class BIM_PT_structural_load_cases(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): if not StructuralLoadCasesData.is_loaded: StructuralLoadCasesData.load() - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() row = self.layout.row() row.operator("bim.add_structural_load_case", icon="ADD") @@ -426,7 +463,16 @@ class BIM_PT_structural_load_cases(Panel): class BIM_UL_structural_activities(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMStructuralProperties, + item: StructuralActivity, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) row.label(text=item.name) @@ -443,12 +489,11 @@ class BIM_PT_show_structural_activities(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): - - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() row = self.layout.row(align=True) row.operator( @@ -474,14 +519,14 @@ class BIM_PT_structural_loads(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): if not StructuralLoadsData.is_loaded: StructuralLoadsData.load() - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() row = self.layout.row(align=True) row.label(text=f"{StructuralLoadsData.data['total_loads']} Structural Loads Found", icon="ANIM_DATA") @@ -514,16 +559,25 @@ class BIM_PT_structural_loads(Panel): class BIM_UL_structural_loads(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMStructuralProperties, + item: StructuralLoad, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) row.label(text=f"{item.name} ({item.number_of_inverse_references})") row.label(text=StructuralLoadsData.data["load_classes"][item.ifc_definition_id]) - if context.scene.BIMStructuralProperties.active_structural_load_id == item.ifc_definition_id: + if data.active_structural_load_id == item.ifc_definition_id: row.operator("bim.edit_structural_load", text="", icon="CHECKMARK") row.operator("bim.disable_editing_structural_load", text="", icon="CANCEL") - elif context.scene.BIMStructuralProperties.active_structural_load_id: + elif data.active_structural_load_id: op = row.operator("bim.remove_structural_load", text="", icon="X") op.structural_load = item.ifc_definition_id else: @@ -543,14 +597,14 @@ class BIM_PT_boundary_conditions(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file and hasattr(file, "schema") and file.schema != "IFC2X3" def draw(self, context): if not BoundaryConditionsData.is_loaded: BoundaryConditionsData.load() - self.props = context.scene.BIMStructuralProperties + self.props = tool.Structural.get_structural_props() row = self.layout.row(align=True) row.label( @@ -588,16 +642,25 @@ class BIM_PT_boundary_conditions(Panel): class BIM_UL_boundary_conditions(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMStructuralProperties, + item: BoundaryCondition, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) row.label(text=f"{item.name} ({item.number_of_inverse_references})") row.label(text=BoundaryConditionsData.data["condition_classes"][item.ifc_definition_id]) - if context.scene.BIMStructuralProperties.active_boundary_condition_id == item.ifc_definition_id: + if data.active_boundary_condition_id == item.ifc_definition_id: row.operator("bim.edit_boundary_condition", text="", icon="CHECKMARK") row.operator("bim.disable_editing_boundary_condition", text="", icon="CANCEL") - elif context.scene.BIMStructuralProperties.active_boundary_condition_id: + elif data.active_boundary_condition_id: op = row.operator("bim.remove_boundary_condition", text="", icon="X") op.boundary_condition = item.ifc_definition_id else: diff --git a/src/bonsai/bonsai/bim/module/structural/workspace.py b/src/bonsai/bonsai/bim/module/structural/workspace.py index cec8115a2d..fbacac3e1a 100644 --- a/src/bonsai/bonsai/bim/module/structural/workspace.py +++ b/src/bonsai/bonsai/bim/module/structural/workspace.py @@ -21,6 +21,7 @@ import os import bpy import bonsai.tool as tool from bpy.types import WorkSpaceTool +from functools import partial class StructuralTool(WorkSpaceTool): @@ -41,9 +42,7 @@ class StructuralTool(WorkSpaceTool): StructuralToolUI.draw(context, layout) -def add_layout_hotkey(layout: bpy.types.UILayout, text: str, hotkey: str, description: str) -> None: - args = ("structural", layout, text, hotkey, description) - tool.Blender.add_layout_hotkey_operator(*args) +add_layout_hotkey = partial(tool.Blender.add_layout_hotkey_operator, tool_name="structural", module_name=__name__) # NOTES before adding new operators: @@ -55,7 +54,7 @@ class StructuralToolUI: @classmethod def draw(cls, context, layout): cls.layout = layout - # cls.props = context.scene.BIMStructuralProperties + cls.props = tool.Structural.get_structural_props() row = cls.layout.row(align=True) if not tool.Ifc.get(): @@ -86,8 +85,8 @@ class StructuralToolUI: class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.structural_hotkey" - bl_label = "Hotkey" - bl_options = {"REGISTER", "UNDO"} + bl_label = "" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} hotkey: bpy.props.StringProperty() description: bpy.props.StringProperty() @@ -100,12 +99,12 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return operator.description or "" def _execute(self, context): - # self.props = context.scene.BIMStructuralProperties + # self.props = tool.Structural.get_structural_props() getattr(self, f"hotkey_{self.hotkey}")() def invoke(self, context, event): # https://blender.stackexchange.com/questions/276035/how-do-i-make-operators-remember-their-property-values-when-called-from-a-hotkey - # self.props = context.scene.BIMStructuralProperties + # self.props = tool.Structural.get_structural_props() return self.execute(context) def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/style/data.py b/src/bonsai/bonsai/bim/module/style/data.py index afcc7eba8d..09cb5447b1 100644 --- a/src/bonsai/bonsai/bim/module/style/data.py +++ b/src/bonsai/bonsai/bim/module/style/data.py @@ -97,9 +97,8 @@ class BlenderMaterialStyleData: material = obj.active_material if not material: return False - props = material.BIMStyleProperties - style_id = props.ifc_definition_id - style = tool.Ifc.get_entity_by_id(style_id) + + style = tool.Ifc.get_entity(material) if not style: return False diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index ef7914c69e..1a9a43f141 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -25,6 +25,7 @@ import ifcopenshell.api import ifcopenshell.api.style import ifcopenshell.util.representation import ifcopenshell.util.unit +from bpy_extras.io_utils import ImportHelper from pathlib import Path from mathutils import Vector from typing import Any, Union @@ -118,8 +119,7 @@ class UnlinkStyle(bpy.types.Operator, tool.Ifc.Operator): # Don't check blender_material and style_id as this operator is only called from UI. assert isinstance(self.blender_material, str) # Type checker. material = bpy.data.materials[self.blender_material] - style_id = material.BIMStyleProperties.ifc_definition_id - style = tool.Ifc.get_entity_by_id(style_id) + style = tool.Ifc.get_entity(material) # Material is linked to a style from a different project. if not style or tool.Ifc.get_object(style) != material: @@ -155,10 +155,10 @@ class UnlinkStyle(bpy.types.Operator, tool.Ifc.Operator): # for unlinked blender material. updated_meshes = set() for obj in bpy.data.objects: - mesh = obj.data - if not isinstance(mesh, bpy.types.Mesh): + if not (mesh := obj.data) or not isinstance(mesh, bpy.types.Mesh): continue - if not mesh.BIMMeshProperties.ifc_definition_id: + representation = tool.Geometry.get_data_representation(mesh) + if not representation: continue if mesh in updated_meshes: continue @@ -221,17 +221,25 @@ class UpdateCurrentStyle(bpy.types.Operator): def execute(self, context): style = tool.Ifc.get().by_id(self.style_id) material = tool.Ifc.get_object(style) - current_style_type = material.BIMStyleProperties.active_style_type + msprops = tool.Style.get_material_style_props(material) + current_style_type = msprops.active_style_type if self.update_all: - context.scene.BIMStylesProperties.active_style_type = current_style_type + sprops = tool.Style.get_style_props() + sprops.active_style_type = current_style_type return {"FINISHED"} - updated_materials = set() + updated_materials: set[bpy.types.Material] = set() for obj in context.selected_objects: + if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)): + continue for mat in obj.data.materials: - if mat and mat not in updated_materials and mat.BIMStyleProperties.ifc_definition_id != 0: - mat.BIMStyleProperties.active_style_type = current_style_type + if ( + mat + and mat not in updated_materials + and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0 + ): + msprops_.active_style_type = current_style_type updated_materials.add(mat) return {"FINISHED"} @@ -258,22 +266,20 @@ class SetAssetMaterialToExternalStyle(bpy.types.Operator): # the temp override to copy material node tree `right now` name = context.asset.name filepath = context.asset.full_library_path + props = tool.Style.get_style_props() bpy.app.timers.register( - lambda: self.execute_delayed(name, filepath, context.scene.BIMStylesProperties), + lambda: self.execute_delayed(name, filepath, props), first_interval=0.001, ) return {"FINISHED"} -class BrowseExternalStyle(bpy.types.Operator): +class BrowseExternalStyle(bpy.types.Operator, ImportHelper): bl_idname = "bim.browse_external_style" bl_label = "Browse External Style" + bl_description = "Select filepath for an external style." bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty( - name="File Path", description="Filepath used to import from", maxlen=1024, default="", subtype="FILE_PATH" - ) - filter_glob: bpy.props.StringProperty( default="*.blend", options={"HIDDEN"}, @@ -348,8 +354,7 @@ class BrowseExternalStyle(bpy.types.Operator): if data_block in data_blocks: self.data_block = data_block - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) def draw(self, context): layout = self.layout @@ -388,7 +393,8 @@ class BrowseExternalStyle(bpy.types.Operator): bpy.data.materials.remove(db["data_block"]) filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) - attributes = context.scene.BIMStylesProperties.external_style_attributes + props = tool.Style.get_style_props() + attributes = props.external_style_attributes attributes["Location"].string_value = filepath attributes["Identification"].string_value = f"{self.data_block_type}/{self.data_block}" attributes["Name"].string_value = self.data_block @@ -416,7 +422,7 @@ class ActivateExternalStyle(bpy.types.Operator): self.report({"INFO"}, "Material '{self.material_name}' is not an IFC style.") return {"CANCELLED"} - props = context.scene.BIMStylesProperties + props = tool.Style.get_style_props() if props.is_editing_style == style.id() and props.is_editing_class == "IfcExternallyDefinedSurfaceStyle": location = props.external_style_attributes["Location"].string_value identification = props.external_style_attributes["Identification"].string_value @@ -509,7 +515,8 @@ class LoadStyles(bpy.types.Operator): style_type: bpy.props.StringProperty() def execute(self, context): - style_type = self.style_type if self.style_type else context.scene.BIMStylesProperties.style_type + props = tool.Style.get_style_props() + style_type = self.style_type if self.style_type else props.style_type core.load_styles(tool.Style, style_type=style_type) bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} @@ -527,18 +534,16 @@ class SelectByStyle(bpy.types.Operator): return {"FINISHED"} -class ChooseTextureMapPath(bpy.types.Operator): +class ChooseTextureMapPath(bpy.types.Operator, ImportHelper): bl_idname = "bim.choose_texture_map_path" bl_label = "Choose Texture Map Path" + bl_description = "Select filepath for a texture map." bl_options = {"REGISTER", "UNDO", "INTERNAL"} texture_map_index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"}) use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", description="Save path relative to IFC file", default=True ) - filepath: bpy.props.StringProperty( - name="File Path", description="Filepath used to import from", maxlen=1024, default="", subtype="FILE_PATH" - ) filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) @@ -551,17 +556,14 @@ class ChooseTextureMapPath(bpy.types.Operator): layout.label(text="Save the .ifc file first ") layout.label(text="to use relative paths.") - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - def execute(self, context): if self.texture_map_index < 0: self.report({"ERROR"}, "Provide a texture map index") return {"CANCELLED"} filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) - texture = context.scene.BIMStylesProperties.textures[self.texture_map_index] + props = tool.Style.get_style_props() + texture = props.textures[self.texture_map_index] texture.path = filepath return {"FINISHED"} @@ -577,7 +579,7 @@ class RemoveTextureMap(bpy.types.Operator): self.report({"ERROR"}, "Provide a texture map index") return {"CANCELLED"} - props = context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.textures.remove(self.texture_map_index) # just to trigger shader graph update props.surface_colour = props.surface_colour @@ -612,7 +614,8 @@ class DuplicateStyle(bpy.types.Operator, tool.Ifc.Operator): style: bpy.props.IntProperty(name="Style ID") def _execute(self, context): - style_type = context.scene.BIMStylesProperties.style_type + props = tool.Style.get_style_props() + style_type = props.style_type ifc_file = tool.Ifc.get() style = ifc_file.by_id(self.style) tool.Style.duplicate_style(style) @@ -636,33 +639,49 @@ class AddPresentationStyle(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() + ifc_file = tool.Ifc.get() if props.style_type == "IfcSurfaceStyle": - style = ifcopenshell.api.run("style.add_style", tool.Ifc.get(), name=props.style_name) + + def get_colour_dict(name: Union[str, None], r: float, g: float, b: float) -> dict[str, Any]: + return { + "Name": name, + "Red": r, + "Green": g, + "Blue": b, + } # setup surface style element surface_style = None if props.surface_style_class in ("IfcSurfaceStyleShading", "IfcSurfaceStyleRendering"): attributes = { - "SurfaceColour": { - "Name": None, - "Red": props.surface_colour[0], - "Green": props.surface_colour[1], - "Blue": props.surface_colour[2], - } + "SurfaceColour": get_colour_dict(None, *props.surface_colour), } if props.surface_style_class == "IfcSurfaceStyleRendering": attributes["ReflectanceMethod"] = "NOTDEFINED" + elif props.surface_style_class == "IfcSurfaceStyleLighting": + # Requires all those colors to be valid style. + attributes = { + "DiffuseTransmissionColour": get_colour_dict(None, 0.0, 0.0, 0.0), + "DiffuseReflectionColour": get_colour_dict(None, 0.0, 0.0, 0.0), + "TransmissionColour": get_colour_dict(None, 0.0, 0.0, 0.0), + "ReflectanceColour": get_colour_dict(None, 0.0, 0.0, 0.0), + } + elif props.surface_style_class == "IfcSurfaceStyleWithTextures": + # TODO: Requires textures to be valid. + self.report( + {"ERROR"}, + "Adding IfcSurfaceStyleWithTextures directly is not supported yet." + "You can create Rendering style and then add Texture style to it.", + ) + return {"CANCELLED"} else: - # NOTE: for all other styles we produce just empty styles. - # In the future we might need to expose to adding presentation style UI - # LightingStyle colors and TextureStyle textures UI - # as they are required for those surface styles to keep IFC valid + # The rest of styles are valid even without any attributes assigned. attributes = {} - surface_style = ifcopenshell.api.run( - "style.add_surface_style", - tool.Ifc.get(), + style = ifcopenshell.api.style.add_style(ifc_file, name=props.style_name) + surface_style = ifcopenshell.api.style.add_surface_style( + ifc_file, style=style, ifc_class=props.surface_style_class, attributes=attributes, @@ -692,7 +711,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator): ifc_class: bpy.props.StringProperty(default="") def execute(self, context): - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = tool.Ifc.get().by_id(self.style) style_elements = tool.Style.get_style_elements(style) @@ -733,7 +752,8 @@ class EnableEditingSurfaceStyle(bpy.types.Operator): bonsai.bim.helper.import_attributes2(surface_style or self.ifc_class, attributes, callback) material = tool.Ifc.get_object(style) - active_style_type = material.BIMStyleProperties.active_style_type + msprops = tool.Style.get_material_style_props(material) + active_style_type = msprops.active_style_type if self.ifc_class == "IfcExternallyDefinedSurfaceStyle" and active_style_type != "External": if tool.Style.has_blender_external_style(style_elements): tool.Style.switch_shading(material, "External") @@ -753,7 +773,7 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): surface_style: Union[ifcopenshell.entity_instance, None] def _execute(self, context): - self.props = bpy.context.scene.BIMStylesProperties + self.props = tool.Style.get_style_props() self.style = tool.Ifc.get().by_id(self.props.is_editing_style) style_elements = tool.Style.get_style_elements(self.style) @@ -781,9 +801,10 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): # restore selected style type material = tool.Ifc.get_object(self.style) - material.BIMStyleProperties.active_style_type = material.BIMStyleProperties.active_style_type + msprops = tool.Style.get_material_style_props(material) + msprops.active_style_type = msprops.active_style_type - def edit_existing_style(self): + def edit_existing_style(self) -> None: ifc_file = tool.Ifc.get() material = tool.Ifc.get_object(self.style) assert self.surface_style @@ -845,7 +866,7 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): attributes=bonsai.bim.helper.export_attributes(attributes), ) - def add_new_style(self): + def add_new_style(self) -> None: material = tool.Ifc.get_object(self.style) if self.props.is_editing_class == "IfcSurfaceStyleShading": surface_style = ifcopenshell.api.run( @@ -889,13 +910,13 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): attributes=bonsai.bim.helper.export_attributes(attributes), ) - def get_shading_attributes(self): + def get_shading_attributes(self) -> dict[str, Any]: return { "SurfaceColour": self.color_to_dict(self.props.surface_colour), "Transparency": self.props.transparency or None, } - def get_rendering_attributes(self): + def get_rendering_attributes(self) -> dict[str, Any]: if self.props.is_diffuse_colour_null: diffuse_colour = None elif self.props.diffuse_colour_class == "IfcColourRgb": @@ -939,7 +960,7 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): textures.append(texture_data) return textures - def color_to_dict(self, x): + def color_to_dict(self, x: tuple[float, float, float]) -> dict[str, Any]: return {"Red": x[0], "Green": x[1], "Blue": x[2]} @@ -950,13 +971,14 @@ class AddSurfaceTexture(bpy.types.Operator): @classmethod def poll(cls, context): - if len(context.scene.BIMStylesProperties.textures) >= 8: + props = tool.Style.get_style_props() + if len(props.textures) >= 8: cls.poll_message_set("Only 8 texture maps available") return False return True def execute(self, context): - props = context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.textures.add() return {"FINISHED"} @@ -1137,13 +1159,16 @@ class AssignStyleToSelected(bpy.types.Operator, tool.Ifc.Operator): ifc_file = tool.Ifc.get() style = ifc_file.by_id(self.style_id) material = tool.Ifc.get_object(style) + assert isinstance(material, bpy.types.Material) has_items = False representations: dict[ifcopenshell.entity_instance, bpy.types.Object] = {} for obj in context.selected_objects: if tool.Geometry.is_representation_item(obj): has_items = True - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + assert isinstance(obj.data, bpy.types.Mesh) + item = tool.Geometry.get_active_representation(obj) + assert item tool.Style.assign_style_to_representation_item(item, style) obj.data.materials.clear() obj.data.materials.append(material) @@ -1156,7 +1181,8 @@ class AssignStyleToSelected(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} if has_items: - tool.Geometry.reload_representation(context.scene.BIMGeometryProperties.representation_obj) + gprops = tool.Geometry.get_geometry_props() + tool.Geometry.reload_representation(gprops.representation_obj) bpy.ops.bim.disable_editing_representation_items() bpy.ops.bim.enable_editing_representation_items() @@ -1179,7 +1205,7 @@ class SelectStyleInStylesUI(bpy.types.Operator): style_id: bpy.props.IntProperty() def execute(self, context): - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() ifc_file = tool.Ifc.get() style = ifc_file.by_id(self.style_id) core.load_styles(tool.Style, style.is_a()) @@ -1200,7 +1226,7 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): ifc_file = tool.Ifc.get() - props = context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = ifc_file.by_id(props.is_editing_style) surface_style = tool.Style.get_style_elements(style)[props.is_editing_class] ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style) diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py index 9111514d46..7daf36f0c4 100644 --- a/src/bonsai/bonsai/bim/module/style/prop.py +++ b/src/bonsai/bonsai/bim/module/style/prop.py @@ -33,19 +33,19 @@ from bpy.props import ( ) import gettext -from typing import Literal, Union +from typing import Literal, Union, TYPE_CHECKING, get_args _ = gettext.gettext -def get_style_types(self, context): +def get_style_types(self: "BIMStylesProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not StylesData.is_loaded: StylesData.load() return StylesData.data["style_types"] -def get_reflectance_methods(self, context): +def get_reflectance_methods(self: "BIMStylesProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not StylesData.is_loaded: StylesData.load() return StylesData.data["reflectance_methods"] @@ -77,6 +77,16 @@ class Style(PropertyGroup): type=bpy.types.Material, ) + if TYPE_CHECKING: + ifc_definition_id: int + total_elements: int + style_classes: bpy.types.bpy_prop_collection_idprop[StrProperty] + has_surface_colour: bool + surface_colour: tuple[float, float, float] + has_diffuse_colour: bool + diffuse_colour: tuple[float, float, float] + blender_material: Union[bpy.types.Material, None] + STYLE_TYPES = [ ("Shading", "Shading", ""), @@ -86,7 +96,7 @@ STYLE_TYPES = [ def update_shading_styles(self: "BIMStylesProperties", context: bpy.types.Context) -> None: for mat in bpy.data.materials: - if mat.BIMStyleProperties.ifc_definition_id == 0: + if tool.Blender.get_ifc_definition_id(mat) == 0: continue tool.Style.change_current_style_type(mat, self.active_style_type) @@ -111,7 +121,9 @@ UV_MODES = [ ("Camera", "Camera", _("UV from position coordinate in camera space")), ] - +TextureMapMode = Literal[ + "DIFFUSE", "NORMAL", "METALLICROUGHNESS", "SPECULAR", "SHININESS", "EMISSIVE", "OCCLUSION", "AMBIENT" +] TEXTURE_MAPS_MODS = ( ("DIFFUSE", "DIFFUSE", ""), ("NORMAL", "NORMAL", ""), @@ -129,6 +141,10 @@ class Texture(PropertyGroup): # NOTE: subtype `FILE_PATH` is not used to avoid .blend relative paths path: StringProperty(name="Texture Path", update=update_shader_graph) + if TYPE_CHECKING: + mode: TextureMapMode + path: str + class ColourRgb(PropertyGroup): name: StringProperty() @@ -136,6 +152,10 @@ class ColourRgb(PropertyGroup): # not exposed in the UI, here just to preserve the data color_name: StringProperty(name="Color Name") + if TYPE_CHECKING: + color_value: tuple[float, float, float] + color_name: str + # to fit blender.bim.helper.export_attributes def get_value(self): return { @@ -154,6 +174,17 @@ class ColourRgb(PropertyGroup): return "color_value" +SurfaceStyleClass = Literal[ + "IfcSurfaceStyleShading", + "IfcSurfaceStyleRendering", + "IfcSurfaceStyleWithTextures", + "IfcSurfaceStyleLighting", + "IfcSurfaceStyleRefraction", + "IfcExternallyDefinedSurfaceStyle", +] +ColourClass = Literal["IfcColourRgb", "IfcNormalisedRatioMeasure"] + + class BIMStylesProperties(PropertyGroup): is_adding: BoolProperty(name="Is Adding", description="Is adding new IfcPresentationStyle") is_editing: BoolProperty(name="Is Editing", description="Is editing IfcPresentationStyle") @@ -170,17 +201,7 @@ class BIMStylesProperties(PropertyGroup): style_type: EnumProperty(items=get_style_types, default=2, name="Style Type") style_name: StringProperty(name="Style Name") surface_style_class: EnumProperty( - items=[ - (x, x, "") - for x in ( - "IfcSurfaceStyleShading", - "IfcSurfaceStyleRendering", - "IfcSurfaceStyleWithTextures", - "IfcSurfaceStyleLighting", - "IfcSurfaceStyleRefraction", - "IfcExternallyDefinedSurfaceStyle", - ) - ], + items=[(x, x, "") for x in get_args(SurfaceStyleClass)], name="Surface Style Class", default="IfcSurfaceStyleShading", ) @@ -200,7 +221,7 @@ class BIMStylesProperties(PropertyGroup): # TODO: do something on null? is_diffuse_colour_null: BoolProperty(name="Is Null") diffuse_colour_class: EnumProperty( - items=[(x, x, "") for x in ("IfcColourRgb", "IfcNormalisedRatioMeasure")], + items=[(x, x, "") for x in get_args(ColourClass)], name="Diffuse Colour Class", update=update_shader_graph, ) @@ -212,7 +233,7 @@ class BIMStylesProperties(PropertyGroup): ) is_specular_colour_null: BoolProperty(name="Is Null") specular_colour_class: EnumProperty( - items=[(x, x, "") for x in ("IfcColourRgb", "IfcNormalisedRatioMeasure")], + items=[(x, x, "") for x in get_args(ColourClass)], name="Specular Colour Class", update=update_shader_graph, default="IfcNormalisedRatioMeasure", @@ -263,17 +284,55 @@ class BIMStylesProperties(PropertyGroup): active_style_index: IntProperty(name="Active Style Index") @property - def active_style(self): - return self.styles[self.active_style_index] if 0 <= self.active_style_index < len(self.styles) else None + def active_style(self) -> Union[Style, None]: + return tool.Blender.get_active_uilist_element(self.styles, self.active_style_index) active_style_type: EnumProperty( name="Active Style Type", description="Update current blender material to match style type for all objects in the scene", - items=STYLE_TYPES, + items=[(i, i, "") for i in get_args(tool.Style.StyleType)], default="Shading", update=update_shading_styles, ) + if TYPE_CHECKING: + is_adding: bool + is_editing: bool + is_editing_style: int + is_editing_class: str + is_editing_existing_style: bool + attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + external_style_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + refraction_style_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + lighting_style_colours: bpy.types.bpy_prop_collection_idprop[ColourRgb] + style_type: str + style_name: str + surface_style_class: SurfaceStyleClass + update_graph: bool + + # Shading props. + surface_colour: tuple[float, float, float] + transparency: float + is_diffuse_colour_null: bool + diffuse_colour_class: ColourClass + diffuse_colour: tuple[float, float, float] + diffuse_colour_ratio: float + is_specular_colour_null: bool + specular_colour_class: ColourClass + specular_colour: tuple[float, float, float] + specular_colour_ratio: float + is_specular_highlight_null: bool + specular_highlight: float + reflectance_method: str + + # Texture props. + textures: bpy.types.bpy_prop_collection_idprop[Texture] + uv_mode: Literal["UV", "Generated", "Camera"] + + styles: bpy.types.bpy_prop_collection_idprop[Style] + active_style_index: int + active_style_type: tool.Style.StyleType + def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context) -> None: blender_material = self.id_data @@ -290,8 +349,13 @@ class BIMStyleProperties(PropertyGroup): active_style_type: EnumProperty( name="Active Style Type", description="Update current blender material to match style type", - items=STYLE_TYPES, + items=[(i, i, "") for i in get_args(tool.Style.StyleType)], default="Shading", update=update_shading_style, ) is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False) + + if TYPE_CHECKING: + ifc_definition_id: int + active_style_type: tool.Style.StyleType + is_renaming: bool diff --git a/src/bonsai/bonsai/bim/module/style/ui.py b/src/bonsai/bonsai/bim/module/style/ui.py index 2486ee91e8..e7fc9676b0 100644 --- a/src/bonsai/bonsai/bim/module/style/ui.py +++ b/src/bonsai/bonsai/bim/module/style/ui.py @@ -16,13 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.bim.helper import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.style.data import StylesData, BlenderMaterialStyleData -from typing import Union +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.style.prop import BIMStylesProperties, Style class BIM_PT_styles(Panel): @@ -36,13 +39,13 @@ class BIM_PT_styles(Panel): @classmethod def poll(cls, context): - return IfcStore.get_file() + return tool.Ifc.get() def draw(self, context): if not StylesData.is_loaded: StylesData.load() - self.props = context.scene.BIMStylesProperties + self.props = tool.Style.get_style_props() if not self.props.is_editing: row = self.layout.row(align=True) @@ -96,7 +99,8 @@ class BIM_PT_styles(Panel): if active_style: row = self.layout.row(align=True) if material := style.blender_material: - row.prop(material.BIMStyleProperties, "active_style_type", icon="SHADING_RENDERED", text="") + msprops = tool.Style.get_material_style_props(material) + row.prop(msprops, "active_style_type", icon="SHADING_RENDERED", text="") op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="") op.style_id = style.ifc_definition_id @@ -166,8 +170,8 @@ class BIM_PT_styles(Panel): row.prop(self.props, "reflectance_method") if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"): - self.layout.label(text=f"Supported reflectance methods are:") - self.layout.label(text=f"PHYSICAL / NOTDEFINED / FLAT") + self.layout.label(text="Supported reflectance methods are:") + self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT") row = self.layout.row(align=True) row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse") @@ -257,11 +261,19 @@ class BIM_PT_styles(Panel): class BIM_UL_styles(UIList): - def draw_item(self, context, layout: bpy.types.UILayout, data, item, icon, active_data, active_property): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMStylesProperties, + item: Style, + icon, + active_data, + active_property, + ): if item: row = layout.row(align=True) - props = context.scene.BIMStylesProperties - if item.ifc_definition_id == props.is_editing_style: + if item.ifc_definition_id == data.is_editing_style: row.label(text="", icon="GREASEPENCIL") row.prop(item, "name", text="", emboss=False) if item.has_surface_colour: @@ -297,7 +309,7 @@ class BIM_PT_style(Panel): @classmethod def poll(cls, context): - return bool(tool.Ifc.get() and (material := context.material) and material.BIMStyleProperties.ifc_definition_id) + return bool(tool.Ifc.get() and (material := context.material) and tool.Blender.get_ifc_definition_id(material)) def draw(self, context): # NOTE: this UI is needed only to indicate whether blender material is linked to IFC @@ -307,10 +319,11 @@ class BIM_PT_style(Panel): BlenderMaterialStyleData.load() material = context.material - style_id = material.BIMStyleProperties.ifc_definition_id + assert material + style_id = tool.Blender.get_ifc_definition_id(material) row = self.layout.row(align=True) - if style_id and not BlenderMaterialStyleData.data["is_linked_to_style"]: + if not BlenderMaterialStyleData.data["is_linked_to_style"]: row.label(text="Material has linked IFC from a different project.") op = row.operator("bim.unlink_style", icon="UNLINKED", text="") op.blender_material = material.name @@ -332,6 +345,6 @@ def draw_asset_browser_context_menu_append(self, context): asset = context.asset if not asset or not asset.id_type == "MATERIAL": return - if not context.scene.BIMStylesProperties.is_editing: + if not tool.Style.get_style_props().is_editing: return self.layout.operator("bim.set_asset_material_to_external_style") diff --git a/src/bonsai/bonsai/bim/module/system/data.py b/src/bonsai/bonsai/bim/module/system/data.py index 836bf17079..d630f14af1 100644 --- a/src/bonsai/bonsai/bim/module/system/data.py +++ b/src/bonsai/bonsai/bim/module/system/data.py @@ -23,6 +23,7 @@ import ifcopenshell.util.system import ifcopenshell.util.unit from ifcopenshell.util.doc import get_entity_doc import bonsai.tool as tool +from typing import Any, Union def refresh(): @@ -43,6 +44,7 @@ class SystemData: cls.data = { "system_class": cls.system_class(), "total_systems": cls.total_systems(), + "active_system": cls.active_system(), } cls.is_loaded = True @@ -63,6 +65,13 @@ class SystemData: def total_systems(cls): return len(tool.System.get_systems()) + @classmethod + def active_system(cls) -> Union[dict[str, Any], None]: + active_system = tool.System.get_active_system() + if not active_system: + return None + return {"id": active_system.id(), "Name": active_system.Name, "ifc_class": active_system.is_a()} + class ObjectSystemData: data = {} @@ -121,8 +130,9 @@ class PortData: @classmethod def load(cls): - element = tool.Ifc.get_entity(bpy.context.active_object) - cls.element = element + cls.element = None + if obj := bpy.context.active_object: + cls.element = tool.Ifc.get_entity(obj) is_port = cls.is_port() cls.data = { "total_ports": cls.total_ports(), @@ -136,19 +146,19 @@ class PortData: cls.is_loaded = True @classmethod - def total_ports(cls): + def total_ports(cls) -> int: return len(ifcopenshell.util.system.get_ports(cls.element)) @classmethod - def is_port(cls): - return cls.element and cls.element.is_a("IfcDistributionPort") + def is_port(cls) -> bool: + return bool(cls.element and cls.element.is_a("IfcDistributionPort")) @classmethod - def port_relating_object_name(cls): + def port_relating_object_name(cls) -> str: return tool.Ifc.get_object(tool.System.get_port_relating_element(cls.element)).name @classmethod - def port_connected_object_name(cls): + def port_connected_object_name(cls) -> Union[str, None]: connected_port = tool.System.get_connected_port(cls.element) if not connected_port: return @@ -156,7 +166,7 @@ class PortData: return tool.Ifc.get_object(connected_element).name @classmethod - def located_ports_data(cls): + def located_ports_data(cls) -> list[dict[str, Any]]: ports = ifcopenshell.util.system.get_ports(cls.element) data = [] @@ -169,17 +179,24 @@ class PortData: else: connected_obj_name = None - data.append((port, port_obj_name, connected_obj_name)) + data.append( + { + "id": port.id(), + "FlowDirection": port.FlowDirection, + "port_obj_name": port_obj_name, + "connected_obj_name": connected_obj_name, + } + ) return data @classmethod - def selected_objects_flow_direction(cls): - for port, _, connected_obj_name in cls.data["located_ports_data"]: - if connected_obj_name is None: + def selected_objects_flow_direction(cls) -> Union[str, None]: + for port_data in cls.data["located_ports_data"]: + if port_data["connected_obj_name"] is None: continue - connected_obj = bpy.data.objects[connected_obj_name] + connected_obj = bpy.data.objects[port_data["connected_obj_name"]] if connected_obj in bpy.context.selected_objects: - return port.FlowDirection + return port_data["FlowDirection"] class SystemDecorationData: @@ -266,6 +283,15 @@ class ActiveObjectZonesData: cls.is_loaded = True @classmethod - def zones(cls): - systems = ifcopenshell.util.system.get_element_systems(tool.Ifc.get_entity(bpy.context.active_object)) - return [s.Name or "Unnamed" for s in systems if s.is_a("IfcZone")] + def zones(cls) -> list[dict[str, Any]]: + obj = bpy.context.active_object + assert obj + element = tool.Ifc.get_entity(obj) + assert element + return [ + { + "id": z.id(), + "Name": (z.Name or "Unnamed"), + } + for z in ifcopenshell.util.system.get_element_zones(element) + ] diff --git a/src/bonsai/bonsai/bim/module/system/decorator.py b/src/bonsai/bonsai/bim/module/system/decorator.py index 3f902d0407..a463aae2e9 100644 --- a/src/bonsai/bonsai/bim/module/system/decorator.py +++ b/src/bonsai/bonsai/bim/module/system/decorator.py @@ -41,7 +41,8 @@ def transparent_color(color, alpha=0.1): @persistent def toggle_decorations_on_load(*args): - if bpy.context.scene.BIMSystemProperties.should_draw_decorations: + props = tool.System.get_system_props() + if props.should_draw_decorations: SystemDecorator.install(bpy.context) else: SystemDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index 4ef3c255f7..afd45ec735 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -18,12 +18,11 @@ import bpy import ifcopenshell.api +import ifcopenshell.util.system import bonsai.tool as tool import bonsai.core.system as core import bonsai.bim.helper -from bonsai.bim.ifc import IfcStore -from bonsai.bim.module.system.data import PortData -from mathutils import Matrix +from bonsai.bim.module.system.data import PortData, SystemData class LoadSystems(bpy.types.Operator): @@ -52,7 +51,8 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.add_system(tool.Ifc, tool.System, ifc_class=context.scene.BIMSystemProperties.system_class) + props = tool.System.get_system_props() + core.add_system(tool.Ifc, tool.System, ifc_class=props.system_class) class EditSystem(bpy.types.Operator, tool.Ifc.Operator): @@ -61,9 +61,8 @@ class EditSystem(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.edit_system( - tool.Ifc, tool.System, system=tool.Ifc.get().by_id(context.scene.BIMSystemProperties.edited_system_id) - ) + props = tool.System.get_system_props() + core.edit_system(tool.Ifc, tool.System, system=tool.Ifc.get().by_id(props.edited_system_id)) class RemoveSystem(bpy.types.Operator, tool.Ifc.Operator): @@ -92,7 +91,7 @@ class DisableEditingSystem(bpy.types.Operator): bl_label = "Disable Editing System" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): core.disable_editing_system(tool.System) return {"FINISHED"} @@ -100,37 +99,71 @@ class DisableEditingSystem(bpy.types.Operator): class AssignSystem(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_system" bl_label = "Assign System" + bl_description = "Assign system to the selected objects.\n\nIf object is not assignable to this type of system, it will be skiped." bl_options = {"REGISTER", "UNDO"} system: bpy.props.IntProperty() + @classmethod + def poll(cls, context): + if not context.selected_objects: + cls.poll_message_set("No objects selected.") + return False + return True + def _execute(self, context): - for obj in context.selected_objects: - element = tool.Ifc.get_entity(obj) - if element: - core.assign_system(tool.Ifc, system=tool.Ifc.get().by_id(self.system), product=element) + elements = [e for o in context.selected_objects if (e := tool.Ifc.get_entity(o))] + if not elements: + self.report({"ERROR"}, "No IFC elements selected.") + return {"CANCELLED"} + system = tool.Ifc.get().by_id(self.system) + assignable_elements = [e for e in elements if ifcopenshell.util.system.is_assignable(e, system)] + if not assignable_elements: + supported_elements_str = ", ".join(ifcopenshell.util.system.group_types[system.is_a()]) + self.report( + {"ERROR"}, + f"No elements assignable to {system.is_a()} is selected.\n" + f"Assignable elements types are: {supported_elements_str}.", + ) + return {"CANCELLED"} + core.assign_system(tool.Ifc, system=system, products=assignable_elements) + self.report({"INFO"}, f"System assigned to {len(assignable_elements)} elements.") + return {"FINISHED"} class UnassignSystem(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_system" bl_label = "Unassign System" + bl_description = "Unassign system from the selected objects." bl_options = {"REGISTER", "UNDO"} system: bpy.props.IntProperty() + @classmethod + def poll(cls, context): + if not context.selected_objects: + cls.poll_message_set("No objects selected.") + return False + return True + def _execute(self, context): - for obj in context.selected_objects: - element = tool.Ifc.get_entity(obj) - if element: - core.unassign_system(tool.Ifc, system=tool.Ifc.get().by_id(self.system), product=element) + elements = [e for o in context.selected_objects if (e := tool.Ifc.get_entity(o))] + if not elements: + self.report({"ERROR"}, "No IFC elements selected.") + return {"CANCELLED"} + system = tool.Ifc.get().by_id(self.system) + core.unassign_system(tool.Ifc, system=system, products=elements) + self.report({"INFO"}, f"System unassigned from {len(elements)} elements.") + return {"FINISHED"} class SelectSystemProducts(bpy.types.Operator): bl_idname = "bim.select_system_products" - bl_label = "Select System Products" + bl_label = "Select System Products And Set Active System" bl_options = {"REGISTER", "UNDO"} system: bpy.props.IntProperty() def execute(self, context): core.select_system_products(tool.System, system=tool.Ifc.get().by_id(self.system)) + SystemData.data["active_system"] = SystemData.active_system() return {"FINISHED"} @@ -336,13 +369,13 @@ class LoadZones(bpy.types.Operator): bl_label = "Load Zones" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): props = tool.System.get_zone_props() props.zones.clear() for zone in tool.Ifc.get().by_type("IfcZone"): new = props.zones.add() new.ifc_definition_id = zone.id() - new.name = zone.Name or "Unnamed" + new["name"] = zone.Name or "Unnamed" props.is_loaded = True props.is_editing = 0 return {"FINISHED"} @@ -353,7 +386,7 @@ class UnloadZones(bpy.types.Operator): bl_label = "Unload Zones" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): props = tool.System.get_zone_props() props.is_loaded = False return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index 3e14bf74e5..a984b62bb2 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -17,6 +17,8 @@ # along with Bonsai. If not, see . import bpy +import bonsai.bim.handler +import bonsai.tool as tool from bonsai.bim.module.system.data import SystemData import bonsai.bim.module.system.decorator as decorator from bonsai.bim.prop import StrProperty, Attribute @@ -31,27 +33,50 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union -def get_system_class(self, context): +def get_system_class(self: "BIMSystemProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not SystemData.is_loaded: SystemData.load() return SystemData.data["system_class"] +def update_system_name(self: "System", context: bpy.types.Context) -> None: + system = tool.Ifc.get().by_id(self.ifc_definition_id) + if system.Name == self.name: + return + system.Name = self.name + bonsai.bim.handler.refresh_ui_data() + + class System(PropertyGroup): - name: StringProperty(name="Name") + name: StringProperty(name="Name", update=update_system_name) ifc_class: StringProperty(name="IFC Class") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + ifc_class: str + ifc_definition_id: int + + +def update_zone_name(self: "Zone", context: bpy.types.Context) -> None: + zone = tool.Ifc.get().by_id(self.ifc_definition_id) + if zone.Name == self.name: + return + zone.Name = self.name + bonsai.bim.handler.refresh_ui_data() + class Zone(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + ifc_definition_id: int -def toggle_decorations(self, context): + +def toggle_decorations(self: "BIMSystemProperties", context: bpy.types.Context) -> None: toggle = self.should_draw_decorations if toggle: decorator.SystemDecorator.install(context) @@ -83,6 +108,10 @@ class BIMSystemProperties(PropertyGroup): system_class: str should_draw_decorations: bool + @property + def active_system_ui_item(self) -> Union[System, None]: + return tool.Blender.get_active_uilist_element(self.systems, self.active_system_index) + class BIMZoneProperties(PropertyGroup): attributes: CollectionProperty(name="Attributes", type=Attribute) diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index b09181c327..23c3d9d371 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -16,12 +16,17 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.bim.helper import bonsai.tool as tool from bonsai.bim.helper import prop_with_search, draw_attributes from bpy.types import Panel, UIList from bonsai.bim.module.system.data import SystemData, ZonesData, ActiveObjectZonesData, ObjectSystemData, PortData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.system.prop import BIMSystemProperties, System, BIMZoneProperties, Zone FLOW_DIRECTION_TO_ICON = { @@ -39,6 +44,7 @@ SYSTEM_ICONS = { "IfcBuiltSystem": "MOD_BUILD", "IfcZone": "CUBE", } +SYSTEM_ICONS["IfcElectricalCircuit"] = SYSTEM_ICONS["IfcDistributionCircuit"] class BIM_PT_systems(Panel): @@ -60,23 +66,17 @@ class BIM_PT_systems(Panel): if not ObjectSystemData.is_loaded: ObjectSystemData.load() - def draw_system_ui(row, system_id, system_name, system_class): - row = self.layout.row(align=True) - row.label(text=system_name, icon=SYSTEM_ICONS[system_class]) - op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") - op.system = system_id - op = row.operator("bim.unassign_system", text="", icon="X") - op.system = system_id - - self.props = context.scene.BIMSystemProperties + self.props = tool.System.get_system_props() + active_system_item = self.props.active_system_ui_item row = self.layout.row(align=True) row.prop(self.props, "should_draw_decorations") row = self.layout.row() - if active_system := tool.System.get_active_system(): + if active_system := SystemData.data["active_system"]: row.label(text=f"Active system:") - row = self.layout.row(align=True) - draw_system_ui(row, active_system.id(), active_system.Name, active_system.is_a()) + tool.System.draw_system_ui( + self.layout, active_system["id"], active_system["Name"], active_system["ifc_class"] + ) else: row.label(text="No active system is selected") @@ -84,8 +84,7 @@ class BIM_PT_systems(Panel): row = self.layout.row() row.label(text="Active object systems:") for system in ObjectSystemData.data["systems"]: - row = self.layout.row(align=True) - draw_system_ui(row, system["id"], system["name"], system["ifc_class"]) + tool.System.draw_system_ui(self.layout, system["id"], system["name"], system["ifc_class"]) else: self.layout.label(text="No System associated with active object") @@ -97,6 +96,20 @@ class BIM_PT_systems(Panel): row = self.layout.row(align=True) prop_with_search(row, self.props, "system_class", text="") row.operator("bim.add_system", text="", icon="ADD") + if active_system_item: + system_id = active_system_item.ifc_definition_id + op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") + op.system = system_id + row.operator("bim.assign_system", text="", icon="KEYFRAME_HLT").system = system_id + row.operator("bim.unassign_system", text="", icon="KEYFRAME").system = system_id + if self.props.edited_system_id == system_id: + row.operator("bim.edit_system", text="", icon="CHECKMARK") + row.operator("bim.disable_editing_system", text="", icon="CANCEL") + else: + op = row.operator("bim.enable_editing_system", text="", icon="GREASEPENCIL") + op.system = system_id + op = row.operator("bim.remove_system", text="", icon="X") + op.system = system_id else: row.operator("bim.load_systems", text="", icon="IMPORT") @@ -141,7 +154,8 @@ class BIM_PT_ports(Panel): def draw(self, context): if not PortData.is_loaded: PortData.load() - self.props = context.scene.BIMSystemProperties + + self.props = tool.System.get_system_props() row = self.layout.row(align=True) total_ports = PortData.data["total_ports"] @@ -172,25 +186,23 @@ class BIM_PT_ports(Panel): row = self.layout.row(align=True) cols = [row.column(align=True) for i in range(6)] - for i, port_data in enumerate(PortData.data["located_ports_data"]): - port, port_obj_name, connected_obj_name = port_data - flow_direction_icon = FLOW_DIRECTION_TO_ICON[port.FlowDirection or "NOTDEFINED"] - if port_obj_name: + for port_data in PortData.data["located_ports_data"]: + flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] + if port_data["port_obj_name"]: cols[0].label(text="", icon=flow_direction_icon) - cols[1].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port.id() - cols[2].label(text=port_obj_name) + cols[1].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port_data["id"] + cols[2].label(text=port_data["port_obj_name"]) else: cols[0].label(text="", icon=flow_direction_icon) cols[1].label(text="", icon="HIDE_ON") cols[2].label(text="Port is hidden") - if connected_obj_name: - connected_obj = bpy.data.objects[connected_obj_name] - cols[3].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port.id() - cols[4].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( - connected_obj.BIMObjectProperties.ifc_definition_id - ) - cols[5].label(text=connected_obj_name) + if port_data["connected_obj_name"]: + connected_obj = bpy.data.objects[port_data["connected_obj_name"]] + cols[3].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port_data["id"] + ifc_id = tool.Blender.get_ifc_definition_id(connected_obj) + cols[4].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols[5].label(text=port_data["connected_obj_name"]) else: cols[3].label(text="", icon="UNLINKED") cols[4].label(text="", icon="BLANK1") @@ -217,7 +229,7 @@ class BIM_PT_port(Panel): return True def draw(self, context): - self.props = context.scene.BIMSystemProperties + self.props = tool.System.get_system_props() layout = self.layout row = layout.row(align=True) @@ -244,9 +256,8 @@ class BIM_PT_port(Panel): relating_object = bpy.data.objects[relating_object_name] row.label(text="Port located on:") row.label(text=relating_object_name) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( - relating_object.BIMObjectProperties.ifc_definition_id - ) + ifc_id = tool.Blender.get_ifc_definition_id(relating_object) + row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id # object connected to the port row = layout.row(align=True) @@ -255,9 +266,8 @@ class BIM_PT_port(Panel): connected_object = bpy.data.objects[connected_object_name] row.label(text="Port connected to:") row.label(text=connected_object_name) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( - connected_object.BIMObjectProperties.ifc_definition_id - ) + ifc_id = tool.Blender.get_ifc_definition_id(connected_object) + row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id else: row.label(text="Port is not connected to any element") @@ -297,16 +307,15 @@ class BIM_PT_flow_controls(Panel): if not ObjectSystemData.is_loaded: ObjectSystemData.load() - def display_element(control_id, flow_element_id, displayed_object_name): + def display_element(control_id: int, flow_element_id: int, displayed_object_name: str) -> None: displayed_object = bpy.data.objects[displayed_object_name] row = self.layout.row(align=True) op = row.operator("bim.assign_unassign_flow_control", text="", icon="X") op.flow_control = control_id op.flow_element = flow_element_id op.assign = False - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( - displayed_object.BIMObjectProperties.ifc_definition_id - ) + ifc_id = tool.Blender.get_ifc_definition_id(displayed_object) + row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id row.label(text=f"{displayed_object_name}") element = tool.Ifc.get_entity(context.active_object) @@ -358,16 +367,17 @@ class BIM_PT_zones(Panel): row.operator("bim.load_zones", text="", icon="IMPORT") return - row.operator("bim.add_zone", text="", icon="ADD") row.operator("bim.unload_zones", text="", icon="CANCEL") + row = self.layout.row(align=True) + row.alignment = "RIGHT" + row.operator("bim.add_zone", text="", icon="ADD") if self.props.zones and self.props.active_zone_index < len(self.props.zones): - row = self.layout.row(align=True) ifc_definition_id = self.props.zones[self.props.active_zone_index].ifc_definition_id - row.operator("bim.enable_editing_zone", text="Edit Zone", icon="GREASEPENCIL").zone = ifc_definition_id row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF").system = ifc_definition_id row.operator("bim.assign_system", text="", icon="KEYFRAME_HLT").system = ifc_definition_id row.operator("bim.unassign_system", text="", icon="KEYFRAME").system = ifc_definition_id + row.operator("bim.enable_editing_zone", text="", icon="GREASEPENCIL").zone = ifc_definition_id row.operator("bim.remove_zone", text="", icon="X").zone = ifc_definition_id self.layout.template_list("BIM_UL_zones", "", self.props, "zones", self.props, "active_zone_index") @@ -397,8 +407,7 @@ class BIM_PT_active_object_zones(Panel): self.props = tool.System.get_zone_props() for zone in ActiveObjectZonesData.data["zones"]: - row = self.layout.row() - row.label(text=zone, icon="SEQ_STRIP_META") + tool.System.draw_system_ui(self.layout, zone["id"], zone["Name"], "IfcZone") if not ActiveObjectZonesData.data["zones"]: row = self.layout.row() @@ -406,33 +415,35 @@ class BIM_PT_active_object_zones(Panel): class BIM_UL_systems(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMSystemProperties, + item: System, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) - row.label(text=item.name, icon=SYSTEM_ICONS[item.ifc_class]) system_id = item.ifc_definition_id - row.operator("bim.assign_system", text="", icon="ADD").system = item.ifc_definition_id - if context.scene.BIMSystemProperties.edited_system_id == system_id: - op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") - op.system = system_id - row.operator("bim.edit_system", text="", icon="CHECKMARK") - row.operator("bim.disable_editing_system", text="", icon="CANCEL") - elif context.scene.BIMSystemProperties.edited_system_id: - op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") - op.system = system_id - op = row.operator("bim.remove_system", text="", icon="X") - op.system = system_id - else: - op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") - op.system = system_id - op = row.operator("bim.enable_editing_system", text="", icon="GREASEPENCIL") - op.system = system_id - op = row.operator("bim.remove_system", text="", icon="X") - op.system = system_id + if data.edited_system_id == system_id: + row.label(text="", icon="GREASEPENCIL") + row.prop(item, "name", text="", icon=SYSTEM_ICONS[item.ifc_class], emboss=False) class BIM_UL_zones(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMZoneProperties, + item: Zone, + icon, + active_data, + active_propname, + ): if item: row = layout.row(align=True) - row.label(text=item.name) + row.prop(item, "name", text="", emboss=False) diff --git a/src/bonsai/bonsai/bim/module/tester/operator.py b/src/bonsai/bonsai/bim/module/tester/operator.py index 652d0ff61a..f1341f0225 100644 --- a/src/bonsai/bonsai/bim/module/tester/operator.py +++ b/src/bonsai/bonsai/bim/module/tester/operator.py @@ -28,6 +28,7 @@ import ifctester.reporter import ifcopenshell import bonsai.tool as tool import bonsai.bim.handler +from bpy_extras.io_utils import ExportHelper from pathlib import Path from typing import Union @@ -72,7 +73,7 @@ class ExecuteIfcTester(bpy.types.Operator): # No need for if-statement, just postponing lots of diffs. if True: - dirpath = tempfile.mkdtemp() + dirpath = tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None) start = time.time() output = Path(os.path.join(dirpath, "{}_{}.html".format(Path(ifc_path).name, Path(specs_path).name))) @@ -150,7 +151,8 @@ class SelectRequirement(bpy.types.Operator): area.spaces[0].shading.show_xray = True failed_ids = [e["id"] for e in failed_entities] for obj in context.scene.objects: - if obj.BIMObjectProperties.ifc_definition_id in failed_ids: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if ifc_id in failed_ids: obj.color = (1, 0, 0, 1) else: obj.color = (1, 1, 1, 1) @@ -175,7 +177,8 @@ class SelectFailedEntities(bpy.types.Operator): failed_ids = [e["id"] for e in failed_entities] for obj in context.scene.objects: - if obj.BIMObjectProperties.ifc_definition_id in failed_ids: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if ifc_id in failed_ids: obj.select_set(True) else: obj.select_set(False) @@ -184,12 +187,13 @@ class SelectFailedEntities(bpy.types.Operator): return {"FINISHED"} -class ExportBcf(bpy.types.Operator): +class ExportBcf(bpy.types.Operator, ExportHelper): bl_idname = "bim.export_bcf" bl_label = "Export BCF" + bl_description = "Save ifctester BCF report by the provided filepath." bl_options = {"REGISTER", "UNDO"} filter_glob: bpy.props.StringProperty(default="*.bcf", options={"HIDDEN"}) - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + filename_ext = ".bcf" def execute(self, context): bcf_reporter = ifctester.reporter.Bcf(tool.Tester.specs) @@ -197,7 +201,3 @@ class ExportBcf(bpy.types.Operator): bcf_reporter.to_file(self.filepath) self.report({"INFO"}, "Finished exporting!") return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/type/__init__.py b/src/bonsai/bonsai/bim/module/type/__init__.py index a083333456..646ab48fcf 100644 --- a/src/bonsai/bonsai/bim/module/type/__init__.py +++ b/src/bonsai/bonsai/bim/module/type/__init__.py @@ -25,7 +25,6 @@ classes = ( operator.DisableEditingType, operator.DuplicateType, operator.EnableEditingType, - operator.LaunchRenameType, operator.RemoveType, operator.RenameType, operator.SelectSimilarType, diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 866407be3e..a43e0b72af 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -29,7 +29,6 @@ import bonsai.tool as tool import bonsai.core.geometry import bonsai.core.type as core import bonsai.core.root -from bonsai.bim.ifc import IfcStore class AssignType(bpy.types.Operator, tool.Ifc.Operator): @@ -66,7 +65,7 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator): def exclude_callback(attribute): return attribute.is_a("IfcProfileDef") and attribute.ProfileName - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() objs = [bpy.data.objects.get(self.related_object)] if self.related_object else context.selected_objects for obj in objs: element = tool.Ifc.get_entity(obj) @@ -187,7 +186,7 @@ class SelectSimilarType(bpy.types.Operator): related_object: bpy.props.StringProperty() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() objects = bpy.context.selected_objects # store relating types to avoid selecting same elements multiple times @@ -229,7 +228,7 @@ class SelectTypeObjects(bpy.types.Operator): relating_type: bpy.props.StringProperty() def execute(self, context): - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() relating_type = bpy.data.objects.get(self.relating_type) if self.relating_type else context.active_object at_least_one_selectable_typed_object = False for element in ifcopenshell.util.element.get_types(tool.Ifc.get_entity(relating_type)): @@ -280,19 +279,6 @@ class RenameType(bpy.types.Operator, tool.Ifc.Operator): self.layout.prop(self, "name") -class LaunchRenameType(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.launch_rename_type" - bl_label = "Launch Rename Type" - bl_options = {"REGISTER", "UNDO"} - element: bpy.props.IntProperty() - name: bpy.props.StringProperty(name="Name") - - def execute(self, context): - # This stub operator is needed because operators from menu skip the invoke call - bpy.ops.bim.rename_type("INVOKE_DEFAULT", element=self.element, name=self.name) - return {"FINISHED"} - - class AutoRenameOccurrences(bpy.types.Operator): bl_idname = "bim.auto_rename_occurrences" bl_label = "Auto Rename Occurrences" @@ -326,7 +312,7 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): new_obj.data = obj.data.copy() new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) new.Name += " Copy" - bpy.ops.bim.load_type_thumbnails(ifc_class=new.is_a()) + bpy.ops.bim.load_type_thumbnails() if obj in context.selectable_objects: tool.Blender.select_and_activate_single_object(context, new_obj) else: @@ -338,5 +324,5 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): # Set duplicated type as active in current tool. if ifc_class in (i[0] for i in (bonsai.bim.helper.get_enum_items(props, "ifc_class", context) or ()) if i): props.ifc_class = new.is_a() - props.relating_type_id = str(new_obj.BIMObjectProperties.ifc_definition_id) + props.relating_type_id = str(tool.Blender.get_ifc_definition_id(new_obj)) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/type/prop.py b/src/bonsai/bonsai/bim/module/type/prop.py index ac7986fe6b..974e4b5d53 100644 --- a/src/bonsai/bonsai/bim/module/type/prop.py +++ b/src/bonsai/bonsai/bim/module/type/prop.py @@ -20,8 +20,6 @@ import bpy import ifcopenshell.util.element import ifcopenshell.util.type from bonsai.bim.module.type.data import TypeData -from bonsai.bim.prop import StrProperty, Attribute -from bonsai.bim.ifc import IfcStore import bonsai.tool as tool from bpy.types import PropertyGroup from bpy.props import ( @@ -87,7 +85,7 @@ class BIMTypeProperties(PropertyGroup): relating_type: EnumProperty(items=get_relating_type, name="Relating Type") relating_type_object: PointerProperty( type=bpy.types.Object, - name="Copy Class", + name="Copy Type", update=update_relating_type_from_object, poll=is_object_class_applicable, ) diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index 4bfca516e3..291c37f1ba 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -19,7 +19,6 @@ import bonsai.tool as tool import bonsai.bim.module.type.prop as type_prop from bpy.types import Panel -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import prop_with_search from bonsai.bim.module.type.data import TypeData @@ -47,7 +46,9 @@ class BIM_PT_type(Panel): if not TypeData.is_loaded: TypeData.load() - oprops = context.active_object.BIMObjectProperties + obj = context.active_object + assert obj + oprops = tool.Blender.get_object_bim_props(obj) if TypeData.data["is_product"]: self.draw_product_ui(context) @@ -55,21 +56,19 @@ class BIM_PT_type(Panel): self.draw_type_ui(context) def draw_type_ui(self, context): - props = context.active_object.BIMTypeProperties - oprops = context.active_object.BIMObjectProperties + oprops = tool.Blender.get_object_bim_props(context.active_object) row = self.layout.row(align=True) row.label(text=f"{TypeData.data['total_instances']} Typed Objects") select_type_objects_row = row.row(align=True) select_type_objects_row.operator("bim.select_type_objects", icon="RESTRICT_SELECT_OFF", text="") select_type_objects_row.enabled = int(TypeData.data["total_instances"]) > 0 op = row.operator("bim.duplicate_type", icon="DUPLICATE", text="") - op.element = context.active_object.BIMObjectProperties.ifc_definition_id + op.element = oprops.ifc_definition_id row.operator("bim.auto_rename_occurrences", icon="ITALIC", text="") def draw_product_ui(self, context): layout = self.layout props = context.active_object.BIMTypeProperties - oprops = context.active_object.BIMObjectProperties if props.is_editing_type: row = layout.row(align=True) @@ -98,4 +97,4 @@ class BIM_PT_type(Panel): def add_object_button(self, context): - self.layout.operator("bim.add_constr_type_instance", icon="PLUGIN") + self.layout.operator("bim.add_occurrence", icon="PLUGIN") diff --git a/src/bonsai/bonsai/bim/module/unit/prop.py b/src/bonsai/bonsai/bim/module/unit/prop.py index edd23f2ce4..f0f04e16ae 100644 --- a/src/bonsai/bonsai/bim/module/unit/prop.py +++ b/src/bonsai/bonsai/bim/module/unit/prop.py @@ -17,7 +17,6 @@ # along with Bonsai. If not, see . import bpy -from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute from bonsai.bim.module.unit.data import UnitsData from bpy.types import PropertyGroup @@ -31,21 +30,22 @@ from bpy.props import ( FloatVectorProperty, CollectionProperty, ) +from typing import TYPE_CHECKING -def get_unit_classes(self, context): +def get_unit_classes(self: "BIMUnitProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not UnitsData.is_loaded: UnitsData.load() return UnitsData.data["unit_classes"] -def get_conversion_unit_types(self, context): +def get_conversion_unit_types(self: "BIMUnitProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not UnitsData.is_loaded: UnitsData.load() return UnitsData.data["conversion_unit_types"] -def get_named_unit_types(self, context): +def get_named_unit_types(self: "BIMUnitProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: if not UnitsData.is_loaded: UnitsData.load() return UnitsData.data["named_unit_types"] @@ -58,6 +58,12 @@ class Unit(PropertyGroup): ifc_class: StringProperty(name="IFC Class") ifc_definition_id: IntProperty(name="IFC Definition ID") + if TYPE_CHECKING: + unit_type: str + is_assigned: bool + ifc_class: str + ifc_definition_id: int + class BIMUnitProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") @@ -68,3 +74,13 @@ class BIMUnitProperties(PropertyGroup): conversion_unit_types: EnumProperty(items=get_conversion_unit_types, name="Conversion Unit Types") named_unit_types: EnumProperty(items=get_named_unit_types, name="Named Unit Types") unit_attributes: CollectionProperty(name="Unit Attributes", type=Attribute) + + if TYPE_CHECKING: + is_editing: bool + units: bpy.types.bpy_prop_collection_idprop[Unit] + active_unit_index: int + active_unit_id: int + unit_classes: str + conversion_unit_types: str + named_unit_types: str + unit_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] diff --git a/src/bonsai/bonsai/bim/module/unit/ui.py b/src/bonsai/bonsai/bim/module/unit/ui.py index 77e0117699..f7b55a96e3 100644 --- a/src/bonsai/bonsai/bim/module/unit/ui.py +++ b/src/bonsai/bonsai/bim/module/unit/ui.py @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . import bonsai.bim.helper +import bonsai.tool as tool from bpy.types import Panel, UIList -from bonsai.bim.ifc import IfcStore from bonsai.bim.helper import prop_with_search from bonsai.bim.module.unit.data import UnitsData @@ -34,14 +34,14 @@ class BIM_PT_units(Panel): @classmethod def poll(cls, context): - file = IfcStore.get_file() + file = tool.Ifc.get() return file def draw(self, context): if not UnitsData.is_loaded: UnitsData.load() - self.props = context.scene.BIMUnitProperties + self.props = tool.Unit.get_unit_props() row = self.layout.row(align=True) row.label(text="{} Units Found".format(UnitsData.data["total_units"]), icon="SNAP_GRID") @@ -103,7 +103,7 @@ class BIM_PT_units(Panel): class BIM_UL_units(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - props = context.scene.BIMUnitProperties + props = tool.Unit.get_unit_props() if item: icon = "MOD_MESHDEFORM" if item.ifc_class == "IfcSIUnit": diff --git a/src/bonsai/bonsai/bim/module/void/data.py b/src/bonsai/bonsai/bim/module/void/data.py index fe387b6f81..133d3e15f5 100644 --- a/src/bonsai/bonsai/bim/module/void/data.py +++ b/src/bonsai/bonsai/bim/module/void/data.py @@ -127,26 +127,14 @@ class BooleansData: def booleans(cls): props = tool.Geometry.get_geometry_props() obj = props.representation_obj or bpy.context.active_object - if ( - not obj.data - or not hasattr(obj.data, "BIMMeshProperties") - or not obj.data.BIMMeshProperties.ifc_definition_id - ): + if not (representation := tool.Geometry.get_active_representation(obj)): return [] - - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) return tool.Model.get_booleans(representation=representation) @classmethod def manual_booleans(cls): props = tool.Geometry.get_geometry_props() obj = props.representation_obj or bpy.context.active_object - if ( - not obj.data - or not hasattr(obj.data, "BIMMeshProperties") - or not obj.data.BIMMeshProperties.ifc_definition_id - ): + if not (representation := tool.Geometry.get_active_representation(obj)): return [] - - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) return tool.Model.get_manual_booleans(tool.Ifc.get_entity(obj), representation=representation) diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py index 5867ed9950..3274b974d7 100644 --- a/src/bonsai/bonsai/bim/module/void/operator.py +++ b/src/bonsai/bonsai/bim/module/void/operator.py @@ -24,7 +24,6 @@ import bonsai.tool as tool import bonsai.core.geometry import bonsai.core.root import bonsai.bim.handler -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.model.opening import FilledOpeningGenerator @@ -98,7 +97,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): break element_had_openings = tool.Geometry.has_openings(voided_element) - body_context = ifcopenshell.util.representation.get_context(IfcStore.get_file(), "Model", "Body") + body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body") if not element2: element2 = bonsai.core.root.assign_class( tool.Ifc, @@ -139,7 +138,8 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc, tool.Geometry, tool.Surveyor, obj=voided_obj ) - representation = tool.Ifc.get().by_id(voided_obj.data.BIMMeshProperties.ifc_definition_id) + representation = tool.Geometry.get_active_representation(voided_obj) + assert representation bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -214,9 +214,9 @@ class AddFilling(bpy.types.Operator, tool.Ifc.Operator): opening = context.scene.objects.get(self.opening, context.scene.VoidProperties.desired_opening) if opening is None: return {"FINISHED"} - self.file = IfcStore.get_file() - element_id = obj.BIMObjectProperties.ifc_definition_id - opening_id = opening.BIMObjectProperties.ifc_definition_id + self.file = tool.Ifc.get() + element_id = tool.Blender.get_object_bim_props(obj).ifc_definition_id + opening_id = tool.Blender.get_object_bim_props(opening).ifc_definition_id if not element_id or not opening_id or element_id == opening_id: return {"FINISHED"} ifcopenshell.api.run( @@ -269,20 +269,18 @@ class BooleansMarkAsManual(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): obj = context.active_object - if ( - obj - and tool.Ifc.get_entity(obj) - and hasattr(obj.data, "BIMMeshProperties") - and obj.data.BIMMeshProperties.ifc_definition_id - ): + if obj and tool.Ifc.get_entity(obj) and tool.Geometry.get_active_representation(obj): return True cls.poll_message_set("Need to select IFC element with representation") return False def _execute(self, context): obj = context.active_object + assert obj element = tool.Ifc.get_entity(obj) - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + assert element + representation = tool.Geometry.get_active_representation(obj) + assert representation booleans = tool.Model.get_booleans(representation=representation) if self.mark_as_manual: @@ -304,13 +302,13 @@ class EnableEditingBooleans(bpy.types.Operator): @classmethod def poll(cls, context): - if not bpy.context.scene.BIMGeometryProperties.representation_obj: + if not tool.Geometry.get_geometry_props().representation_obj: cls.poll_message_set("To enable editing booleans object should be in item mode.") return False return True def execute(self, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() gprops = tool.Geometry.get_geometry_props() rep_obj = gprops.representation_obj assert rep_obj @@ -344,6 +342,6 @@ class DisableEditingBooleans(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() props.is_editing = False return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/void/prop.py b/src/bonsai/bonsai/bim/module/void/prop.py index 8c6bbaf4e6..0170dbd606 100644 --- a/src/bonsai/bonsai/bim/module/void/prop.py +++ b/src/bonsai/bonsai/bim/module/void/prop.py @@ -19,34 +19,51 @@ import bpy from bpy.types import PropertyGroup from bpy.props import PointerProperty, StringProperty, IntProperty, BoolProperty, CollectionProperty, EnumProperty -from typing import Union +from typing import Union, TYPE_CHECKING, Literal, get_args + +OperatorType = Literal["DIFFERENCE", "INTERSECTION", "UNION"] class Boolean(PropertyGroup): name: StringProperty(name="Name") - operator: StringProperty(name="Operator") + operator: EnumProperty( + items=[(i, i, "") for i in get_args(OperatorType)], + name="Operator", + default="DIFFERENCE", + ) ifc_definition_id: IntProperty(name="IFC Definition ID") level: IntProperty(name="Level") + if TYPE_CHECKING: + operator: OperatorType + name: str + ifc_definition_id: int + level: int + class VoidProperties(PropertyGroup): desired_opening: PointerProperty(name="Desired Opening To Fill", type=bpy.types.Object) + if TYPE_CHECKING: + desired_opening: Union[bpy.types.Object, None] + class BIMBooleanProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) booleans: CollectionProperty(name="Booleans", type=Boolean) active_boolean_index: IntProperty(name="Active Boolean Index") operator: EnumProperty( - items=[ - ("DIFFERENCE", "DIFFERENCE", ""), - ("INTERSECTION", "INTERSECTION", ""), - ("UNION", "UNION", ""), - ], + items=[(i, i, "") for i in get_args(OperatorType)], name="Operator", default="DIFFERENCE", ) + if TYPE_CHECKING: + is_editing: bool + booleans: bpy.types.bpy_prop_collection_idprop[Boolean] + active_boolean_index: int + operator: OperatorType + @property def active_boolean(self) -> Union[Boolean, None]: if self.booleans and 0 <= self.active_boolean_index < len(self.booleans): diff --git a/src/bonsai/bonsai/bim/module/void/ui.py b/src/bonsai/bonsai/bim/module/void/ui.py index d07266a56f..66701510e3 100644 --- a/src/bonsai/bonsai/bim/module/void/ui.py +++ b/src/bonsai/bonsai/bim/module/void/ui.py @@ -52,8 +52,6 @@ class BIM_PT_voids(Panel): if not VoidsData.is_loaded: VoidsData.load() - props = context.active_object.BIMObjectProperties - if len(context.selected_objects) >= 2: row = self.layout.row(align=True) op = row.operator("bim.add_opening", icon="ADD", text="Add Opening") @@ -126,13 +124,10 @@ class BIM_PT_booleans(Panel): @classmethod def poll(cls, context): return ( - context.active_object is not None - and context.active_object.type == "MESH" - and hasattr(context.active_object.data, "BIMMeshProperties") - and ( - context.active_object.data.BIMMeshProperties.ifc_definition_id - or context.active_object.data.BIMMeshProperties.ifc_boolean_id - ) + (obj := context.active_object) is not None + and isinstance(data := obj.data, bpy.types.Mesh) + and (mesh_props := tool.Geometry.get_mesh_props(data)) + and (mesh_props.ifc_definition_id or mesh_props.ifc_boolean_id) ) def draw(self, context): @@ -141,13 +136,13 @@ class BIM_PT_booleans(Panel): obj = context.active_object assert obj + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) - if not context.active_object.data: - return layout = self.layout - props = context.scene.BIMBooleanProperties + props = tool.Feature.get_boolean_props() - if context.active_object.data.BIMMeshProperties.ifc_definition_id: + if tool.Geometry.get_mesh_props(mesh).ifc_definition_id: row = layout.row(align=True) total_booleans = BooleansData.data["total_booleans"] manual_booleans = BooleansData.data["manual_booleans"] diff --git a/src/bonsai/bonsai/bim/module/web/data.py b/src/bonsai/bonsai/bim/module/web/data.py index fa94ad26d8..db939cadfa 100644 --- a/src/bonsai/bonsai/bim/module/web/data.py +++ b/src/bonsai/bonsai/bim/module/web/data.py @@ -39,9 +39,11 @@ class WebData: @classmethod def get_ifc_file_name(cls): - filename = os.path.basename(bpy.context.scene.BIMProperties.ifc_file) + props = tool.Blender.get_bim_props() + filename = os.path.basename(props.ifc_file) return filename @classmethod def get_is_dirty(cls): - return bpy.context.scene.BIMProperties.is_dirty + props = tool.Blender.get_bim_props() + return props.is_dirty diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 5ba1596203..52bd2cf42c 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -30,6 +30,8 @@ import webbrowser import ifcopenshell import bonsai.bim import bonsai.tool as tool +import bonsai.bim.handler +from bpy_extras.io_utils import ImportHelper from bonsai.bim import import_ifc from bonsai.bim.prop import StrProperty from bonsai.bim.ui import IFCFileSelector @@ -38,7 +40,10 @@ from mathutils import Vector, Euler from math import radians from pathlib import Path from collections import namedtuple -from typing import List, Iterable, Union +from typing import List, Iterable, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.prop import MultipleFileSelect class SetTab(bpy.types.Operator): @@ -130,7 +135,8 @@ class CloseBlendWarning(bpy.types.Operator): bl_label = "Close Blend Warning" def execute(self, context): - bpy.context.scene.BIMProperties.has_blend_warning = False + props = tool.Blender.get_bim_props() + props.has_blend_warning = False return {"FINISHED"} def draw(self, context): @@ -140,12 +146,11 @@ class CloseBlendWarning(bpy.types.Operator): return context.window_manager.invoke_props_dialog(self) -class SelectURIAttribute(bpy.types.Operator): +class SelectURIAttribute(bpy.types.Operator, ImportHelper): bl_idname = "bim.select_uri_attribute" bl_label = "Select URI Attribute" bl_options = {"REGISTER", "UNDO"} bl_description = "Select a local file" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") data_path: bpy.props.StringProperty(name="Data Path") use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) @@ -169,22 +174,20 @@ class SelectURIAttribute(bpy.types.Operator): attribute.string_value = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path) return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - -class BIM_OT_multiple_file_selector(bpy.types.Operator): +class BIM_OT_multiple_file_selector(bpy.types.Operator, ImportHelper): """Open Blender's file explorer to select one or multiple files.""" bl_idname = "bim.multiple_file_selector" bl_label = "Select File(s)" bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") files: bpy.props.CollectionProperty(name="File Path", type=bpy.types.OperatorFileListElement) filter_glob: bpy.props.StringProperty(default="*", options={"HIDDEN"}) filepath: bpy.props.StringProperty(subtype="FILE_PATH") + if TYPE_CHECKING: + file_props: MultipleFileSelect + @classmethod def poll(cls, context): return getattr(context, "file_props", None) is not None @@ -198,80 +201,59 @@ class BIM_OT_multiple_file_selector(bpy.types.Operator): def invoke(self, context, event): self.file_props = context.file_props - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) -class SelectIfcFile(bpy.types.Operator, IFCFileSelector): +class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_idname = "bim.select_ifc_file" bl_label = "Select IFC File" bl_options = {"REGISTER", "UNDO"} bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + filename_ext = ".ifc" def execute(self, context): if self.is_existing_ifc_file(): - context.scene.BIMProperties.ifc_file = self.get_filepath() + props = tool.Blender.get_bim_props() + props.ifc_file = self.get_filepath() + bonsai.bim.handler.loadIfcStore(bpy.context.scene) + tool.Blender.clear_undo_history() return {"FINISHED"} def invoke(self, context, event): - filepath = Path(context.scene.BIMProperties.ifc_file) + props = tool.Blender.get_bim_props() + filepath = Path(props.ifc_file) res = tool.Blender.operator_invoke_filepath_hotkeys(self, context, event, filepath) if res is not None: return res - - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) -class SelectDataDir(bpy.types.Operator): - bl_idname = "bim.select_data_dir" - bl_label = "Select Data Directory" +class SelectDir(bpy.types.Operator, ImportHelper): + bl_idname = "bim.select_dir" + bl_label = "Select Directory" bl_options = {"REGISTER", "UNDO"} - bl_description = "Select the directory that contains all IFC data es. PSet, styles, etc..." - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + bl_description = "Open a file browser to choose the directory" + data_path: bpy.props.StringProperty(name="Data Path") def execute(self, context): - context.scene.BIMProperties.data_dir = os.path.dirname(self.filepath) + crumbs = self.data_path.split(".") + if crumbs[0] == "preferences": + crumbs.pop(0) + data = tool.Blender.get_addon_preferences() + else: + data = context + while crumbs: + crumb = crumbs.pop(0) + if crumbs: + data = getattr(data, crumb) + else: + setattr(data, crumb, os.path.dirname(self.filepath)) return {"FINISHED"} def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - - -class SelectCacheDir(bpy.types.Operator): - bl_idname = "bim.select_cache_dir" - bl_label = "Select Cache Directory" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Select the directory that contains HDF5 cache files" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") - - def execute(self, context): - context.scene.BIMProperties.cache_dir = os.path.dirname(self.filepath) - return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - - -class SelectSchemaDir(bpy.types.Operator): - bl_idname = "bim.select_schema_dir" - bl_label = "Select Schema Directory" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Select the directory containing the IFC schema specification" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") - - def execute(self, context): - context.scene.BIMProperties.schema_dir = os.path.dirname(self.filepath) - return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} + return ImportHelper.invoke(self, context, event) class FileAssociate(bpy.types.Operator): @@ -595,7 +577,8 @@ class BIM_OT_add_section_plane(bpy.types.Operator): backfacing.location = mix_backfacing.location + Vector((-200, 200)) emission = nodes.new(type="ShaderNodeEmission") - emission.inputs[0].default_value = list(context.scene.BIMProperties.section_plane_colour) + [1] + props = tool.Blender.get_bim_props() + emission.inputs[0].default_value = list(props.section_plane_colour) + [1] emission.location = mix_backfacing.location - Vector((200, 150)) cut_obj = nodes.new(type="ShaderNodeTexCoord") @@ -659,7 +642,8 @@ class BIM_OT_add_section_plane(bpy.types.Operator): material = bpy.data.materials.new("Section Override") material.use_nodes = True - if context.scene.BIMProperties.should_section_selected_objects: + props = tool.Blender.get_bim_props() + if props.should_section_selected_objects: objects = list(context.selected_objects) else: objects = list(context.visible_objects) @@ -682,8 +666,17 @@ class BIM_OT_add_section_plane(bpy.types.Operator): material.use_nodes = True if material.node_tree.nodes.get("Section Override"): continue - material.blend_method = "HASHED" - material.shadow_method = "HASHED" + # In EEVEE rendering engine, `blend_mode` is deprecated and replaced by `surface_render_method` + # https://developer.blender.org/docs/release_notes/4.2/eevee_migration/#materials + if hasattr(material, "surface_render_method"): + material.surface_render_method = "DITHERED" + else: + material.blend_method = "HASHED" + + # TODO: Find an alternative to `shadow_method` for EEVEE engine + if hasattr(material, "shadow_method"): + material.shadow_method = "HASHED" + material_output = tool.Blender.get_material_node(material, "OUTPUT_MATERIAL", {"is_active_output": True}) if not material_output: continue @@ -713,13 +706,14 @@ class BIM_OT_remove_section_plane(bpy.types.Operator): ( n for n in section_override.nodes - if isinstance(n, bpy.types.ShaderNodeTexCoord) and n.object.name == name + if isinstance(n, bpy.types.ShaderNodeTexCoord) and n.object and n.object.name == name ), None, ) if tex_coords is not None: section_compare = tex_coords.outputs["Object"].links[0].to_node - if section_compare.inputs[0].links: + + if section_compare.inputs[0].links and section_compare.outputs[0].links: previous_section_compare = section_compare.inputs[0].links[0].from_node next_section_compare = section_compare.outputs[0].links[0].to_node section_override.links.new(previous_section_compare.outputs[0], next_section_compare.inputs[0]) @@ -764,13 +758,13 @@ class BIM_OT_remove_section_plane(bpy.types.Operator): bpy.ops.object.delete() -class ReloadIfcFile(bpy.types.Operator, tool.Ifc.Operator): +class ReloadIfcFile(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.reload_ifc_file" bl_label = "Reload IFC File" bl_options = {"REGISTER", "UNDO"} bl_description = "Reload an updated IFC file" - filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) + filename_ext = ".ifc" def _execute(self, context): import ifcdiff @@ -816,7 +810,9 @@ class ReloadIfcFile(bpy.types.Operator, tool.Ifc.Operator): logger = logging.getLogger("ImportIFC") path_log = tool.Blender.get_data_dir_path("process.log") if not os.access(path_log.parent, os.W_OK): - path_log = os.path.join(tempfile.mkdtemp(), "process.log") + path_log = os.path.join( + tempfile.mkdtemp(dir=tool.Blender.get_addon_preferences().tmp_dir or None), "process.log" + ) logging.basicConfig( filename=path_log, filemode="a", @@ -832,13 +828,10 @@ class ReloadIfcFile(bpy.types.Operator, tool.Ifc.Operator): settings.logger.info("Import finished in {:.2f} seconds".format(time.time() - start)) print("Import finished in {:.2f} seconds".format(time.time() - start)) - context.scene.BIMProperties.ifc_file = self.filepath + bim_props = tool.Blender.get_bim_props() + bim_props.ifc_file = self.filepath return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class AddIfcFile(bpy.types.Operator): bl_idname = "bim.add_ifc_file" @@ -846,7 +839,8 @@ class AddIfcFile(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.DocProperties.ifc_files.add() + props = tool.Drawing.get_document_props() + props.ifc_files.add() return {"FINISHED"} @@ -857,7 +851,8 @@ class RemoveIfcFile(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - context.scene.DocProperties.ifc_files.remove(self.index) + props = tool.Drawing.get_document_props() + props.ifc_files.remove(self.index) return {"FINISHED"} @@ -867,8 +862,11 @@ class FetchObjectPassport(bpy.types.Operator): def execute(self, context): # TODO: this is dead code, awaiting reimplementation. See #1222. - for reference in context.active_object.BIMObjectProperties.document_references: - reference = context.scene.BIMProperties.document_references[reference.name] + obj = context.active_object + props = tool.Blender.get_object_bim_props(obj) + for reference in props.document_references: + bim_props = tool.Blender.get_bim_props() + reference = bim_props.document_references[reference.name] if reference.location[-6:] == ".blend": self.fetch_blender(reference, context) return {"FINISHED"} @@ -1078,7 +1076,8 @@ class ClippingPlaneCutWithCappings(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - cutting_planes = [p.obj for p in context.scene.BIMProjectProperties.clipping_planes] + props = tool.Project.get_project_props() + cutting_planes = [obj for p in props.clipping_planes if (obj := p.obj)] if not cutting_planes: self.report({"INFO"}, "No cutting planes found.") return {"FINISHED"} @@ -1088,7 +1087,7 @@ class ClippingPlaneCutWithCappings(bpy.types.Operator): objects_processed, t0 = 0, time.time() wm.progress_begin(0, len(context.selected_objects)) for obj_i, obj in enumerate(context.selected_objects): - if obj.type != "MESH": + if not isinstance((mesh := obj.data), bpy.types.Mesh): continue if obj in cutting_planes: @@ -1100,7 +1099,6 @@ class ClippingPlaneCutWithCappings(bpy.types.Operator): ws_to_ls = obj.matrix_world.inverted() rotation = ws_to_ls.to_quaternion() - mesh = obj.data bm = tool.Blender.get_bmesh_for_mesh(mesh) object_changed = False @@ -1124,7 +1122,7 @@ class ClippingPlaneCutWithCappings(bpy.types.Operator): # don't swap mesh if it wasn't affected by any of the cutting planes if object_changed: temp_mesh = bpy.data.meshes.new("temp_cut") - temp_mesh.BIMMeshProperties.replaced_mesh = mesh + tool.Geometry.get_mesh_props(temp_mesh).replaced_mesh = mesh for material in mesh.materials: temp_mesh.materials.append(material) obj.data = temp_mesh @@ -1181,9 +1179,10 @@ class RevertClippingPlaneCut(bpy.types.Operator): self.report({"INFO"}, f"{objects_processed} processed - {time.time()-t0:.3f} sec") return {"FINISHED"} - def revert_object_mesh(self, obj): + def revert_object_mesh(self, obj: bpy.types.Object) -> None: mesh = obj.data - replaced_mesh = mesh.BIMMeshProperties.replaced_mesh + assert isinstance(mesh, bpy.types.Mesh) + replaced_mesh = tool.Geometry.get_mesh_props(mesh).replaced_mesh if replaced_mesh: obj.data = replaced_mesh tool.Blender.remove_data_block(mesh, do_unlink=False) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 8c790444f1..af378bb8f4 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -119,24 +119,22 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) -> def update_schema_dir(self: "BIMProperties", context: bpy.types.Context) -> None: import bonsai.bim.schema - bonsai.bim.schema.ifc.schema_dir = context.scene.BIMProperties.schema_dir + bim_props = tool.Blender.get_bim_props() + bonsai.bim.schema.ifc.schema_dir = bim_props.schema_dir def update_data_dir(self: "BIMProperties", context: bpy.types.Context) -> None: import bonsai.bim.schema - bonsai.bim.schema.ifc.data_dir = context.scene.BIMProperties.data_dir + bim_props = tool.Blender.get_bim_props() + bonsai.bim.schema.ifc.data_dir = bim_props.data_dir def update_cache_dir(self: "BIMProperties", context: bpy.types.Context) -> None: import bonsai.bim.schema - bonsai.bim.schema.ifc.cache_dir = context.scene.BIMProperties.cache_dir - - -def update_ifc_file(self: "BIMProperties", context: bpy.types.Context) -> None: - if context.scene.BIMProperties.ifc_file: - bonsai.bim.handler.loadIfcStore(context.scene) + bim_props = tool.Blender.get_bim_props() + bonsai.bim.schema.ifc.cache_dir = bim_props.cache_dir def update_section_color(self: "BIMProperties", context: bpy.types.Context) -> None: @@ -230,15 +228,16 @@ def update_attribute_value(self: "Attribute", context: bpy.types.Context) -> Non def update_is_null(self: "Attribute", context: bpy.types.Context) -> None: - if not self.is_null: - return - self.string_value = "" - self.int_value = 0 - self.float_value = 0 - self.length_value = 0 - self.bool_value = False - if self.is_null is not True: - self.is_null = True + if self.is_null: + if self.data_type != "enum" and self.get_value() != (default := self.get_value_default()): + self.set_value(default) + if self.is_null is not True: + self.is_null = True + if self.update: + update = globals() + for name in self.update.split("."): + update = update[name] if isinstance(update, dict) else getattr(update, name) + update(self, context) def set_int_value(self: "Attribute", new_value: int) -> None: @@ -268,12 +267,16 @@ def set_length_value(self: "Attribute", value: float) -> None: def get_display_name(self: "Attribute") -> str: + DISPLAY_UNIT_TYPES = ("AREA", "VOLUME", "FORCE") name = self.name - if not self.special_type or self.special_type == "LENGTH": + if not self.special_type or self.special_type not in DISPLAY_UNIT_TYPES: return name unit_type = f"{self.special_type}UNIT" project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), unit_type) + if not project_unit: + return name + unit_symbol = ifcopenshell.util.unit.get_unit_symbol(project_unit) return f"{name}, {unit_symbol}" @@ -326,13 +329,35 @@ class Attribute(PropertyGroup): value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound") special_type: StringProperty(name="Special Value Type", default="") metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute") + update: StringProperty(name="Update", description="Custom update function to be executed") if TYPE_CHECKING: name: str + display_name: str description: str ifc_class: str data_type: AttributeDataType special_type: AttributeSpecialType + string_value: str + bool_value: bool + int_value: int + float_value: float + length_value: float + enum_items: str + enum_descriptions: bpy.types.bpy_prop_collection_idprop[StrProperty] + enum_value: str + filepath_value: MultipleFileSelect + filter_glob: str + is_null: bool + is_optional: bool + is_uri: bool + is_selected: bool + value_min: float + value_min_constraint: bool + value_max: float + value_max_constraint: bool + metadata: str + update: str def get_value(self) -> Union[str, float, int, bool, None]: if self.is_optional and self.is_null: @@ -437,6 +462,13 @@ class BIMAreaProperties(PropertyGroup): active_tab: BoolProperty(default=True, name="Active Tab") inactive_tab: BoolProperty(default=False, name="Inactive Tab") + if TYPE_CHECKING: + tab: str + previous_tab: str + alt_tab: str + active_tab: bool + inactive_tab: bool + # BIMAreaProperties exists per area and is setup on load post. However, for new # or temporary screens, they may not be setup yet, so this global tab @@ -447,6 +479,11 @@ class BIMTabProperties(PropertyGroup): active_tab: BoolProperty(default=True, name="Active Tab") inactive_tab: BoolProperty(default=False, name="Inactive Tab") + if TYPE_CHECKING: + tab: str + active_tab: bool + inactive_tab: bool + class BIMProperties(PropertyGroup): is_dirty: BoolProperty(name="Is Dirty", default=False) @@ -463,7 +500,7 @@ class BIMProperties(PropertyGroup): ) has_blend_warning: BoolProperty(name="Has Blend Warning", default=False) pset_dir: StringProperty(default=os.path.join("psets") + os.path.sep, name="Default Psets Directory") - ifc_file: StringProperty(name="IFC File", update=update_ifc_file) + ifc_file: StringProperty(name="IFC File") last_transaction: StringProperty(name="Last Transaction") should_section_selected_objects: BoolProperty(name="Section Selected Objects", default=False) section_plane_colour: FloatVectorProperty( @@ -515,6 +552,21 @@ class BIMProperties(PropertyGroup): name="IFC Volume Unit", ) + if TYPE_CHECKING: + is_dirty: bool + schema_dir: str + data_dir: str + cache_dir: str + has_blend_warning: bool + pset_dir: str + ifc_file: str + last_transaction: str + should_section_selected_objects: bool + section_plane_colour: tuple[float, float, float] + section_line_decorator_width: float + area_unit: str + volume_unit: str + class IfcParameter(PropertyGroup): name: StringProperty(name="Name") @@ -523,6 +575,13 @@ class IfcParameter(PropertyGroup): value: FloatProperty(name="Value") # For now, only floats type: StringProperty(name="Type") + if TYPE_CHECKING: + name: str + step_id: int + index: int + value: float + type: str + class PsetQto(PropertyGroup): name: StringProperty(name="Name") @@ -530,20 +589,32 @@ class PsetQto(PropertyGroup): is_expanded: BoolProperty(name="Is Expanded", default=True) is_editable: BoolProperty(name="Is Editable") + if TYPE_CHECKING: + properties: bpy.types.bpy_prop_collection_idprop[Attribute] + is_expanded: bool + is_editable: bool + class GlobalId(PropertyGroup): name: StringProperty(name="Name") + ifc_definition_id: IntProperty(name="IFC Definition ID") class BIMCollectionProperties(PropertyGroup): obj: PointerProperty(type=bpy.types.Object) + if TYPE_CHECKING: + obj: Union[bpy.types.Object, None] + + +BlenderOffsetType = Literal["NONE", "OBJECT_PLACEMENT", "CARTESIAN_POINT", "NOT_APPLICABLE"] + class BIMObjectProperties(PropertyGroup): collection: PointerProperty(type=bpy.types.Collection) ifc_definition_id: IntProperty(name="IFC Definition ID") blender_offset_type: EnumProperty( - items=[(o, o, "") for o in ["NONE", "OBJECT_PLACEMENT", "CARTESIAN_POINT", "NOT_APPLICABLE"]], + items=[(o, o, "") for o in get_args(BlenderOffsetType)], name="Blender Offset", default="NONE", ) @@ -553,6 +624,16 @@ class BIMObjectProperties(PropertyGroup): location_checksum: StringProperty(name="Location Checksum") rotation_checksum: StringProperty(name="Rotation Checksum") + if TYPE_CHECKING: + collection: Union[bpy.types.Collection, None] + ifc_definition_id: int + blender_offset_type: BlenderOffsetType + cartesian_point_offset: str + is_reassigning_class: bool + is_renaming: bool + location_checksum: str + rotation_checksum: str + def get_profiles(self: "BIMMeshProperties", context: bpy.types.Context): from bonsai.bim.module.model.data import ItemData @@ -562,6 +643,9 @@ def get_profiles(self: "BIMMeshProperties", context: bpy.types.Context): return ItemData.data["profiles_enum"] +SubshapeType = Literal["-", "PROFILE", "AXIS"] + + class BIMMeshProperties(PropertyGroup): ifc_definition_id: IntProperty(name="IFC Definition ID") ifc_boolean_id: IntProperty(name="IFC Boolean ID") @@ -570,7 +654,7 @@ class BIMMeshProperties(PropertyGroup): is_native: BoolProperty(name="Is Native", default=False) is_swept_solid: BoolProperty(name="Is Swept Solid") is_parametric: BoolProperty(name="Is Parametric", default=False) - subshape_type: EnumProperty(name="Subshape Type", items=[(i, i, "") for i in ("-", "PROFILE", "AXIS")]) + subshape_type: EnumProperty(name="Subshape Type", items=[(i, i, "") for i in get_args(SubshapeType)]) ifc_parameters: CollectionProperty(name="IFC Parameters", type=IfcParameter) item_attributes: CollectionProperty(name="Item Attributes", type=Attribute) item_profile: EnumProperty(name="Item Profile", items=get_profiles) @@ -578,6 +662,22 @@ class BIMMeshProperties(PropertyGroup): mesh_checksum: StringProperty(name="Mesh Checksum", default="") replaced_mesh: PointerProperty(type=bpy.types.Mesh, description="Original mesh to revert section cutaway") + if TYPE_CHECKING: + ifc_definition_id: int + ifc_boolean_id: int + obj: Union[bpy.types.Object, None] + has_openings_applied: bool + is_native: bool + is_swept_solid: bool + is_parametric: bool + subshape_type: SubshapeType + ifc_parameters: bpy.types.bpy_prop_collection_idprop[IfcParameter] + item_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + item_profile: str + material_checksum: str + mesh_checksum: str + replaced_mesh: Union[bpy.types.Mesh, None] + class BIMFacet(PropertyGroup): name: StringProperty(name="Name") @@ -597,16 +697,30 @@ class BIMFacet(PropertyGroup): ], ) + if TYPE_CHECKING: + pset: str + value: str + type: str + comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="] + class BIMFilterGroup(PropertyGroup): filters: CollectionProperty(type=BIMFacet, name="filters") + if TYPE_CHECKING: + filters: bpy.types.bpy_prop_collection_idprop[BIMFacet] + class BIMSnapGroups(PropertyGroup): object: BoolProperty(name="Object", default=True) polyline: BoolProperty(name="Polyline", default=True) measure: BoolProperty(name="Measure", default=True) + if TYPE_CHECKING: + object: bool + polyline: bool + measure: bool + class BIMSnapProperties(PropertyGroup): vertex: BoolProperty(name="Vertex", default=True) @@ -614,3 +728,10 @@ class BIMSnapProperties(PropertyGroup): edge_center: BoolProperty(name="Edge Center", default=True) edge_intersection: BoolProperty(name="Edge Intersection", default=True) face: BoolProperty(name="Face", default=True) + + if TYPE_CHECKING: + vertex: bool + edge: bool + edge_center: bool + edge_intersection: bool + face: bool diff --git a/src/bonsai/bonsai/bim/schema.py b/src/bonsai/bonsai/bim/schema.py index 6d67d2e20a..cae1e32146 100644 --- a/src/bonsai/bonsai/bim/schema.py +++ b/src/bonsai/bonsai/bim/schema.py @@ -19,6 +19,8 @@ import ifcopenshell import ifcopenshell.util.pset import bonsai.tool as tool +import bpy +import bpy_restrict_state class IfcSchema: @@ -47,10 +49,16 @@ class IfcSchema: self.psetqto.get_applicable.cache_clear() self.psetqto.get_applicable_names.cache_clear() self.psetqto.get_by_name.cache_clear() + + # During register we cannot access the context either way. + if isinstance(bpy.context, bpy_restrict_state._RestrictContext): + return for path in tool.Blender.get_data_dir_paths("pset", "*.ifc"): self.psetqto.templates.append(ifcopenshell.open(path)) +# TODO: do we really need to load it on module import? +# Loading it on IFC load should be enough. ifc = IfcSchema() diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 8326ab1b66..a6e8d2b25f 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -19,6 +19,7 @@ import os import bpy import platform +import bonsai.bim.helper from pathlib import Path from bpy.types import Panel from bpy.props import StringProperty, IntProperty, BoolProperty @@ -34,12 +35,14 @@ import bonsai.bim import bonsai.tool as tool from ifcopenshell.util.file import IfcHeaderExtractor from bonsai.bim.prop import Attribute -from typing import Optional +from typing import Optional, TYPE_CHECKING class IFCFileSelector: - filepath: str - use_relative_path: bool + # Avoid overriding blender prop annotations at runtime. + if TYPE_CHECKING: + filepath: str + use_relative_path: bool def is_existing_ifc_file(self, filepath: Optional[str] = None) -> bool: """Check if file path exists and if it's an IFC file. @@ -66,7 +69,7 @@ class IFCFileSelector: if self.use_relative_path: filepath = filepath.relative_to(bpy.path.abspath("//")) - return filepath.as_posix() + return filepath.as_posix().replace("\\", "/") def draw(self, context: bpy.types.Context) -> None: assert isinstance(context.space_data, bpy.types.SpaceFileBrowser) @@ -120,7 +123,7 @@ class BIM_PT_section_plane(Panel): def draw(self, context): layout = self.layout layout.use_property_split = True - props = context.scene.BIMProperties + props = tool.Blender.get_bim_props() layout.prop(props, "should_section_selected_objects") layout.prop(props, "section_plane_colour") @@ -147,7 +150,7 @@ class BIM_PT_section_with_cappings(Panel): row.operator("bim.clipping_plane_cut_with_cappings", icon="XRAY", text="Cut") row.operator("bim.revert_clipping_plane_cut", icon="FILE_REFRESH", text="Revert Cut") - props = context.scene.BIMProjectProperties + props = tool.Project.get_project_props() box = layout.box() header = box.row(align=True) header.label(text="Clipping Planes") @@ -245,6 +248,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="If disabled, the toolbar will only load when an IFC model is active", ) should_play_chaching_sound: BoolProperty(name="Play A Cha-Ching Sound When Project Costs Updates", default=False) + tmp_dir: StringProperty( + name="Temporary Directory", + description="Path to create and store temporary files. If left blank, a system default will be used.", + ) spatial_elements_unselectable: BoolProperty( name="Make Spatial Elements Unselectable By Default", default=True, @@ -298,7 +305,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): size=4, description="Color of background overlays", ) - opening_focus_opacity: bpy.props.IntProperty( default=100, min=0, @@ -308,7 +314,29 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): description="When modifying openings, other elements of the model will display with some transparency.\n0 is fully transparent and 100 is fully opaque", ) - def draw(self, context): + if TYPE_CHECKING: + svg2pdf_command: str + svg2dxf_command: str + svg_command: str + layout_svg_command: str + pdf_command: str + spreadsheet_command: str + should_hide_empty_props: bool + should_setup_workspace: bool + activate_workspace: bool + should_setup_toolbar: bool + should_play_chaching_sound: bool + spatial_elements_unselectable: bool + tmp_dir: str + decorations_colour: tuple[float, float, float, float] + decorator_color_selected: tuple[float, float, float, float] + decorator_color_unselected: tuple[float, float, float, float] + decorator_color_special: tuple[float, float, float, float] + decorator_color_error: tuple[float, float, float, float] + decorator_color_background: tuple[float, float, float, float] + opening_focus_opacity: int + + def draw(self, context: bpy.types.Context) -> None: layout = self.layout row = layout.row() @@ -329,7 +357,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): bonsai.bim.helper.draw_expandable_panel(self.layout, context, "Drawing", self.draw_drawing_settings) bonsai.bim.helper.draw_expandable_panel(self.layout, context, "Openings", self.draw_openings_settings) - def draw_commands(self, layout, context): + def draw_commands(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "svg2pdf_command") layout.prop(self, "svg2dxf_command") layout.prop(self, "svg_command") @@ -337,15 +365,16 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.prop(self, "pdf_command") layout.prop(self, "spreadsheet_command") - def draw_misc_settings(self, layout, context): + def draw_misc_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "should_hide_empty_props") layout.prop(self, "should_setup_workspace") layout.prop(self, "activate_workspace") layout.prop(self, "should_setup_toolbar") layout.prop(self, "should_play_chaching_sound") layout.prop(self, "spatial_elements_unselectable") - layout.prop(context.scene.BIMProjectProperties, "should_disable_undo_on_save") - layout.prop(context.scene.BIMProjectProperties, "should_stream") + props = tool.Project.get_project_props() + layout.prop(props, "should_disable_undo_on_save") + layout.prop(props, "should_stream") def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: props = tool.Model.get_model_props() @@ -353,34 +382,41 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): if props.occurrence_name_style == "CUSTOM": layout.prop(props, "occurrence_name_function") - def draw_directories(self, layout, context): + def draw_directories(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + props = tool.Blender.get_bim_props() row = layout.row(align=True) - row.prop(context.scene.BIMProperties, "data_dir") - row.operator("bim.select_data_dir", icon="FILE_FOLDER", text="") + row.prop(props, "data_dir") + row.operator("bim.select_dir", icon="FILE_FOLDER", text="").data_path = "scene.BIMProperties.data_dir" row = layout.row(align=True) - row.prop(context.scene.BIMProperties, "cache_dir") - row.operator("bim.select_cache_dir", icon="FILE_FOLDER", text="") + row.prop(props, "cache_dir") + row.operator("bim.select_dir", icon="FILE_FOLDER", text="").data_path = "scene.BIMProperties.cache_dir" - def draw_drawing_settings(self, layout, context): - layout.prop(context.scene.BIMProperties, "pset_dir") - layout.prop(context.scene.DocProperties, "sheets_dir") - layout.prop(context.scene.DocProperties, "layouts_dir") - layout.prop(context.scene.DocProperties, "titleblocks_dir") - layout.prop(context.scene.DocProperties, "drawings_dir") - layout.prop(context.scene.DocProperties, "stylesheet_path") - layout.prop(context.scene.DocProperties, "schedules_stylesheet_path") - layout.prop(context.scene.DocProperties, "markers_path") - layout.prop(context.scene.DocProperties, "symbols_path") - layout.prop(context.scene.DocProperties, "patterns_path") - layout.prop(context.scene.DocProperties, "shadingstyles_path") - layout.prop(context.scene.DocProperties, "shadingstyle_default") + row = layout.row(align=True) + row.prop(self, "tmp_dir") + row.operator("bim.select_dir", icon="FILE_FOLDER", text="").data_path = "preferences.tmp_dir" + + def draw_drawing_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + props = tool.Blender.get_bim_props() + layout.prop(props, "pset_dir") + dprops = tool.Drawing.get_document_props() + layout.prop(dprops, "sheets_dir") + layout.prop(dprops, "layouts_dir") + layout.prop(dprops, "titleblocks_dir") + layout.prop(dprops, "drawings_dir") + layout.prop(dprops, "stylesheet_path") + layout.prop(dprops, "schedules_stylesheet_path") + layout.prop(dprops, "markers_path") + layout.prop(dprops, "symbols_path") + layout.prop(dprops, "patterns_path") + layout.prop(dprops, "shadingstyles_path") + layout.prop(dprops, "shadingstyle_default") row = layout.row() - row.prop(context.scene.DocProperties, "drawing_font") - row.prop(context.scene.DocProperties, "magic_font_scale") - layout.prop(context.scene.DocProperties, "imperial_precision") - layout.prop(context.scene.DocProperties, "tolerance") - layout.prop(context.scene.DocProperties, "classes_to_wireframe") + row.prop(dprops, "drawing_font") + row.prop(dprops, "magic_font_scale") + layout.prop(dprops, "imperial_precision") + layout.prop(dprops, "tolerance") + layout.prop(dprops, "classes_to_wireframe") def draw_decorator_colors(self, layout, context): layout.row().prop(self, "decorations_colour") @@ -478,8 +514,8 @@ class BIM_PT_tabs(Panel): if not tool.Ifc.get(): return - props = context.scene.BIMProperties - if props.has_blend_warning: + bim_props = tool.Blender.get_bim_props() + if bim_props.has_blend_warning: box = self.layout.box() box.alert = True row = box.row(align=True) @@ -488,9 +524,10 @@ class BIM_PT_tabs(Panel): op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files" row.operator("bim.close_blend_warning", text="", icon="CANCEL") - if context.mode == "OBJECT" and context.scene.BIMGeometryProperties.mode in ("OBJECT", "ITEM"): + gprops = tool.Geometry.get_geometry_props() + if context.mode == "OBJECT" and gprops.mode in ("OBJECT", "ITEM"): pass - elif context.mode.startswith("EDIT") and context.scene.BIMGeometryProperties.mode == "EDIT": + elif context.mode.startswith("EDIT") and gprops.mode == "EDIT": pass else: box = self.layout.box() @@ -524,11 +561,11 @@ class BIM_PT_tab_new_project_wizard(Panel): def poll(cls, context): if not tool.Blender.is_tab(context, "PROJECT"): return False - props = context.scene.BIMProperties - pprops = context.scene.BIMProjectProperties + bim_props = tool.Blender.get_bim_props() + pprops = tool.Project.get_project_props() if pprops.is_loading: return False - elif tool.Ifc.get() or props.ifc_file: + elif tool.Ifc.get() or bim_props.ifc_file: return False return True @@ -546,11 +583,11 @@ class BIM_PT_tab_project_info(Panel): def poll(cls, context): if not tool.Blender.is_tab(context, "PROJECT"): return False - props = context.scene.BIMProperties - pprops = context.scene.BIMProjectProperties + bim_props = tool.Blender.get_bim_props() + pprops = tool.Project.get_project_props() if pprops.is_loading: return True - elif tool.Ifc.get() or props.ifc_file: + elif tool.Ifc.get() or bim_props.ifc_file: return True return False @@ -846,6 +883,7 @@ class BIM_PT_tab_object_metadata(Panel): @classmethod def poll(cls, context): + props = tool.Project.get_project_props() return ( tool.Blender.is_tab(context, "OBJECT") and tool.Ifc.get() @@ -854,7 +892,7 @@ class BIM_PT_tab_object_metadata(Panel): and ( obj.type != "EMPTY" or not obj.instance_collection - or not any(l.empty_handle == obj for l in context.scene.BIMProjectProperties.links) + or not any(l.empty_handle == obj for l in props.links) ) ) @@ -1223,7 +1261,7 @@ class BIM_PT_decorators_overlay(Panel): view = context.space_data overlay = view.overlay - georeference_props = bpy.context.scene.BIMGeoreferenceProperties + georeference_props = tool.Georeference.get_georeference_props() aggregate_props = bpy.context.scene.BIMAggregateProperties nest_props = bpy.context.scene.BIMNestProperties model_props = tool.Model.get_model_props() diff --git a/src/bonsai/bonsai/core/brick.py b/src/bonsai/bonsai/core/brick.py index 4d537a6751..27b82d43f8 100644 --- a/src/bonsai/bonsai/core/brick.py +++ b/src/bonsai/bonsai/core/brick.py @@ -16,8 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Union -def load_brick_project(brick, filepath=None, brick_root=None): +if TYPE_CHECKING: + import bpy + import ifcopenshell + import bonsai.tool as tool + + +def load_brick_project(brick: tool.Brick, filepath: str, brick_root: str) -> None: brick.load_brick_file(filepath) brick.import_brick_classes(brick_root) brick.import_brick_classes(brick_root, split_screen=True) @@ -25,7 +33,7 @@ def load_brick_project(brick, filepath=None, brick_root=None): brick.set_active_brick_class(brick_root, split_screen=True) -def new_brick_file(brick, brick_root=None): +def new_brick_file(brick: tool.Brick, brick_root: str) -> None: brick.new_brick_file() brick.import_brick_classes(brick_root) brick.import_brick_classes(brick_root, split_screen=True) @@ -33,7 +41,7 @@ def new_brick_file(brick, brick_root=None): brick.set_active_brick_class(brick_root, split_screen=True) -def view_brick_class(brick, brick_class=None, split_screen=False): +def view_brick_class(brick: tool.Brick, brick_class: str, split_screen: bool = False) -> None: brick.add_brick_breadcrumb(split_screen=split_screen) brick.clear_brick_browser(split_screen=split_screen) brick.import_brick_classes(brick_class, split_screen=split_screen) @@ -41,13 +49,13 @@ def view_brick_class(brick, brick_class=None, split_screen=False): brick.set_active_brick_class(brick_class, split_screen=split_screen) -def view_brick_item(brick, item=None, split_screen=False): +def view_brick_item(brick: tool.Brick, item: str, split_screen: bool = False) -> None: brick_class = brick.get_item_class(item) brick.run_view_brick_class(brick_class=brick_class, split_screen=split_screen) brick.select_browser_item(item, split_screen=split_screen) -def rewind_brick_class(brick, split_screen=False): +def rewind_brick_class(brick: tool.Brick, split_screen: bool = False) -> None: previous_class = brick.pop_brick_breadcrumb(split_screen=split_screen) brick.clear_brick_browser(split_screen=split_screen) brick.import_brick_classes(previous_class, split_screen=split_screen) @@ -55,7 +63,7 @@ def rewind_brick_class(brick, split_screen=False): brick.set_active_brick_class(previous_class, split_screen=split_screen) -def close_brick_project(brick): +def close_brick_project(brick: tool.Brick) -> None: brick.clear_project() brick.clear_brick_browser() brick.clear_brick_browser(split_screen=True) @@ -63,13 +71,19 @@ def close_brick_project(brick): brick.clear_breadcrumbs(split_screen=True) -def convert_brick_project(ifc, brick): +def convert_brick_project(ifc: tool.Ifc, brick: tool.Brick) -> None: library = ifc.run("library.add_library", name=brick.get_brick_path_name()) if ifc.get_schema() != "IFC2X3": ifc.run("library.edit_library", library=library, attributes={"Location": brick.get_brick_path()}) -def assign_brick_reference(ifc, brick, element=None, library=None, brick_uri=None): +def assign_brick_reference( + ifc: tool.Ifc, + brick: tool.Brick, + element: ifcopenshell.entity_instance, + library: ifcopenshell.entity_instance, + brick_uri: str, +) -> None: reference = brick.get_library_brick_reference(library, brick_uri) if not reference: reference = ifc.run("library.add_reference", library=library) @@ -81,7 +95,15 @@ def assign_brick_reference(ifc, brick, element=None, library=None, brick_uri=Non brick.add_brickifc_reference(brick_uri, element, project) -def add_brick(ifc, brick, element=None, namespace=None, brick_class=None, library=None, label="Unnamed"): +def add_brick( + ifc: tool.Ifc, + brick: tool.Brick, + element: Union[ifcopenshell.entity_instance, None], + namespace: str, + brick_class: str, + library: Union[str, None], + label: str = "Unnamed", +) -> None: if element: brick_uri = brick.add_brick_from_element(element, namespace, brick_class) if library: @@ -91,12 +113,12 @@ def add_brick(ifc, brick, element=None, namespace=None, brick_class=None, librar brick.run_refresh_brick_viewer() -def add_brick_relation(brick, brick_uri=None, predicate=None, object=None): +def add_brick_relation(brick: tool.Brick, brick_uri: str, predicate: str, object: str) -> None: brick.add_relation(brick_uri, predicate, object) brick.run_refresh_brick_viewer() -def convert_ifc_to_brick(brick, namespace=None, library=None): +def convert_ifc_to_brick(brick: tool.Brick, namespace: str, library: Union[ifcopenshell.entity_instance, None]) -> None: # convert spaces to brick spaces = brick.get_convertable_brick_spaces() space_uris = {} @@ -141,14 +163,14 @@ def convert_ifc_to_brick(brick, namespace=None, library=None): brick.run_refresh_brick_viewer() -def refresh_brick_viewer(brick): +def refresh_brick_viewer(brick: tool.Brick) -> None: brick.run_view_brick_class(brick_class=brick.get_active_brick_class()) brick.pop_brick_breadcrumb() brick.run_view_brick_class(brick_class=brick.get_active_brick_class(split_screen=True), split_screen=True) brick.pop_brick_breadcrumb(split_screen=True) -def remove_brick(ifc, brick, library=None, brick_uri=None): +def remove_brick(ifc: tool.Ifc, brick: tool.Brick, library: ifcopenshell.entity_instance, brick_uri: str) -> None: if library: reference = brick.get_library_brick_reference(library, brick_uri) if reference: @@ -157,18 +179,18 @@ def remove_brick(ifc, brick, library=None, brick_uri=None): brick.run_refresh_brick_viewer() -def serialize_brick(brick): +def serialize_brick(brick: tool.Brick) -> None: brick.serialize_brick() -def add_brick_namespace(brick, alias=None, uri=None): +def add_brick_namespace(brick: tool.Brick, alias: str, uri: str) -> None: brick.add_namespace(alias, uri) -def set_brick_list_root(brick, brick_root=None, split_screen=False): +def set_brick_list_root(brick: tool.Brick, brick_root: str, split_screen: bool = False) -> None: brick.run_view_brick_class(brick_class=brick_root, split_screen=split_screen) brick.clear_breadcrumbs(split_screen=split_screen) -def remove_brick_relation(brick, brick_uri=None, predicate=None, object=None): +def remove_brick_relation(brick: tool.Brick, brick_uri: str, predicate: str, object: str) -> None: brick.remove_relation(brick_uri, predicate, object) diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index e5ae3cd493..c01b70bd55 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -417,6 +417,7 @@ def add_annotation( drawing_tool.show_decorations() obj = drawing_tool.create_annotation_object(drawing, object_type) element = ifc.get_entity(obj) + # TODO: element is never None? if not element: relating_type_rep = drawing_tool.get_annotation_representation(relating_type) if relating_type else None element = drawing_tool.run_root_assign_class( diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 731e3751f2..404bd8df9b 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -99,15 +99,19 @@ def colourise_revision(ifcgit: tool.IfcGit) -> None: step_ids = ifcgit.get_revisions_step_ids() if not step_ids: return - modified_shape_object_step_ids = ifcgit.get_modified_shape_object_step_ids(step_ids) - final_step_ids = ifcgit.update_step_ids(step_ids, modified_shape_object_step_ids) + modified_step_ids = ifcgit.get_modified_step_ids(step_ids) + final_step_ids = ifcgit.update_step_ids(step_ids, modified_step_ids) ifcgit.colourise(final_step_ids) def colourise_uncommitted(ifcgit: tool.IfcGit, ifc: tool.Ifc, repo: git.Repo) -> None: path_ifc = ifc.get_path() step_ids = ifcgit.ifc_diff_ids(repo, None, "HEAD", path_ifc) - ifcgit.colourise(step_ids) + if not step_ids: + return + modified_step_ids = ifcgit.get_modified_step_ids(step_ids) + final_step_ids = ifcgit.update_step_ids(step_ids, modified_step_ids) + ifcgit.colourise(final_step_ids) def switch_revision(ifcgit: tool.IfcGit, ifc: tool.Ifc) -> None: diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index f3f2d6932a..1621585065 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . import bonsai.core.tool as tool -from typing import Literal +from typing import Literal, Iterable def unjoin_walls(ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, joiner, model: tool.Model) -> None: @@ -25,6 +25,8 @@ def unjoin_walls(ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, if not (element := ifc.get_entity(obj)) or model.get_usage_type(element) != "LAYER2": continue geometry.clear_scale(obj) + if ifc.is_moved(obj): + geometry.run_edit_object_placement(obj=obj) joiner.unjoin(obj) @@ -35,7 +37,7 @@ def extend_walls( if not (element := ifc.get_entity(obj)) or model.get_usage_type(element) != "LAYER2": continue geometry.clear_scale(obj) - joiner.join_E(obj, target) + joiner.extend(obj, target) def join_walls_LV( @@ -60,10 +62,22 @@ def join_walls_LV( for obj in selected_objs: geometry.clear_scale(obj) - if join_type == "L": - joiner.join_L(another_selected_object, active_obj) - elif join_type == "V": - joiner.join_V(another_selected_object, active_obj) + joiner.connect(another_selected_object, active_obj) + + +def extend_wall_to_slab( + ifc: tool.Ifc, geometry: tool.Geometry, model: tool.Model, slab_obj, wall_objs: Iterable +) -> None: + if not (clip := model.get_slab_clipping_bmesh(slab_obj)): + return # Nothing to clip? + slab = ifc.get_entity(slab_obj) + for obj in wall_objs: + if ifc.is_moved(obj): + geometry.run_edit_object_placement(obj=obj) + wall = ifc.get_entity(obj) + model.clip_wall_to_slab(wall, clip) + model.connect_wall_to_slab(wall, slab) + model.reload_body_representation(wall_objs) def join_walls_TZ(ifc: tool.Ifc, blender: tool.Blender, geometry: tool.Geometry, joiner, model: tool.Model) -> None: diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index eff50ec2a3..241e0a3df2 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -121,9 +121,8 @@ def import_spatial_decomposition(spatial: tool.Spatial) -> None: spatial.import_spatial_decomposition() -def edit_container_attributes(spatial: tool.Spatial, entity: ifcopenshell.entity_instance) -> None: - spatial.edit_container_attributes(entity) - spatial.import_spatial_decomposition() +def set_orientation_slot(spatial: tool.Spatial, container: ifcopenshell.entity_instance) -> None: + spatial.create_orientation_slot(container) def contract_container(spatial: tool.Spatial, container: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/core/system.py b/src/bonsai/bonsai/core/system.py index 2acd1d21d4..8b2edb2e41 100644 --- a/src/bonsai/bonsai/core/system.py +++ b/src/bonsai/bonsai/core/system.py @@ -62,12 +62,16 @@ def disable_editing_system(system: tool.System) -> None: system.disable_editing_system() -def assign_system(ifc: tool.Ifc, system: ifcopenshell.entity_instance, product: ifcopenshell.entity_instance) -> None: - ifc.run("system.assign_system", products=[product], system=system) +def assign_system( + ifc: tool.Ifc, system: ifcopenshell.entity_instance, products: list[ifcopenshell.entity_instance] +) -> None: + ifc.run("system.assign_system", products=products, system=system) -def unassign_system(ifc: tool.Ifc, system: ifcopenshell.entity_instance, product: ifcopenshell.entity_instance) -> None: - ifc.run("system.unassign_system", products=[product], system=system) +def unassign_system( + ifc: tool.Ifc, system: ifcopenshell.entity_instance, products: list[ifcopenshell.entity_instance] +) -> None: + ifc.run("system.unassign_system", products=products, system=system) def select_system_products(system_tool: tool.System, system: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index b84e931871..4763ef7e8f 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -432,6 +432,7 @@ class Geometry: def rename_object(cls, obj, name): pass def replace_object_data_globally(cls, old_data, new_data): pass def resolve_mapped_representation(cls, representation): pass + def run_edit_object_placement(cls, obj=None): pass def run_geometry_update_representation(cls, obj=None): pass def run_style_add_style(cls, obj=None): pass def select_connection(cls, connection): pass @@ -562,22 +563,26 @@ class Misc: @interface class Model: + def clip_wall_to_slab(cls, element, bm): pass + def connect_wall_to_slab(cls, wall, slab): pass def convert_si_to_unit(cls, value): pass def convert_unit_to_si(cls, value): pass def export_points(cls, position, indices): pass def export_profile(cls, obj, position=None): pass def generate_occurrence_name(cls, element_type, ifc_class): pass def get_extrusion(cls, representation): pass - def import_profile(cls, profile, obj=None, position=None): pass + def get_manual_booleans(cls, element): pass + def get_material_layer_parameters(cls, element): pass + def get_slab_clipping_bmesh(cls, obj): pass + def get_usage_type(cls, element): pass + def get_wall_axis(cls, obj, layers=None): pass def import_curve(cls, curve, obj=None, position=None): pass + def import_profile(cls, profile, obj=None, position=None): pass def import_rectangle(cls, obj, position, profile): pass def load_openings(cls, openings): pass def purge_scene_openings(cls): pass - def get_usage_type(cls, element): pass - def get_material_layer_parameters(cls, element): pass - def get_manual_booleans(cls, element): pass - def get_wall_axis(cls, obj, layers=None): pass def regenerate_array(cls, parent, data): pass + def reload_body_representation(cls, obj_or_objects): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass @@ -901,7 +906,6 @@ class Spatial: def deselect_objects(cls): pass def disable_editing(cls, obj): pass def duplicate_object_and_data(cls, obj): pass - def edit_container_attributes(cls, entity): pass def edit_container_name(cls, container, name): pass def enable_editing(cls, obj): pass def expand_container(cls, container): pass diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py index 4ad7a95425..d3c0589cea 100644 --- a/src/bonsai/bonsai/tool/aggregate.py +++ b/src/bonsai/bonsai/tool/aggregate.py @@ -118,7 +118,9 @@ class Aggregate(bonsai.core.tool.Aggregate): not_editing_obj = props.not_editing_objects.add() not_editing_obj.obj = obj.original not_editing_obj.previous_display_type = obj.original.display_type + not_editing_obj.previous_hide_select = obj.original.hide_select obj.original.display_type = "WIRE" + obj.hide_select = True else: editing_obj = props.editing_objects.add() editing_obj.obj = obj.original @@ -132,13 +134,15 @@ class Aggregate(bonsai.core.tool.Aggregate): props = context.scene.BIMAggregateProperties for obj_prop in props.not_editing_objects: obj = obj_prop.obj + if not obj: + continue obj.original.display_type = obj_prop.previous_display_type + obj.hide_select = obj_prop.previous_hide_select element = tool.Ifc.get_entity(obj) if not element: continue parts = ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(props.editing_aggregate)) - objs = [tool.Ifc.get_object(part) for part in parts] if context.space_data.local_view: bpy.ops.view3d.localview() diff --git a/src/bonsai/bonsai/tool/bcf.py b/src/bonsai/bonsai/tool/bcf.py index 4a4068034a..c427a22e90 100644 --- a/src/bonsai/bonsai/tool/bcf.py +++ b/src/bonsai/bonsai/tool/bcf.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bcf.v2.visinfo import bonsai.core.tool import bonsai.tool as tool @@ -32,16 +33,24 @@ import bcf.agnostic.model import bcf.agnostic.topic import bcf.agnostic.visinfo -from typing import Any, Union, TypeVar, TypeGuard, Optional +from typing import Any, Union, TypeVar, TypeGuard, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.bcf.prop import BCFProperties T = TypeVar("T") class Bcf(bonsai.core.tool.Bcf): + @classmethod + def get_bcf_props(cls) -> "BCFProperties": + return bpy.context.scene.BCFProperties + @classmethod def get_path(cls) -> str: - return bpy.context.scene.BCFProperties.bcf_file + props = cls.get_bcf_props() + return props.bcf_file @classmethod def is_list_of(cls, a: list[Any], t: type[T]) -> TypeGuard[list[T]]: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index f7e997158a..d5269a405f 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . from __future__ import annotations +import sys import bpy import bmesh import json @@ -37,9 +38,15 @@ from mathutils import Vector from pathlib import Path from functools import lru_cache from bonsai.bim.ifc import IFC_CONNECTED_TYPE -from typing import Any, Optional, Union, Literal, Iterable, Callable, TypeVar, Generator +from typing import Any, Optional, Union, Literal, Iterable, Callable, TypeVar, Generator, TYPE_CHECKING from typing_extensions import assert_never +if TYPE_CHECKING: + from bonsai.bim.prop import BIMProperties, BIMObjectProperties + from bonsai.bim.module.csv.prop import CsvProperties + from bonsai.bim.module.diff.prop import DiffProperties + + T = TypeVar("T") VIEWPORT_ATTRIBUTES = [ "view_matrix", @@ -149,15 +156,22 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]: - obj = getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active - if not is_selected: - return obj - if obj in cls.get_selected_objects(include_active=False): - return obj + """Gets the active object + + :param is_selected: If true, the active object also needs to be selected. + """ + if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active): + if not is_selected: + return obj + if obj.select_get(): + return obj @classmethod def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]: - """Get selected objects including active object.""" + """Get selected objects + + :param include_active: If true, the active object is included regardless if it is also selected. + """ if selected_objects := getattr(bpy.context, "selected_objects", None): if include_active and (active_obj := cls.get_active_object()): return set(selected_objects + [active_obj]) @@ -189,13 +203,15 @@ class Blender(bonsai.core.tool.Blender): if context is None: context = bpy.context if obj_type == "Object": - return bpy.data.objects.get(obj).BIMObjectProperties.ifc_definition_id + props = tool.Blender.get_object_bim_props(bpy.data.objects[obj]) + return props.ifc_definition_id elif obj_type == "Material": - return context.scene.BIMMaterialProperties.materials[ - context.scene.BIMMaterialProperties.active_material_index - ].ifc_definition_id + props = tool.Material.get_material_props() + return props.materials[props.active_material_index].ifc_definition_id elif obj_type == "MaterialSetItem": - return bpy.data.objects.get(obj).BIMObjectMaterialProperties.active_material_set_item_id + obj_ = bpy.data.objects[obj] + omprops = tool.Material.get_object_material_props(obj_) + return omprops.active_material_set_item_id elif obj_type == "Task": tprops = tool.Sequence.get_task_tree_props() return tprops.tasks[context.scene.BIMWorkScheduleProperties.active_task_index].ifc_definition_id @@ -208,9 +224,8 @@ class Blender(bonsai.core.tool.Blender): context.scene.BIMResourceProperties.active_resource_index ].ifc_definition_id elif obj_type == "Profile": - return context.scene.BIMProfileProperties.profiles[ - context.scene.BIMProfileProperties.active_profile_index - ].ifc_definition_id + props = tool.Profile.get_profile_props() + return props.profiles[props.active_profile_index].ifc_definition_id elif obj_type == "WorkSchedule": return context.scene.BIMWorkScheduleProperties.active_work_schedule_id elif obj_type == "Group": @@ -220,7 +235,8 @@ class Blender(bonsai.core.tool.Blender): @classmethod def is_ifc_object(cls, obj: bpy.types.Object) -> bool: - return bool(obj.BIMObjectProperties.ifc_definition_id) + props = tool.Blender.get_object_bim_props(obj) + return bool(props.ifc_definition_id) @classmethod def is_ifc_class_active(cls, ifc_class: str) -> bool: @@ -487,23 +503,64 @@ class Blender(bonsai.core.tool.Blender): ) return keymap + KEY_MODIFIERS = { + "A": ("EVENT_ALT", "OPTION" if sys.platform == "Darwin" else "ALT"), + "C": ("EVENT_CTRL", "CTRL"), + "S": ("EVENT_SHIFT", "⇧"), + "E": ("EVENT_PADENTER", "ENTER" if sys.platform == "Darwin" else "RETURN"), + } + @classmethod def add_layout_hotkey_operator( - cls, tool_name: str, layout: bpy.types.UILayout, text: str, hotkey: str, description: str + cls, + layout: bpy.types.UILayout, + text: str, + hotkey: str, + description: str, + ui_context: str = "", + *, + tool_name: str, + module_name: str, + operator: Optional[str] = None, ) -> tuple[bpy.types.OperatorProperties, bpy.types.UILayout]: - modifiers = { - "A": "EVENT_ALT", - "S": "EVENT_SHIFT", - } + """ + :param module_name: Provide `__name__` of the current module, + so method could pick up icon previews based on the module's `custom_icon_previews` attribute. + :param operator: Operator to display in UI. Displaying the specific operator in UI can be useful + to provide poll error messages. + """ + if tool_name == "bim": + hotkey_operator = "bim.hotkey" + else: + hotkey_operator = f"bim.{tool_name}_hotkey" + operator_to_use = operator or hotkey_operator + modifier, key = hotkey.split("_") + op_text = "" if ui_context == "TOOL_HEADER" else text + modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", "")) - row = layout.row(align=True) - row.label(text="", icon=modifiers[modifier]) - row.label(text="", icon=f"EVENT_{key}") + row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True) + module = sys.modules[module_name] + icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None] + icon_previews = getattr(module, "custom_icon_previews", None) + if icon_previews: + custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id + op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon) + else: + op = row.operator(operator_to_use, text=op_text) + if ui_context != "TOOL_HEADER": + row.label(text="", icon=modifier_icon) + row.label(text="", icon=f"EVENT_{key}") - op = row.operator(f"bim.{tool_name}_hotkey", text=text) - op.hotkey = hotkey - op.description = description + if operator_to_use == hotkey_operator: + hotkey_description = f"Hotkey: {modifier_str} {key}".strip() + description = "\n\n".join(filter(None, [description, hotkey_description])) + + op.hotkey = hotkey + if ui_context == "TOOL_HEADER": + op.description = text + "\n" + description + else: + op.description = description return op, row @classmethod @@ -869,7 +926,7 @@ class Blender(bonsai.core.tool.Blender): cls.open_file_or_folder(filepath.as_posix()) return {"PASS_THROUGH"} - # holding sHIFT - open file + # holding SHIFT - open file if not filepath.exists(): operator.report({"ERROR"}, f'Cannot open non-existing file: "{filepath.as_posix()}"') return {"CANCELLED"} @@ -879,7 +936,7 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_layer_collection(cls, collection: bpy.types.Collection) -> Union[bpy.types.LayerCollection, None]: project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_collection = project.BIMObjectProperties.collection + project_collection = tool.Blender.get_object_bim_props(project).collection for layer_collection in bpy.context.view_layer.layer_collection.children: if layer_collection.collection == project_collection: for layer_collection2 in layer_collection.children: @@ -915,7 +972,7 @@ class Blender(bonsai.core.tool.Blender): return False if not (element := tool.Ifc.get_entity(obj)): return True - if obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: + if obj in tool.Project.get_project_props().clipping_planes_objs: return False usage_type = tool.Model.get_usage_type(element) if usage_type in ("LAYER1", "LAYER2"): @@ -1018,23 +1075,28 @@ class Blender(bonsai.core.tool.Blender): @classmethod def is_editing_railing_path(cls, obj: bpy.types.Object): - return obj.BIMRailingProperties.is_editing_path + props = tool.Model.get_railing_props(obj) + return props.is_editing_path @classmethod def is_editing_roof_path(cls, obj: bpy.types.Object) -> bool: - return obj.BIMRoofProperties.is_editing_path + props = tool.Model.get_roof_props(obj) + return props.is_editing_path @classmethod def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool: - return obj.BIMRailingProperties.is_editing + props = tool.Model.get_railing_props(obj) + return props.is_editing @classmethod def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool: - return obj.BIMRoofProperties.is_editing + props = tool.Model.get_roof_props(obj) + return props.is_editing @classmethod def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool: - return obj.BIMWindowProperties.is_editing + props = tool.Model.get_window_props(obj) + return props.is_editing @classmethod def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool: @@ -1043,7 +1105,8 @@ class Blender(bonsai.core.tool.Blender): @classmethod def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool: - return obj.BIMStairProperties.is_editing + props = tool.Model.get_stair_props(obj) + return props.is_editing @classmethod def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool: @@ -1150,6 +1213,7 @@ class Blender(bonsai.core.tool.Blender): # It's faster to get them as f and then convert to d # with .astype("d"), if precision is needed. coords = np.empty(len(verts) * 3, dtype="f") + verts.foreach_get("co", coords) coords = coords.reshape(-1, 3) return coords @@ -1414,9 +1478,9 @@ class Blender(bonsai.core.tool.Blender): def set_prop_from_path(cls, bpy_object: bpy.types.bpy_struct, prop_path: str, value: Any) -> None: """Set `data_block` property value using path from `path_from_id`.""" - T = TypeVar("T", bound=bpy.types.bpy_struct) + T_ = TypeVar("T_", bound=bpy.types.bpy_struct) - def path_resolve(obj: T, prop_path: str) -> tuple[T, str]: + def path_resolve(obj: T_, prop_path: str) -> tuple[T_, str]: if "." in prop_path: extra_path, prop_path = prop_path.rsplit(".", 1) obj = obj.path_resolve(extra_path) @@ -1478,10 +1542,8 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_user_data_dir(cls) -> Path: - try: - return Path(bpy.context.scene.BIMProperties.data_dir) - except AttributeError: - return Path() + props = tool.Blender.get_bim_props() + return Path(props.data_dir) @classmethod def get_data_dir_path(cls, relative_path: Union[str, Path]) -> Path: @@ -1539,3 +1601,61 @@ class Blender(bonsai.core.tool.Blender): dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())} return types.MappingProxyType(dct) + + @classmethod + def get_csv_props(cls) -> CsvProperties: + return bpy.context.scene.CsvProperties + + @classmethod + def get_diff_props(cls) -> DiffProperties: + return bpy.context.scene.DiffProperties + + @classmethod + def get_bim_props(cls, scene: Optional[bpy.types.Scene] = None) -> BIMProperties: + if scene is None: + scene = bpy.context.scene + return scene.BIMProperties + + @classmethod + def get_object_bim_props(cls, obj: bpy.types.Object) -> BIMObjectProperties: + return obj.BIMObjectProperties + + @classmethod + def get_ifc_definition_id(cls, obj: IFC_CONNECTED_TYPE) -> int: + if isinstance(obj, bpy.types.Object): + return tool.Blender.get_object_bim_props(obj).ifc_definition_id + return tool.Style.get_material_style_props(obj).ifc_definition_id + + @classmethod + def get_active_uilist_element( + cls, collection: bpy.types.bpy_prop_collection_idprop[T], index: int + ) -> Union[T, None]: + if 0 <= index < len(collection): + return collection[index] + return None + + @classmethod + def clear_undo_history(cls) -> None: + """Clears the Blender history, Bonsai history, and IfcOpenShell history""" + old_undo_steps = bpy.context.preferences.edit.undo_steps + bpy.context.preferences.edit.undo_steps = 2 + for i in range(3): + bpy.ops.ed.undo_push(message="Undo history cleared") + bpy.context.preferences.edit.undo_steps = old_undo_steps + tool.Ifc.clear_history() + old_history_size = tool.Ifc.get().history_size + tool.Ifc.get().set_history_size(0) + tool.Ifc.get().set_history_size(old_history_size) + + @classmethod + def get_unit_scale(cls): + unit_length = bpy.context.scene.unit_settings.length_unit + unit_scale = 1.0 + if unit_length == "CENTIMETERS": + unit_scale = 0.01 + if unit_length == "MILLIMETERS": + unit_scale = 0.001 + if unit_length == "FEET": + unit_scale = 0.3048 + + return unit_scale diff --git a/src/bonsai/bonsai/tool/brick.py b/src/bonsai/bonsai/tool/brick.py index 4b034f2160..ce34cb65fb 100644 --- a/src/bonsai/bonsai/tool/brick.py +++ b/src/bonsai/bonsai/tool/brick.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import os import bpy import datetime @@ -29,6 +30,7 @@ import bonsai.core.tool import bonsai.tool as tool from pathlib import Path from contextlib import contextmanager +from typing import Generator, Any, Union, TYPE_CHECKING try: import brickschema @@ -41,6 +43,11 @@ except: # See #1860 print("Warning: brickschema not available.") +if TYPE_CHECKING: + import brickschema + from rdflib import Literal, URIRef, Namespace, BNode + from bonsai.bim.module.brick.prop import BIMBrickProperties + # silence known rdflib_sqlalchemy TypeError warning # see https://github.com/BrickSchema/Brick/issues/513#issuecomment-1558493675 import logging @@ -51,7 +58,11 @@ logger.setLevel(logging.ERROR) class Brick(bonsai.core.tool.Brick): @classmethod - def add_brick(cls, namespace, brick_class, label): + def get_brick_props(cls) -> BIMBrickProperties: + return bpy.context.scene.BIMBrickProperties + + @classmethod + def add_brick(cls, namespace: str, brick_class: str, label: str) -> str: ns = Namespace(namespace) brick = ns[ifcopenshell.guid.expand(ifcopenshell.guid.new())] with BrickStore.new_changeset() as cs: @@ -60,8 +71,8 @@ class Brick(bonsai.core.tool.Brick): return str(brick) @classmethod - def add_brick_breadcrumb(cls, split_screen=False): - props = bpy.context.scene.BIMBrickProperties + def add_brick_breadcrumb(cls, split_screen: bool = False) -> None: + props = tool.Brick.get_brick_props() if split_screen: new = props.split_screen_brick_breadcrumbs.add() new.name = props.split_screen_active_brick_class @@ -70,7 +81,7 @@ class Brick(bonsai.core.tool.Brick): new.name = props.active_brick_class @classmethod - def add_brick_from_element(cls, element, namespace, brick_class): + def add_brick_from_element(cls, element: ifcopenshell.entity_instance, namespace: str, brick_class: str) -> str: ns = Namespace(namespace) brick = ns[element.GlobalId] with BrickStore.new_changeset() as cs: @@ -82,20 +93,21 @@ class Brick(bonsai.core.tool.Brick): return str(brick) @classmethod - def add_brickifc_project(cls, namespace): + def add_brickifc_project(cls, namespace: str) -> str: project = tool.Ifc.get().by_type("IfcProject")[0] ns = Namespace(namespace) brick_project = ns[project.GlobalId] + props = tool.Blender.get_bim_props() with BrickStore.new_changeset() as cs: cs.add((brick_project, A, REF.ifcProject)) cs.add((brick_project, REF.ifcProjectID, Literal(project.GlobalId))) - cs.add((brick_project, REF.ifcFileLocation, Literal(bpy.context.scene.BIMProperties.ifc_file))) + cs.add((brick_project, REF.ifcFileLocation, Literal(props.ifc_file))) if project.Name: cs.add((brick_project, URIRef("http://www.w3.org/2000/01/rdf-schema#label"), Literal(project.Name))) return str(brick_project) @classmethod - def add_brickifc_reference(cls, brick, element, project): + def add_brickifc_reference(cls, brick: str, element: ifcopenshell.entity_instance, project: str) -> None: with BrickStore.new_changeset() as cs: bnode = BNode() cs.add((URIRef(brick), REF.hasExternalReference, bnode)) @@ -106,23 +118,24 @@ class Brick(bonsai.core.tool.Brick): cs.add((bnode, REF.ifcName, Literal(element.Name))) @classmethod - def add_relation(cls, brick_uri, predicate, object): + def add_relation(cls, brick_uri: str, predicate: str, object: str) -> None: + props = tool.Brick.get_brick_props() if predicate == "http://www.w3.org/2000/01/rdf-schema#label": with BrickStore.new_changeset() as cs: cs.add((URIRef(brick_uri), URIRef(predicate), Literal(object))) - bpy.context.scene.BIMBrickProperties.new_brick_relation_type = BrickStore.relationships[0] - bpy.context.scene.BIMBrickProperties.add_brick_relation_failed = False + props.new_brick_relation_type = BrickStore.relationships[0] + props.add_brick_relation_failed = False return query = BrickStore.graph.query("ASK { <{object_uri}> a ?o . }".replace("{object_uri}", object)) if query: with BrickStore.new_changeset() as cs: cs.add((URIRef(brick_uri), URIRef(predicate), URIRef(object))) - bpy.context.scene.BIMBrickProperties.add_brick_relation_failed = False + props.add_brick_relation_failed = False else: - bpy.context.scene.BIMBrickProperties.add_brick_relation_failed = True + props.add_brick_relation_failed = True @classmethod - def remove_relation(cls, brick_uri, predicate, object): + def remove_relation(cls, brick_uri: str, predicate: str, object: str) -> None: with BrickStore.new_changeset() as cs: for s, p, o in BrickStore.graph.triples((brick_uri, predicate, object)): cs.remove((s, p, o)) @@ -131,21 +144,22 @@ class Brick(bonsai.core.tool.Brick): cs.remove(triple) @classmethod - def clear_brick_browser(cls, split_screen=False): - props = bpy.context.scene.BIMBrickProperties + def clear_brick_browser(cls, split_screen: bool = False) -> None: + props = cls.get_brick_props() if split_screen: props.split_screen_bricks.clear() else: props.bricks.clear() @classmethod - def clear_project(cls): + def clear_project(cls) -> None: BrickStore.purge() - bpy.context.scene.BIMBrickProperties.active_brick_class == "" - bpy.context.scene.BIMBrickProperties.split_screen_active_brick_class == "" + props = cls.get_brick_props() + props.active_brick_class = "" + props.split_screen_active_brick_class = "" @classmethod - def export_brick_attributes(cls, brick_uri): + def export_brick_attributes(cls, brick_uri: str) -> dict[str, Any]: query = BrickStore.graph.query( """ PREFIX rdfs: @@ -168,13 +182,14 @@ class Brick(bonsai.core.tool.Brick): return {"Identification": brick_uri, "Name": name} @classmethod - def get_active_brick_class(cls, split_screen=False): + def get_active_brick_class(cls, split_screen: bool = False) -> str: + props = cls.get_brick_props() if split_screen: - return bpy.context.scene.BIMBrickProperties.split_screen_active_brick_class - return bpy.context.scene.BIMBrickProperties.active_brick_class + return props.split_screen_active_brick_class + return props.active_brick_class @classmethod - def get_brick(cls, element): + def get_brick(cls, element: ifcopenshell.entity_instance) -> Union[str, None]: for rel in element.HasAssociations: if rel.is_a("IfcRelAssociatesLibrary"): if tool.Ifc.get_schema() == "IFC2X3" and "#" in rel.RelatingLibrary.ItemReference: @@ -183,21 +198,21 @@ class Brick(bonsai.core.tool.Brick): return rel.RelatingLibrary.Identification @classmethod - def get_brick_class(cls, element): + def get_brick_class(cls, element: ifcopenshell.entity_instance) -> Union[str, None]: return ifcopenshell.util.brick.get_brick_type(element) @classmethod - def get_brick_path(cls): + def get_brick_path(cls) -> Union[str, None]: return BrickStore.path @classmethod - def get_brick_path_name(cls): + def get_brick_path_name(cls) -> str: if BrickStore.path: return os.path.basename(BrickStore.path) return "Unnamed" @classmethod - def get_brickifc_project(cls): + def get_brickifc_project(cls) -> Union[str, None]: project = tool.Ifc.get().by_type("IfcProject")[0] query = BrickStore.graph.query( """ @@ -216,45 +231,46 @@ class Brick(bonsai.core.tool.Brick): return results[0][0].toPython() @classmethod - def get_convertable_brick_elements(cls): + def get_convertable_brick_elements(cls) -> set[ifcopenshell.entity_instance]: equipment = set(tool.Ifc.get().by_type("IfcDistributionElement")) equipment -= set(tool.Ifc.get().by_type("IfcFlowSegment")) equipment -= set(tool.Ifc.get().by_type("IfcFlowFitting")) return equipment @classmethod - def get_convertable_brick_spaces(cls): + def get_convertable_brick_spaces(cls) -> set[ifcopenshell.entity_instance]: if tool.Ifc.get_schema() == "IFC2X3": return set(tool.Ifc.get().by_type("IfcSpatialStructureElement")) return set(tool.Ifc.get().by_type("IfcSpatialElement")) @classmethod - def get_convertable_brick_systems(cls): + def get_convertable_brick_systems(cls) -> set[ifcopenshell.entity_instance]: systems = set(tool.Ifc.get().by_type("IfcSystem")) systems -= set(tool.Ifc.get().by_type("IfcStructuralAnalysisModel")) systems -= set(tool.Ifc.get().by_type("IfcZone")) return systems @classmethod - def get_parent_space(cls, space): + def get_parent_space(cls, space: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: element = ifcopenshell.util.element.get_aggregate(space) + assert element if not element.is_a("IfcProject"): return element @classmethod - def get_element_container(cls, element): + def get_element_container(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: return ifcopenshell.util.element.get_container(element) @classmethod - def get_element_systems(cls, element): + def get_element_systems(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: return ifcopenshell.util.system.get_element_systems(element) @classmethod - def get_element_feeds(cls, element): + def get_element_feeds(cls, element: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]: return ifcopenshell.util.brick.get_element_feeds(element) @classmethod - def get_item_class(cls, item): + def get_item_class(cls, item: str) -> Union[str, None]: query = BrickStore.graph.query( """ PREFIX brick: @@ -270,7 +286,9 @@ class Brick(bonsai.core.tool.Brick): return row.get("class").toPython().split("#")[-1] @classmethod - def get_library_brick_reference(cls, library, brick_uri): + def get_library_brick_reference( + cls, library: ifcopenshell.entity_instance, brick_uri: str + ) -> Union[ifcopenshell.entity_instance, None]: if tool.Ifc.get_schema() == "IFC2X3": for reference in library.LibraryReference or []: if reference.ItemReference == brick_uri: @@ -281,11 +299,11 @@ class Brick(bonsai.core.tool.Brick): return reference @classmethod - def get_namespace(cls, uri): + def get_namespace(cls, uri: str) -> str: return uri.split("#")[0] + "#" @classmethod - def import_brick_classes(cls, brick_class, split_screen=False): + def import_brick_classes(cls, brick_class: str, split_screen: bool = False) -> None: query = BrickStore.graph.query( """ PREFIX brick: @@ -304,10 +322,11 @@ class Brick(bonsai.core.tool.Brick): "{brick_class}", brick_class ) ) + props = tool.Brick.get_brick_props() if split_screen: - bricks = bpy.context.scene.BIMBrickProperties.split_screen_bricks + bricks = props.split_screen_bricks else: - bricks = bpy.context.scene.BIMBrickProperties.bricks + bricks = props.bricks for row in query: new = bricks.add() label = row.get("label") @@ -318,7 +337,7 @@ class Brick(bonsai.core.tool.Brick): new.total_items = row.get("total_items").toPython() @classmethod - def import_brick_items(cls, brick_class, split_screen=False): + def import_brick_items(cls, brick_class: str, split_screen: bool = False) -> None: query = BrickStore.graph.query( """ PREFIX brick: @@ -335,10 +354,11 @@ class Brick(bonsai.core.tool.Brick): "{brick_class}", brick_class ) ) + props = tool.Brick.get_brick_props() if split_screen: - bricks = bpy.context.scene.BIMBrickProperties.split_screen_bricks + bricks = props.split_screen_bricks else: - bricks = bpy.context.scene.BIMBrickProperties.bricks + bricks = props.bricks for row in query: new = bricks.add() label = row.get("label") @@ -348,7 +368,7 @@ class Brick(bonsai.core.tool.Brick): new.uri = row.get("item").toPython() @classmethod - def load_brick_file(cls, filepath): + def load_brick_file(cls, filepath: str) -> None: if not BrickStore.schema: # important check for running under test cases BrickStore.schema = tool.Blender.get_data_dir_path(Path("brick", "Brick.ttl")) BrickStore.graph = brickschema.persistent.VersionedGraphCollection("sqlite://") @@ -364,7 +384,7 @@ class Brick(bonsai.core.tool.Brick): BrickStore.load_relationships() @classmethod - def new_brick_file(cls): + def new_brick_file(cls) -> None: if not BrickStore.schema: # important check for running under test cases BrickStore.schema = tool.Blender.get_data_dir_path(Path("brick", "Brick.ttl")) BrickStore.graph = brickschema.persistent.VersionedGraphCollection("sqlite://") @@ -377,8 +397,8 @@ class Brick(bonsai.core.tool.Brick): BrickStore.load_relationships() @classmethod - def pop_brick_breadcrumb(cls, split_screen=False): - props = bpy.context.scene.BIMBrickProperties + def pop_brick_breadcrumb(cls, split_screen: bool = False) -> str: + props = cls.get_brick_props() if split_screen: breadcrumbs = props.split_screen_brick_breadcrumbs else: @@ -390,7 +410,7 @@ class Brick(bonsai.core.tool.Brick): return name @classmethod - def remove_brick(cls, brick_uri): + def remove_brick(cls, brick_uri: str) -> None: with BrickStore.new_changeset() as cs: for s, p, o in BrickStore.graph.triples((URIRef(brick_uri), None, None)): cs.remove((s, p, o)) @@ -405,51 +425,55 @@ class Brick(bonsai.core.tool.Brick): ) @classmethod - def run_refresh_brick_viewer(cls): + def run_refresh_brick_viewer(cls) -> None: return bonsai.core.brick.refresh_brick_viewer(tool.Brick) @classmethod - def run_view_brick_class(cls, brick_class=None, split_screen=False): + def run_view_brick_class(cls, brick_class: Union[str, None] = None, split_screen: bool = False) -> None: return bonsai.core.brick.view_brick_class(tool.Brick, brick_class=brick_class, split_screen=split_screen) @classmethod - def select_browser_item(cls, item, split_screen=False): + def select_browser_item(cls, item: str, split_screen: bool = False) -> None: name = item.split("#")[-1] - props = bpy.context.scene.BIMBrickProperties + props = cls.get_brick_props() if split_screen: props.split_screen_active_brick_index = props.split_screen_bricks.find(name) else: props.active_brick_index = props.bricks.find(name) @classmethod - def set_active_brick_class(cls, brick_class, split_screen=False): - props = bpy.context.scene.BIMBrickProperties + def set_active_brick_class(cls, brick_class: str, split_screen: bool = False) -> None: + props = cls.get_brick_props() if split_screen: props.split_screen_active_brick_class = brick_class else: props.active_brick_class = brick_class @classmethod - def serialize_brick(cls): + def serialize_brick(cls) -> None: BrickStore.get_project().serialize(destination=BrickStore.path, format="turtle") BrickStore.set_last_saved() @classmethod - def add_namespace(cls, alias, uri): + def add_namespace(cls, alias: str, uri: str) -> None: + assert BrickStore.graph BrickStore.graph.bind(alias, Namespace(uri)) BrickStore.load_namespaces() @classmethod - def clear_breadcrumbs(cls, split_screen=False): + def clear_breadcrumbs(cls, split_screen: bool = False) -> None: + props = cls.get_brick_props() if split_screen: - bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs.clear() + props.split_screen_brick_breadcrumbs.clear() else: - bpy.context.scene.BIMBrickProperties.brick_breadcrumbs.clear() + props.brick_breadcrumbs.clear() class BrickStore: schema = None # this is now a os path + path: Union[str, None] path = None # file path if the project was loaded in + graph: Union[brickschema.persistent.VersionedGraphCollection, None] graph = None # this is the VersionedGraphCollection with 2 arbitrarily named graphs: "schema" and "project" # "SCHEMA" holds the Brick.ttl metadata; "PROJECT" holds all the authored entities last_saved = None @@ -474,11 +498,11 @@ class BrickStore: BrickStore.relationships = [] @classmethod - def get_project(cls): + def get_project(cls) -> brickschema.graph.Graph: return BrickStore.graph.graph_at(graph="PROJECT") @classmethod - def load_sub_roots(cls): + def load_sub_roots(cls) -> None: query = BrickStore.graph.query( """ PREFIX brick: @@ -504,7 +528,7 @@ class BrickStore: BrickStore.root_classes.append(sub_root) @classmethod - def load_namespaces(cls): + def load_namespaces(cls) -> None: BrickStore.namespaces = [] keyword_filter = [ "brickschema.org", @@ -528,7 +552,7 @@ class BrickStore: BrickStore.namespaces.append((alias, str(uri))) @classmethod - def load_entity_classes(cls): + def load_entity_classes(cls) -> None: for root_class in BrickStore.root_classes: query = BrickStore.graph.query( """ @@ -550,7 +574,7 @@ class BrickStore: BrickStore.entity_classes[root_class].append(uri) @classmethod - def load_relationships(cls): + def load_relationships(cls) -> None: query = BrickStore.graph.query( """ PREFIX brick: @@ -564,30 +588,30 @@ class BrickStore: BrickStore.relationships.append(uri) @classmethod - def set_history_size(cls, size): + def set_history_size(cls, size: int) -> None: cls.history_size = size while len(cls.history) > cls.history_size: cls.history.pop(0) @classmethod - def begin_transaction(cls): + def begin_transaction(cls) -> None: cls.current_changesets = 0 @classmethod - def end_transaction(cls): + def end_transaction(cls) -> None: cls.history.append(cls.current_changesets) if len(cls.history) > cls.history_size: cls.history.pop(0) @classmethod @contextmanager - def new_changeset(cls): + def new_changeset(cls) -> Generator[Any, None, None]: cls.current_changesets += 1 with BrickStore.graph.new_changeset("PROJECT") as cs: yield cs @classmethod - def undo(cls): + def undo(cls) -> None: if not BrickStore.graph or not BrickStore.history: return total_changesets = BrickStore.history.pop() @@ -596,7 +620,7 @@ class BrickStore: BrickStore.future.append(total_changesets) @classmethod - def redo(cls): + def redo(cls) -> None: if not BrickStore.graph or not BrickStore.future: return total_changesets = BrickStore.future.pop() @@ -605,7 +629,7 @@ class BrickStore: BrickStore.history.append(total_changesets) @classmethod - def set_last_saved(cls): + def set_last_saved(cls) -> None: save = os.path.getmtime(BrickStore.path) save = datetime.datetime.fromtimestamp(save) BrickStore.last_saved = f"{save.year}-{save.month}-{save.day} {save.hour}:{save.minute}" diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index b82ed6c47f..8f1d1a1fd7 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -111,10 +111,7 @@ class Bsdd(bonsai.core.tool.Bsdd): possible_values = [v["value"] for v in possible_values] description = prop.get("description", "") - # Prefer propertyCode as it's later will be used to set properties, - # so name should be exact. Fallback to name as propertyCode is nullable. - prop_name = prop.get("propertyCode") or prop["name"] - psets[pset][prop_name] = { + psets[pset][prop["name"]] = { "data_type": prop.get("dataType"), "possible_values": possible_values, "description": description, diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 36d97fbd09..62c2b854ab 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -28,7 +28,7 @@ # - An arc is reconstructed from 3 points instead of a full circle # - You can now derive the center from an arc without generating geometry - +from __future__ import annotations import sys import bpy import math @@ -36,12 +36,20 @@ import bmesh import mathutils.geometry from mathutils import Vector, Matrix, geometry import itertools +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from bonsai.bim.module.cad.prop import BIMCadProperties VTX_PRECISION = 1.0e-5 class Cad: + @classmethod + def get_cad_props(cls) -> BIMCadProperties: + return bpy.context.scene.BIMCadProperties + @classmethod def is_point_on_edge(cls, p, edge): """ @@ -110,7 +118,7 @@ class Cad: parameter = ( round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) - or (round(axis.x, 2) == 0 and round(axis.y < 0)) + or (round(axis.x, 2) == 0 and round(axis.y > 0)) ) if new_angle is not None: rot_mat = Matrix.Rotation(new_angle, 3, axis) @@ -162,7 +170,9 @@ class Cad: return geometry.intersect_line_plane(v1, v2, plane_co, plane_no) @classmethod - def intersect_edges(cls, edge1, edge2): + def intersect_edges( + cls, edge1: tuple[Vector, Vector], edge2: tuple[Vector, Vector] + ) -> Union[tuple[Vector, Vector], None]: """ > takes 2 tuples, each tuple contains 2 vectors - prepares input for sending to intersect_line_line diff --git a/src/bonsai/bonsai/tool/clash.py b/src/bonsai/bonsai/tool/clash.py index 57b7ffd4a8..2ec7ccb75d 100644 --- a/src/bonsai/bonsai/tool/clash.py +++ b/src/bonsai/bonsai/tool/clash.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import os import bpy import json @@ -25,14 +26,23 @@ import bonsai.tool as tool from contextlib import contextmanager from mathutils import Vector from ifcclash import ifcclash +from typing import TYPE_CHECKING, Union + +if TYPE_CHECKING: + from bonsai.bim.module.clash.prop import BIMClashProperties class Clash(bonsai.core.tool.Clash): + @classmethod + def get_clash_props(cls) -> BIMClashProperties: + return bpy.context.scene.BIMClashProperties + @classmethod def export_clash_sets(cls) -> list[ifcclash.ClashSet]: - clash_sets = [] - for clash_set in bpy.context.scene.BIMClashProperties.clash_sets: + clash_sets: list[ifcclash.ClashSet] = [] + props = cls.get_clash_props() + for clash_set in props.clash_sets: a = [] b = [] for ab in ["a", "b"]: @@ -46,7 +56,7 @@ class Clash(bonsai.core.tool.Clash): a.append(clash_source) elif ab == "b": b.append(clash_source) - clash_set_data = {"name": clash_set.name, "mode": clash_set.mode, "a": a, "b": b} + clash_set_data = ifcclash.ClashSet(name=clash_set.name, mode=clash_set.mode, a=a, b=b) if clash_set.mode == "intersection": clash_set_data["tolerance"] = clash_set.tolerance clash_set_data["check_all"] = clash_set.check_all @@ -55,33 +65,37 @@ class Clash(bonsai.core.tool.Clash): elif clash_set.mode == "clearance": clash_set_data["clearance"] = clash_set.clearance clash_set_data["check_all"] = clash_set.check_all - clash_sets.append(clash_set_data) + clash_sets.append(ifcclash.ClashSet(**clash_set_data)) return clash_sets @classmethod - def get_clash(cls, clash_set, a_global_id, b_global_id): + def get_clash( + cls, clash_set: ifcclash.ClashSet, a_global_id: str, b_global_id: str + ) -> Union[ifcclash.ClashResult, None]: clashes = clash_set.get("clashes", None) if not clashes: return return clashes.get(f"{a_global_id}-{b_global_id}", None) @classmethod - def get_clash_set(cls, name): + def get_clash_set(cls, name: str) -> Union[ifcclash.ClashSet, None]: for clash_set in ClashStore.clash_sets: if clash_set["name"] == name: return clash_set @classmethod - def get_clash_sets(cls): + def get_clash_sets(cls) -> list[ifcclash.ClashSet]: return ClashStore.clash_sets @classmethod - def import_active_clashes(cls): - clash_set = bpy.context.scene.BIMClashProperties.active_clash_set + def import_active_clashes(cls) -> None: + props = cls.get_clash_props() + clash_set = props.active_clash_set if not clash_set: return clash_set.clashes.clear() result = tool.Clash.get_clash_set(clash_set.name) + assert result is not None for clash in sorted(result.get("clashes", {}).values(), key=lambda x: x["distance"]): blender_clash = clash_set.clashes.add() blender_clash.a_global_id = clash["a_global_id"] @@ -91,17 +105,19 @@ class Clash(bonsai.core.tool.Clash): blender_clash.status = False if not "status" in clash.keys() else clash["status"] @classmethod - def load_clash_sets(cls, fn): + def load_clash_sets(cls, fn: str) -> None: with open(fn) as f: ClashStore.clash_sets = json.load(f) @classmethod - def look_at(cls, target, location): + def look_at(cls, target: Vector, location: Vector) -> None: camera_location = location area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") region = next(region for region in area.regions if region.type == "WINDOW") space = next(space for space in area.spaces if space.type == "VIEW_3D") override = {"area": area, "region": region, "space_data": space} + assert isinstance(space, bpy.types.SpaceView3D) + assert space.region_3d space.region_3d.view_location = target space.region_3d.view_rotation = Vector((camera_location - target)).to_track_quat("Z", "Y") space.region_3d.view_distance = (camera_location - target).length @@ -109,10 +125,8 @@ class Clash(bonsai.core.tool.Clash): class ClashStore: - clash_sets = None - path = None + clash_sets: list[ifcclash.ClashSet] = [] @staticmethod def purge(): - ClashStore.clash_sets = None - ClashStore.path = None + ClashStore.clash_sets = [] diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 2f669fe32e..fdef8c9c71 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -29,7 +29,7 @@ class Collector(bonsai.core.tool.Collector): """Links an object to an appropriate Blender collection.""" if should_clean_users_collection: for users_collection in obj.users_collection: - if obj.BIMObjectProperties.collection == users_collection: + if tool.Blender.get_object_bim_props(obj).collection == users_collection: continue # Users are free to use extra collections for their own # purposes except for the reserved keyword "Ifc" and @@ -44,6 +44,8 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. tool.Geometry.lock_scale(obj) + if element.is_a("IfcSlab"): + tool.Geometry.lock_rotation(obj, x=True) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): @@ -87,7 +89,7 @@ class Collector(bonsai.core.tool.Collector): if collection := cls._create_own_collection(obj): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_collection_child_safe(project_obj.BIMObjectProperties.collection, collection) + cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) elif ( tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcSpatialElement") @@ -98,21 +100,21 @@ class Collector(bonsai.core.tool.Collector): if collection := cls._create_own_collection(obj): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_collection_child_safe(project_obj.BIMObjectProperties.collection, collection) + cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": if collection := cls._create_own_collection(obj): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - cls.link_collection_child_safe(project_obj.BIMObjectProperties.collection, collection) + cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)): - cls.link_collection_object_safe(drawing_obj.BIMObjectProperties.collection, obj) + cls.link_collection_object_safe(tool.Blender.get_object_bim_props(drawing_obj).collection, obj) elif container := ifcopenshell.util.element.get_container(element): while container.is_a("IfcSpace"): container = ifcopenshell.util.element.get_aggregate(container) container_obj = tool.Ifc.get_object(container) - if not (collection := container_obj.BIMObjectProperties.collection): + if not (collection := tool.Blender.get_object_bim_props(container_obj).collection): cls.assign(container_obj) - collection = container_obj.BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(container_obj).collection cls.link_collection_object_safe(collection, obj) else: collection = cls._create_project_child_collection("Unsorted") @@ -128,7 +130,7 @@ class Collector(bonsai.core.tool.Collector): return collection collection = bpy.data.collections.new(name) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_obj.BIMObjectProperties.collection.children.link(collection) + tool.Blender.get_object_bim_props(project_obj).collection.children.link(collection) if layer_collection := tool.Blender.get_layer_collection(collection): cls.set_layer_collection_visibility(layer_collection) return collection @@ -136,11 +138,12 @@ class Collector(bonsai.core.tool.Collector): @classmethod def _create_own_collection(cls, obj: bpy.types.Object) -> bpy.types.Collection: """get or create own collection for the element""" - if obj.BIMObjectProperties.collection: - obj.BIMObjectProperties.collection.name = obj.name + props = tool.Blender.get_object_bim_props(obj) + if props.collection: + props.collection.name = obj.name return collection = bpy.data.collections.new(obj.name) - obj.BIMObjectProperties.collection = collection + props.collection = collection collection.BIMCollectionProperties.obj = obj return collection @@ -184,7 +187,8 @@ class Collector(bonsai.core.tool.Collector): @classmethod def reset_default_visibility(cls) -> None: project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_collection = project.BIMObjectProperties.collection + assert project + project_collection = tool.Blender.get_object_bim_props(project).collection for layer_collection in bpy.context.view_layer.layer_collection.children: if layer_collection.collection == project_collection: for layer_collection2 in layer_collection.children: diff --git a/src/bonsai/bonsai/tool/covering.py b/src/bonsai/bonsai/tool/covering.py index 5141cbffbd..c83a6f3e09 100644 --- a/src/bonsai/bonsai/tool/covering.py +++ b/src/bonsai/bonsai/tool/covering.py @@ -16,17 +16,26 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import ifcopenshell import ifcopenshell.util.element import bonsai.core.tool import bonsai.tool as tool +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.covering.prop import BIMCoveringProperties class Covering(bonsai.core.tool.Covering): + @classmethod + def get_covering_props(cls) -> BIMCoveringProperties: + return bpy.context.scene.BIMCoveringProperties + @classmethod def get_z_from_ceiling_height(cls) -> float: - props = bpy.context.scene.BIMCoveringProperties + props = cls.get_covering_props() return props.ceiling_height # def toggle_spaces_visibility_wired_and_textured(cls, spaces): diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 0dd9b6c855..51008e04a4 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import os import json import bpy @@ -30,10 +31,17 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from mathutils import Vector from collections import defaultdict -from typing import Iterable, Literal +from typing import Iterable, Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.debug.prop import BIMDebugProperties class Debug(bonsai.core.tool.Debug): + @classmethod + def get_debug_props(cls) -> BIMDebugProperties: + return bpy.context.scene.BIMDebugProperties + @classmethod def add_schema_identifier(cls, schema: W.schema_definition) -> None: IfcStore.schema_identifiers.append(schema.name()) @@ -46,7 +54,8 @@ class Debug(bonsai.core.tool.Debug): @classmethod def purge_hdf5_cache(cls) -> None: - cache_dir = bpy.context.scene.BIMProperties.cache_dir + props = tool.Blender.get_bim_props() + cache_dir = props.cache_dir filelist = [f for f in os.listdir(cache_dir) if f.endswith(".h5")] for f in filelist: try: @@ -54,6 +63,14 @@ class Debug(bonsai.core.tool.Debug): except PermissionError: pass + @classmethod + def debug_bmesh(cls, bm: bpy.types.BMesh, name: str = "Debug") -> bpy.types.Object: + mesh = bpy.data.meshes.new("Debug") + bm.to_mesh(mesh) + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + return obj + @classmethod def debug_geometry( cls, verts: list[Vector] = [], edges: list[tuple[int, int]] = [], name: str = "Debug" @@ -111,8 +128,7 @@ class Debug(bonsai.core.tool.Debug): """ def get_hash(element: ifcopenshell.entity_instance) -> int: - # TODO: replace with get_info_2 after bonsai build update. - return hash(json.dumps(element.get_info(include_identifier=False, recursive=True), sort_keys=True)) + return hash(json.dumps(element.get_info_2(include_identifier=False, recursive=True), sort_keys=True)) ifc_file = tool.Ifc.get() merged_element_types: dict[str, list[str]] = {} diff --git a/src/bonsai/bonsai/tool/document.py b/src/bonsai/bonsai/tool/document.py index 25799948fc..5d1e5eee54 100644 --- a/src/bonsai/bonsai/tool/document.py +++ b/src/bonsai/bonsai/tool/document.py @@ -16,56 +16,68 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import ifcopenshell.util.system import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool -from typing import Any, Union, Sequence +from typing import Any, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.document.prop import BIMDocumentProperties class Document(bonsai.core.tool.Document): + @classmethod + def get_document_props(cls) -> BIMDocumentProperties: + return bpy.context.scene.BIMDocumentProperties + @classmethod def add_breadcrumb(cls, document: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() new = props.breadcrumbs.add() new.name = str(document.id()) @classmethod def clear_breadcrumbs(cls) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() props.breadcrumbs.clear() @classmethod def clear_document_tree(cls) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() props.documents.clear() @classmethod def disable_editing_document(cls) -> None: - bpy.context.scene.BIMDocumentProperties.active_document_id = 0 + props = cls.get_document_props() + props.active_document_id = 0 @classmethod def disable_editing_ui(cls) -> None: - bpy.context.scene.BIMDocumentProperties.is_editing = False + props = cls.get_document_props() + props.is_editing = False @classmethod def enable_editing_ui(cls) -> None: - bpy.context.scene.BIMDocumentProperties.is_editing = True + props = cls.get_document_props() + props.is_editing = True @classmethod def export_document_attributes(cls) -> dict[str, Any]: - return bonsai.bim.helper.export_attributes(bpy.context.scene.BIMDocumentProperties.document_attributes) + props = cls.get_document_props() + return bonsai.bim.helper.export_attributes(props.document_attributes) @classmethod def get_active_breadcrumb(cls) -> Union[ifcopenshell.entity_instance, None]: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() if len(props.breadcrumbs): return tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) @classmethod def import_document_attributes(cls, document: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() props.document_attributes.clear() def callback(attr_name: str, _, data: dict[str, Any]) -> Union[bool, None]: @@ -86,7 +98,7 @@ class Document(bonsai.core.tool.Document): @classmethod def import_project_documents(cls) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() props.documents.clear() project = tool.Ifc.get().by_type("IfcProject")[0] for rel in project.HasAssociations or []: @@ -100,7 +112,7 @@ class Document(bonsai.core.tool.Document): @classmethod def import_references(cls, document: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() is_ifc2x3 = tool.Ifc.get_schema() == "IFC2X3" references = cls.get_document_references(document) for element in references: @@ -115,7 +127,7 @@ class Document(bonsai.core.tool.Document): @classmethod def import_subdocuments(cls, document: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() if document.IsPointer: for element in document.IsPointer[0].RelatedDocuments or []: new = props.documents.add() @@ -130,13 +142,14 @@ class Document(bonsai.core.tool.Document): @classmethod def remove_latest_breadcrumb(cls) -> None: - props = bpy.context.scene.BIMDocumentProperties + props = cls.get_document_props() if len(props.breadcrumbs): props.breadcrumbs.remove(len(props.breadcrumbs) - 1) @classmethod def set_active_document(cls, document: ifcopenshell.entity_instance) -> None: - bpy.context.scene.BIMDocumentProperties.active_document_id = document.id() + props = cls.get_document_props() + props.active_document_id = document.id() @classmethod def get_document_information_id(cls, document: ifcopenshell.entity_instance) -> Union[str, None]: diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 134f3bcf5f..92f234f71d 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -51,7 +51,7 @@ from shapely.ops import unary_union from lxml import etree from mathutils import Vector, Matrix from fractions import Fraction -from typing import Optional, Union, Iterable, Any, Literal, Sequence, TYPE_CHECKING +from typing import Optional, Union, Iterable, Any, Literal, Sequence, TYPE_CHECKING, NamedTuple from pathlib import Path if TYPE_CHECKING: @@ -65,25 +65,32 @@ class Drawing(bonsai.core.tool.Drawing): # ObjectType: annotation_name, description, icon, data_type # fmt: off - ANNOTATION_TYPES_DATA = { - "DIMENSION": ("Dimension", "Add dimensions annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "FIXED_SIZE", "curve"), - "ANGLE": ("Angle", "", "DRIVER_ROTATIONAL_DIFFERENCE", "curve"), - "RADIUS": ("Radius", "", "FORWARD", "curve"), - "DIAMETER": ("Diameter", "Add diameter annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "ARROW_LEFTRIGHT", "curve"), - "TEXT": ("Text", "", "SMALL_CAPS", "empty"), - "TEXT_LEADER": ("Leader", "", "TRACKING_BACKWARDS", "curve"), - "STAIR_ARROW": ("Stair Arrow", "Add stair arrow annotation.\nIf you have IfcStairFlight object selected, it will be used as a reference for the annotation", "SCREEN_BACK", "curve"), - "PLAN_LEVEL": ("Level (Plan)", "", "SORTBYEXT", "curve"), - "SECTION_LEVEL": ("Level (Section)", "", "TRIA_DOWN", "curve"), - "BREAKLINE": ("Breakline", "", "FCURVE", "mesh"), - "SYMBOL": ("Symbol", "", "KEYFRAME", "empty"), - "MULTI_SYMBOL": ("Multi-Symbol", "", "OUTLINER_DATA_POINTCLOUD", "mesh"), - "LINEWORK": ("Line", "", "SNAP_MIDPOINT", "mesh"), - "BATTING": ("Batting", "Add batting annotation.\nThickness could be changed through Thickness property of BBIM_Batting property set", "FORCE_FORCE", "mesh"), - "REVISION_CLOUD":("Revision Cloud", "Add revision cloud", "VOLUME_DATA", "mesh"), - "FILL_AREA": ("Fill Area", "", "NODE_TEXTURE", "mesh"), - "FALL": ("Fall", "", "SORT_ASC", "curve"), - "IMAGE": ("Image", "Add reference image attached to the drawing", "TEXTURE", "mesh"), + + class AnnotationObjectType(NamedTuple): + annotation_name: str + description: str + icon: str + data_type: Drawing.ANNOTATION_DATA_TYPE + + ANNOTATION_TYPES_DATA: dict[str, AnnotationObjectType] = { + "DIMENSION": AnnotationObjectType("Dimension", "Add dimensions annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "FIXED_SIZE", "curve"), + "ANGLE": AnnotationObjectType("Angle", "", "DRIVER_ROTATIONAL_DIFFERENCE", "curve"), + "RADIUS": AnnotationObjectType("Radius", "", "FORWARD", "curve"), + "DIAMETER": AnnotationObjectType("Diameter", "Add diameter annotation.\nMeasurement values can be hidden through ShowDescriptionOnly property\nof BBIM_Dimension property set", "ARROW_LEFTRIGHT", "curve"), + "TEXT": AnnotationObjectType("Text", "", "SMALL_CAPS", "empty"), + "TEXT_LEADER": AnnotationObjectType("Leader", "", "TRACKING_BACKWARDS", "curve"), + "STAIR_ARROW": AnnotationObjectType("Stair Arrow", "Add stair arrow annotation.\nIf you have IfcStairFlight object selected, it will be used as a reference for the annotation", "SCREEN_BACK", "curve"), + "PLAN_LEVEL": AnnotationObjectType("Level (Plan)", "", "SORTBYEXT", "curve"), + "SECTION_LEVEL": AnnotationObjectType("Level (Section)", "", "TRIA_DOWN", "curve"), + "BREAKLINE": AnnotationObjectType("Breakline", "", "FCURVE", "mesh"), + "SYMBOL": AnnotationObjectType("Symbol", "", "KEYFRAME", "empty"), + "MULTI_SYMBOL": AnnotationObjectType("Multi-Symbol", "", "OUTLINER_DATA_POINTCLOUD", "mesh"), + "LINEWORK": AnnotationObjectType("Line", "", "SNAP_MIDPOINT", "mesh"), + "BATTING": AnnotationObjectType("Batting", "Add batting annotation.\nThickness could be changed through Thickness property of BBIM_Batting property set", "FORCE_FORCE", "mesh"), + "REVISION_CLOUD":AnnotationObjectType("Revision Cloud", "Add revision cloud", "VOLUME_DATA", "mesh"), + "FILL_AREA": AnnotationObjectType("Fill Area", "", "NODE_TEXTURE", "mesh"), + "FALL": AnnotationObjectType("Fall", "", "SORT_ASC", "curve"), + "IMAGE": AnnotationObjectType("Image", "Add reference image attached to the drawing", "TEXTURE", "mesh"), } # fmt: on @@ -112,7 +119,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def get_annotation_data_type(cls, object_type: str) -> ANNOTATION_DATA_TYPE: - return cls.ANNOTATION_TYPES_DATA[object_type][3] + return cls.ANNOTATION_TYPES_DATA[object_type].data_type @classmethod def create_annotation_object(cls, drawing: ifcopenshell.entity_instance, object_type: str) -> bpy.types.Object: @@ -238,7 +245,7 @@ class Drawing(bonsai.core.tool.Drawing): element_type = element.is_a() - if element_type == "IfcAnnotation" and element.ObjectType in object_types: + if element_type == "IfcAnnotation" and ifcopenshell.util.element.get_predefined_type(element) in object_types: return True if element_type == "IfcTypeProduct" and ( @@ -346,19 +353,23 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def disable_editing_drawings(cls) -> None: - bpy.context.scene.DocProperties.is_editing_drawings = False + props = tool.Drawing.get_document_props() + props.is_editing_drawings = False @classmethod def disable_editing_schedules(cls) -> None: - bpy.context.scene.DocProperties.is_editing_schedules = False + props = tool.Drawing.get_document_props() + props.is_editing_schedules = False @classmethod def disable_editing_references(cls) -> None: - bpy.context.scene.DocProperties.is_editing_references = False + props = tool.Drawing.get_document_props() + props.is_editing_references = False @classmethod def disable_editing_sheets(cls) -> None: - bpy.context.scene.DocProperties.is_editing_sheets = False + props = tool.Drawing.get_document_props() + props.is_editing_sheets = False @classmethod def disable_editing_text(cls, obj: bpy.types.Object) -> None: @@ -383,19 +394,23 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def enable_editing_drawings(cls) -> None: - bpy.context.scene.DocProperties.is_editing_drawings = True + props = tool.Drawing.get_document_props() + props.is_editing_drawings = True @classmethod def enable_editing_schedules(cls) -> None: - bpy.context.scene.DocProperties.is_editing_schedules = True + props = tool.Drawing.get_document_props() + props.is_editing_schedules = True @classmethod def enable_editing_references(cls) -> None: - bpy.context.scene.DocProperties.is_editing_references = True + props = tool.Drawing.get_document_props() + props.is_editing_references = True @classmethod def enable_editing_sheets(cls) -> None: - bpy.context.scene.DocProperties.is_editing_sheets = True + props = tool.Drawing.get_document_props() + props.is_editing_sheets = True @classmethod def enable_editing_text(cls, obj: bpy.types.Object) -> None: @@ -509,7 +524,7 @@ class Drawing(bonsai.core.tool.Drawing): def get_drawing_collection(cls, drawing: ifcopenshell.entity_instance) -> Union[bpy.types.Collection, None]: obj = tool.Ifc.get_object(drawing) if obj: - return obj.BIMObjectProperties.collection + return tool.Blender.get_object_bim_props(obj).collection @classmethod def get_drawing_group(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: @@ -616,7 +631,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def is_editing_sheets(cls) -> bool: - return bpy.context.scene.DocProperties.is_editing_sheets + props = tool.Drawing.get_document_props() + return props.is_editing_sheets @classmethod def remove_literal_from_annotation(cls, obj: bpy.types.Object, literal: ifcopenshell.entity_instance) -> None: @@ -810,7 +826,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def import_drawings(cls) -> None: - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() expanded_target_views = {d.target_view for d in props.drawings if d.is_expanded} if not hasattr(cls, "drawing_selected_states"): cls.drawing_selected_states = {} @@ -1048,7 +1064,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def show_decorations(cls) -> None: - bpy.context.scene.DocProperties.should_draw_decorations = True + props = tool.Drawing.get_document_props() + props.should_draw_decorations = True @classmethod def update_text_value(cls, obj: bpy.types.Object) -> None: @@ -1147,36 +1164,34 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def get_default_layout_path(cls, identification: str, name: str) -> str: project = tool.Ifc.get().by_type("IfcProject")[0] + props = tool.Drawing.get_document_props() layouts_dir = ( - ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "LayoutsDir") - or bpy.context.scene.DocProperties.layouts_dir + ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "LayoutsDir") or props.layouts_dir ) return os.path.join(layouts_dir, cls.sanitise_filename(f"{identification} - {name}.svg")).replace("\\", "/") @classmethod def get_default_sheet_path(cls, identification: str, name: str) -> str: project = tool.Ifc.get().by_type("IfcProject")[0] - sheets_dir = ( - ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "SheetsDir") - or bpy.context.scene.DocProperties.sheets_dir - ) + props = tool.Drawing.get_document_props() + sheets_dir = ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "SheetsDir") or props.sheets_dir return os.path.join(sheets_dir, cls.sanitise_filename(f"{identification} - {name}.svg")).replace("\\", "/") @classmethod def get_default_titleblock_path(cls, name: str) -> str: project = tool.Ifc.get().by_type("IfcProject")[0] + props = tool.Drawing.get_document_props() titleblocks_dir = ( - ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "TitleblocksDir") - or bpy.context.scene.DocProperties.titleblocks_dir + ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "TitleblocksDir") or props.titleblocks_dir ) return os.path.join(titleblocks_dir, cls.sanitise_filename(f"{name}.svg")).replace("\\", "/") @classmethod def get_default_drawing_path(cls, name: str) -> str: project = tool.Ifc.get().by_type("IfcProject")[0] + props = tool.Drawing.get_document_props() drawings_dir = ( - ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "DrawingsDir") - or bpy.context.scene.DocProperties.drawings_dir + ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", "DrawingsDir") or props.drawings_dir ) return os.path.join(drawings_dir, cls.sanitise_filename(f"{name}.svg")).replace("\\", "/") @@ -1187,15 +1202,16 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def get_default_drawing_resource_path(cls, resource: str) -> Union[str, None]: project = tool.Ifc.get().by_type("IfcProject")[0] + props = tool.Drawing.get_document_props() resource_path = ifcopenshell.util.element.get_pset(project, "BBIM_Documentation", f"{resource}Path") or getattr( - bpy.context.scene.DocProperties, f"{resource.lower()}_path" + props, f"{resource.lower()}_path" ) if resource_path: return resource_path.replace("\\", "/") @classmethod def get_default_shading_style(cls) -> str: - dprops = bpy.context.scene.DocProperties + dprops = tool.Drawing.get_document_props() return dprops.shadingstyle_default @classmethod @@ -1500,8 +1516,8 @@ class Drawing(bonsai.core.tool.Drawing): dst = src.copy() dst.data = dst.data.copy() dst.name = dst.name.replace("IfcGridAxis/", "") - dst.BIMObjectProperties.ifc_definition_id = 0 - dst.data.BIMMeshProperties.ifc_definition_id = 0 + tool.Blender.get_object_bim_props(dst).ifc_definition_id = 0 + tool.Geometry.get_mesh_props(dst.data).ifc_definition_id = 0 return dst def disassemble(obj: bpy.types.Object) -> tuple[bpy.types.Object, bmesh.types.BMesh]: @@ -1872,7 +1888,7 @@ class Drawing(bonsai.core.tool.Drawing): return bool( camera is not None and camera.type == "CAMERA" - and camera.BIMObjectProperties.ifc_definition_id + and tool.Blender.get_ifc_definition_id(camera) and area is not None ) @@ -1883,7 +1899,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def is_active_drawing(cls, drawing: ifcopenshell.entity_instance) -> bool: - return drawing.id() == bpy.context.scene.DocProperties.active_drawing_id + props = tool.Drawing.get_document_props() + return drawing.id() == props.active_drawing_id @classmethod def run_drawing_activate_model(cls) -> None: @@ -1893,11 +1910,12 @@ class Drawing(bonsai.core.tool.Drawing): def isolate_camera_collection(cls, camera: bpy.types.Object) -> None: drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"] drawing_collections = [] - camera_collection = camera.BIMObjectProperties.collection + camera_collection = tool.Blender.get_object_bim_props(camera).collection for drawing in drawings: if not (drawing_obj := tool.Ifc.get_object(drawing)): continue - if not (drawing_collection := drawing_obj.BIMObjectProperties.collection): + oprops = tool.Blender.get_object_bim_props(drawing_obj) + if not (drawing_collection := oprops.collection): continue if drawing_obj == camera: drawing_collection.hide_render = False @@ -1905,7 +1923,7 @@ class Drawing(bonsai.core.tool.Drawing): drawing_collection.hide_render = True project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_collection = project.BIMObjectProperties.collection + project_collection = tool.Blender.get_object_bim_props(project).collection for layer_collection in bpy.context.view_layer.layer_collection.children: if layer_collection.collection == project_collection: for layer_collection2 in layer_collection.children: @@ -2094,6 +2112,7 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def bisect_mesh(cls, obj: bpy.types.Object, camera: bpy.types.Object) -> tuple[list[Vector], list[list[int]]]: + # TODO consolidate with other bisect functions camera_matrix = obj.matrix_world.inverted() @ camera.matrix_world plane_co = camera_matrix.translation plane_no = camera_matrix.col[2].xyz @@ -2103,10 +2122,38 @@ class Drawing(bonsai.core.tool.Drawing): return cls.bisect_mesh_with_plane(obj, plane_co, plane_no, global_offset=global_offset) + @classmethod + def bisect_bmesh(cls, obj, bm, geom, camera): + # TODO consolidate with other bisect functions + camera_matrix = obj.matrix_world.inverted() @ camera.matrix_world + plane_co = camera_matrix.translation + plane_no = camera_matrix.col[2].xyz + + global_offset = camera.matrix_world.col[2].xyz * -camera.data.clip_start + + # Run the bisect operation + results = bmesh.ops.bisect_plane(bm, geom=geom, dist=0.0001, plane_co=plane_co, plane_no=plane_no) + + vert_map = {} + verts = [] + edges = [] + i = 0 + for geom in results["geom_cut"]: + if isinstance(geom, bmesh.types.BMVert): + verts.append(tuple((obj.matrix_world @ geom.co) + global_offset)) + vert_map[geom.index] = i + i += 1 + else: + # It seems as though edges always appear after verts + edges.append([vert_map[v.index] for v in geom.verts]) + + return verts, edges + @classmethod def bisect_mesh_with_plane( cls, obj: bpy.types.Object, plane_co: Vector, plane_no: Vector, global_offset: Optional[Vector] = None ) -> tuple[list[Vector], list[list[int]]]: + # TODO consolidate with other bisect functions if global_offset is None: global_offset = Vector() @@ -2134,6 +2181,16 @@ class Drawing(bonsai.core.tool.Drawing): return verts, edges + @classmethod + def get_extrusion_vector(cls, wall): + if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + return Vector(item.ExtrudedDirection.DirectionRatios) + return Vector([0.0, 0.0, 1.0]) + @classmethod def get_scale_ratio(cls, scale: str) -> float: numerator, denominator = scale.split("/") diff --git a/src/bonsai/bonsai/tool/feature.py b/src/bonsai/bonsai/tool/feature.py index d8fd8921eb..0990aef008 100644 --- a/src/bonsai/bonsai/tool/feature.py +++ b/src/bonsai/bonsai/tool/feature.py @@ -16,17 +16,25 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.core.tool import bonsai.tool as tool import bonsai.bim.helper import ifcopenshell -from typing import Iterable +from typing import Iterable, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.void.prop import BIMBooleanProperties class Feature(bonsai.core.tool.Feature): # TODO: consolidate module/model/opening and module/void into new module/feature + @classmethod + def get_boolean_props(cls) -> BIMBooleanProperties: + return bpy.context.scene.BIMBooleanProperties + @classmethod def add_feature(cls, featured_obj: bpy.types.Object, feature_objs: Iterable[bpy.types.Object]) -> None: featured_element = tool.Ifc.get_entity(featured_obj) diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 40ee6dccd1..2975bbdbcb 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -27,6 +27,7 @@ import numpy.typing as npt import multiprocessing import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.geometry import ifcopenshell.api.grid import ifcopenshell.api.profile import ifcopenshell.api.style @@ -53,11 +54,23 @@ from math import radians, pi from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree from bonsai.bim.ifc import IfcStore -from typing import Union, Iterable, Optional, Literal, Iterator, List, TYPE_CHECKING, get_args, Generator, cast +from typing import ( + Union, + Iterable, + Optional, + Literal, + Iterator, + List, + TYPE_CHECKING, + get_args, + Generator, + cast, + TypeGuard, +) from typing_extensions import TypeIs if TYPE_CHECKING: - from bonsai.bim.prop import Attribute + from bonsai.bim.prop import Attribute, BIMMeshProperties from bonsai.bim.module.geometry.prop import BIMObjectGeometryProperties, BIMGeometryProperties @@ -70,6 +83,10 @@ class Geometry(bonsai.core.tool.Geometry): def get_object_geometry_props(cls, object: bpy.types.Object) -> BIMObjectGeometryProperties: return object.BIMGeometryProperties + @classmethod + def get_mesh_props(cls, mesh: TYPES_WITH_MESH_PROPERTIES) -> BIMMeshProperties: + return mesh.BIMMeshProperties + @classmethod def change_object_data(cls, obj: bpy.types.Object, data: bpy.types.ID, is_global: bool = False) -> None: if is_global: @@ -130,11 +147,11 @@ class Geometry(bonsai.core.tool.Geometry): def is_locked(cls, element: ifcopenshell.entity_instance) -> bool: if element.is_a("IfcProject"): return True - elif tool.Root.is_spatial_element(element) and bpy.context.scene.BIMSpatialDecompositionProperties.is_locked: + elif tool.Root.is_spatial_element(element) and tool.Spatial.get_spatial_props().is_locked: return True elif ( element.is_a("IfcPositioningElement") or element.is_a("IfcGrid") or element.is_a("IfcGridAxis") - ) and bpy.context.scene.BIMGridProperties.is_locked: + ) and tool.Spatial.get_grid_props().is_locked: return True return False @@ -160,6 +177,16 @@ class Geometry(bonsai.core.tool.Geometry): def unlock_scale(cls, obj: bpy.types.Object) -> None: obj.lock_scale = (False, False, False) + @classmethod + def lock_rotation( + cls, + obj: bpy.types.Object, + x: bool = False, + y: bool = False, + z: bool = False, + ) -> None: + obj.lock_rotation = (x, y, z) + @classmethod def unlock_scale_object_with_openings(cls, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) @@ -182,7 +209,9 @@ class Geometry(bonsai.core.tool.Geometry): if item_obj.obj == obj: props.item_objs.remove(i) break - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id) cls.remove_representation_item(item) cls.reload_representation(props.representation_obj) bpy.data.objects.remove(obj) @@ -216,7 +245,7 @@ class Geometry(bonsai.core.tool.Geometry): bpy.data.objects.remove(axis_obj) ifcopenshell.api.grid.remove_grid_axis(tool.Ifc.get(), axis=axis) - collection = obj.BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(obj).collection if collection: parent = ifcopenshell.util.element.get_aggregate(element) if not parent: @@ -224,7 +253,7 @@ class Geometry(bonsai.core.tool.Geometry): if parent: parent_obj = tool.Ifc.get_object(parent) if parent_obj: - parent_collection = parent_obj.BIMObjectProperties.collection + parent_collection = tool.Blender.get_object_bim_props(parent_obj).collection for child in collection.children: parent_collection.children.link(child) for child_object in collection.objects: @@ -249,17 +278,20 @@ class Geometry(bonsai.core.tool.Geometry): bonsai.core.system.remove_port(tool.Ifc, tool.System, port=port) ifcopenshell.api.run("root.remove_product", tool.Ifc.get(), product=element) - if isinstance(obj.data, bpy.types.Mesh) and not tool.Ifc.get_entity_by_id( - obj.data.BIMMeshProperties.ifc_definition_id + data = obj.data + if ( + tool.Geometry.has_mesh_properties(data) + and tool.Ifc.get_entity_by_id(tool.Geometry.get_mesh_props(data).ifc_definition_id) is None ): - tool.Blender.remove_data_block(obj.data) + tool.Blender.remove_data_block(data) if is_spatial: bonsai.core.spatial.import_spatial_decomposition(tool.Spatial) try: obj.name - if bpy.context.scene.BIMGeometryProperties.representation_obj == obj: - bpy.context.scene.BIMGeometryProperties.representation_obj = None + props = tool.Geometry.get_geometry_props() + if props.representation_obj == obj: + props.representation_obj = None bpy.data.objects.remove(obj) except: pass @@ -268,7 +300,9 @@ class Geometry(bonsai.core.tool.Geometry): def dissolve_triangulated_edges(cls, obj: bpy.types.Object) -> None: # AdvancedBreps may contain non-faceted, curved faces (e.g. as part of # a cylinder) so dissolving edges should not be allowed. - mesh_element = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + mesh = obj.data + assert isinstance(mesh, Geometry.TYPES_WITH_MESH_PROPERTIES) + mesh_element = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id) if ( ( mesh_element.is_a("IfcShapeRepresentation") @@ -279,26 +313,30 @@ class Geometry(bonsai.core.tool.Geometry): or not obj.data ): return - if hasattr(obj.data, "attributes") and (ios_edges_attribute := obj.data.attributes.get("ios_edges")): + + if not isinstance(mesh, bpy.types.Mesh): + return + + if hasattr(mesh, "attributes") and (ios_edges_attribute := mesh.attributes.get("ios_edges")): # Edges from a forced triangulation are stored as True in a boolean attribute on the mesh bm = bmesh.new() - bm.from_mesh(obj.data) + bm.from_mesh(mesh) edges_to_dissolve = [e for i, e in enumerate(bm.edges) if not ios_edges_attribute.data[i].value] bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) - bm.to_mesh(obj.data) + bm.to_mesh(mesh) bm.free() - elif "ios_edges" in obj.data: + elif "ios_edges" in mesh: bm = bmesh.new() - bm.from_mesh(obj.data) - edges_to_keep = set(map(frozenset, obj.data["ios_edges"])) + bm.from_mesh(mesh) + edges_to_keep = set(map(frozenset, mesh["ios_edges"])) edges_to_dissolve = [] for edge in bm.edges: if frozenset([vert.index for vert in edge.verts]) not in edges_to_keep: edges_to_dissolve.append(edge) bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve) - bm.to_mesh(obj.data) + bm.to_mesh(mesh) bm.free() - del obj.data["ios_edges"] + del mesh["ios_edges"] @classmethod def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None: @@ -486,13 +524,19 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_active_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: """:return: IfcRepresentation/IfcRepresentationItem or None""" - if obj.data and hasattr(obj.data, "BIMMeshProperties") and obj.data.BIMMeshProperties.ifc_definition_id: - return tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + if ( + (data := obj.data) + and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) + and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) + ): + return tool.Ifc.get().by_id(ifc_id) @classmethod - def get_data_representation(cls, data: bpy.types.Mesh) -> ifcopenshell.entity_instance | None: - if hasattr(data, "BIMMeshProperties") and data.BIMMeshProperties.ifc_definition_id: - return tool.Ifc.get().by_id(data.BIMMeshProperties.ifc_definition_id) + def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None: + if isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) and ( + ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id + ): + return tool.Ifc.get().by_id(ifc_id) @classmethod def get_active_representation_context(cls, obj: bpy.types.Object) -> ifcopenshell.entity_instance: @@ -523,11 +567,9 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_cartesian_point_offset(cls, obj: bpy.types.Object) -> npt.NDArray[np.float64] | None: - if ( - obj.BIMObjectProperties.blender_offset_type == "CARTESIAN_POINT" - and obj.BIMObjectProperties.cartesian_point_offset - ): - return np.array(tuple(map(float, obj.BIMObjectProperties.cartesian_point_offset.split(",")))) + props = tool.Blender.get_object_bim_props(obj) + if props.blender_offset_type == "CARTESIAN_POINT" and props.cartesian_point_offset: + return np.array(tuple(map(float, props.cartesian_point_offset.split(",")))) @classmethod def get_element_type(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: @@ -607,7 +649,7 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_object_materials_without_styles(cls, obj: bpy.types.Object) -> list[bpy.types.Material]: return [ - s.material for s in obj.material_slots if s.material and not s.material.BIMStyleProperties.ifc_definition_id + s.material for s in obj.material_slots if s.material and not tool.Blender.get_ifc_definition_id(s.material) ] @classmethod @@ -670,13 +712,13 @@ class Geometry(bonsai.core.tool.Geometry): return data.users != 0 @classmethod - def has_geometric_data(cls, obj: bpy.types.Object) -> bool: - if not obj.data: + def is_geometric_data(cls, data: Union[bpy.types.ID, None]) -> TypeGuard[Union[bpy.types.Mesh, bpy.types.Curve]]: + if not data: return False - if isinstance(obj.data, bpy.types.Mesh): - return bool(obj.data.vertices) - elif isinstance(obj.data, bpy.types.Curve): - return bool(obj.data.splines) + if isinstance(data, bpy.types.Mesh): + return bool(data.vertices) + elif isinstance(data, bpy.types.Curve): + return bool(data.splines) return False @classmethod @@ -826,7 +868,8 @@ class Geometry(bonsai.core.tool.Geometry): ifc_importer.material_creator.load_existing_materials() shape_has_openings = cls.does_shape_has_openings(shape) ifc_importer.material_creator.create(element, obj, mesh, shape_has_openings) - mesh.BIMMeshProperties.has_openings_applied = apply_openings + mprops = tool.Geometry.get_mesh_props(mesh) + mprops.has_openings_applied = apply_openings if not shape_has_openings: tool.Loader.load_indexed_colour_map(representation, mesh) tool.Loader.link_mesh(shape, mesh) @@ -852,7 +895,8 @@ class Geometry(bonsai.core.tool.Geometry): ifc_importer.material_creator.load_existing_materials() shape_has_openings = False ifc_importer.material_creator.create(element, obj, mesh, shape_has_openings) - mesh.BIMMeshProperties.has_openings_applied = apply_openings + mprops = tool.Geometry.get_mesh_props(mesh) + mprops.has_openings_applied = apply_openings if not shape_has_openings: tool.Loader.load_indexed_colour_map(representation, mesh) meshes[mesh_name] = mesh @@ -867,7 +911,7 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def import_representation_parameters(cls, data: bpy.types.Mesh) -> None: - props = data.BIMMeshProperties + props = tool.Geometry.get_mesh_props(data) elements = tool.Ifc.get().traverse(tool.Ifc.get().by_id(props.ifc_definition_id)) props.ifc_parameters.clear() for element in elements: @@ -974,7 +1018,8 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def is_profile_based(cls, data: bpy.types.Mesh) -> bool: - return data.BIMMeshProperties.subshape_type == "PROFILE" + props = tool.Geometry.get_mesh_props(data) + return props.subshape_type == "PROFILE" @classmethod def is_profile_object_active(cls) -> bool: @@ -992,7 +1037,7 @@ class Geometry(bonsai.core.tool.Geometry): data = obj.data if ( isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) - and (ifc_id := data.BIMMeshProperties.ifc_definition_id) + and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem")) ): return item @@ -1008,14 +1053,14 @@ class Geometry(bonsai.core.tool.Geometry): if tool.Ifc.get_entity(obj): return obj elif tool.Geometry.is_representation_item(obj): - return bpy.context.scene.BIMGeometryProperties.representation_obj + return tool.Geometry.get_geometry_props().representation_obj @classmethod def is_boolean_operand(cls, obj: bpy.types.Object) -> bool: return bool( (data := obj.data) and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) - and (ifc_id := data.BIMMeshProperties.ifc_definition_id) + and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) and (item := tool.Ifc.get().by_id(ifc_id)) and ( item.is_a("IfcBooleanResult") @@ -1041,13 +1086,15 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def record_object_materials(cls, obj: bpy.types.Object) -> None: - obj.data.BIMMeshProperties.material_checksum = cls.get_material_checksum(obj) + props = tool.Geometry.get_mesh_props(obj.data) + props.material_checksum = cls.get_material_checksum(obj) @classmethod def record_object_position(cls, obj: bpy.types.Object) -> None: # These are recorded separately because they have different numerical tolerances - obj.BIMObjectProperties.location_checksum = repr(np.array(obj.matrix_world.translation).tobytes()) - obj.BIMObjectProperties.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3()).tobytes()) + props = tool.Blender.get_object_bim_props(obj) + props.location_checksum = repr(np.array(obj.matrix_world.translation).tobytes()) + props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3()).tobytes()) @classmethod def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None: @@ -1146,11 +1193,13 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def should_force_faceted_brep(cls) -> bool: - return bpy.context.scene.BIMGeometryProperties.should_force_faceted_brep + props = tool.Geometry.get_geometry_props() + return props.should_force_faceted_brep @classmethod def should_force_triangulation(cls) -> bool: - return bpy.context.scene.BIMGeometryProperties.should_force_triangulation + props = tool.Geometry.get_geometry_props() + return props.should_force_triangulation @classmethod def should_generate_uvs(cls, obj: bpy.types.Object) -> bool: @@ -1167,7 +1216,8 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def should_use_presentation_style_assignment(cls) -> bool: - return bpy.context.scene.BIMGeometryProperties.should_use_presentation_style_assignment + props = tool.Geometry.get_geometry_props() + return props.should_use_presentation_style_assignment @classmethod def get_model_representations(cls) -> list[ifcopenshell.entity_instance]: @@ -1238,7 +1288,8 @@ class Geometry(bonsai.core.tool.Geometry): In the most cases just use reload_representation as it will handle those complications by itself. """ - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + representation = cls.get_active_representation(obj) + assert representation bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -1353,6 +1404,9 @@ class Geometry(bonsai.core.tool.Geometry): also_consider = list(consider_inverses) ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider) + props = tool.Geometry.get_geometry_props() + rep_element = tool.Ifc.get_entity(props.representation_obj) + tool.Model.unmark_manual_booleans(rep_element, [b.id() for b in boolean_results_to_remove]) for boolean_result in boolean_results_to_remove: cls.remove_representation_item(boolean_result) @@ -1526,10 +1580,11 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_blender_offset_type(cls, obj: bpy.types.Object) -> Optional[str]: - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: - if (result := obj.BIMObjectProperties.blender_offset_type) == "NONE": - result = obj.BIMObjectProperties.blender_offset_type = "OBJECT_PLACEMENT" + props = tool.Blender.get_object_bim_props(obj) + if (result := props.blender_offset_type) == "NONE": + result = props.blender_offset_type = "OBJECT_PLACEMENT" return result @classmethod @@ -1605,15 +1660,13 @@ class Geometry(bonsai.core.tool.Geometry): Use `.RelatedOpeningElement` to get the opening element. """ - for element_rel in getattr(element, "HasOpenings", ()): - yield element_rel - - if aggregate := ifcopenshell.util.element.get_aggregate(element): - yield from cls.get_openings(aggregate) + # TODO: replace everywhere with util method. + return ifcopenshell.util.element.get_openings(element) @classmethod def has_openings(cls, element: ifcopenshell.entity_instance) -> bool: - return bool(next(cls.get_openings(element), False)) + # TODO: replace everywhere with util method. + return ifcopenshell.util.element.has_openings(element) @classmethod def get_elements_by_representation( @@ -1641,7 +1694,8 @@ class Geometry(bonsai.core.tool.Geometry): for item_obj in props.item_objs: if not (obj := item_obj.obj) or not tool.Ifc.is_moved(obj): continue - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + item = cls.get_active_representation(obj) + assert item if item.is_a("IfcSweptAreaSolid"): has_changed = True old_position = item.Position @@ -1683,7 +1737,7 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def import_item_attributes(cls, obj: bpy.types.Object) -> None: - props = obj.data.BIMMeshProperties + props = tool.Geometry.get_mesh_props(obj.data) props.item_attributes.clear() item = tool.Ifc.get().by_id(props.ifc_definition_id) allowed_attributes = [ @@ -1710,10 +1764,10 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def update_item_attributes(cls, obj: bpy.types.Object) -> None: - props = obj.data.BIMMeshProperties + props = tool.Geometry.get_mesh_props(obj.data) ifc_file = tool.Ifc.get() - item = tool.Ifc.get().by_id(props.ifc_definition_id) + item = ifc_file.by_id(props.ifc_definition_id) for attribute in props.item_attributes: setattr(item, attribute.name, attribute.get_value()) @@ -1738,7 +1792,9 @@ class Geometry(bonsai.core.tool.Geometry): tool.Loader.settings.contexts = ifcopenshell.util.representation.get_prioritised_contexts(tool.Ifc.get()) tool.Loader.settings.context_settings = tool.Loader.create_settings() tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True) - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + assert isinstance(obj.data, bpy.types.Mesh) + item = tool.Geometry.get_active_representation(obj) + assert item obj.data.clear_geometry() if item.is_a("IfcHalfSpaceSolid"): @@ -1759,7 +1815,7 @@ class Geometry(bonsai.core.tool.Geometry): geometry = tool.Loader.create_generic_shape(item) verts = ifcopenshell.util.shape.get_vertices(geometry) if (cartesian_point_offset := cls.get_cartesian_point_offset(rep_obj)) is not None: - verts -= cartesian_point_offset + verts = verts - cartesian_point_offset tool.Loader.convert_geometry_to_mesh(geometry, obj.data, verts=verts) if ios_materials := list(obj.data["ios_materials"]): @@ -1802,12 +1858,14 @@ class Geometry(bonsai.core.tool.Geometry): props.mode = "OBJECT" props.is_changing_mode = False props.representation_obj = None - bpy.context.scene.BIMBooleanProperties.is_editing = False + tool.Feature.get_boolean_props().is_editing = False @classmethod def edit_meshlike_item(cls, obj: bpy.types.Object) -> None: - item = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) - if obj.data.BIMMeshProperties.mesh_checksum == cls.get_mesh_checksum(obj.data): + item = tool.Geometry.get_active_representation(obj) + assert item + mprops = tool.Geometry.get_mesh_props(obj.data) + if mprops.mesh_checksum == cls.get_mesh_checksum(obj.data): return builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -1830,8 +1888,8 @@ class Geometry(bonsai.core.tool.Geometry): for inverse in tool.Ifc.get().get_inverse(item): ifcopenshell.util.element.replace_attribute(inverse, item, new_item) ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), item) - obj.data.BIMMeshProperties.ifc_definition_id = new_item.id() - cls.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Ifc.link(new_item, obj.data) + cls.reload_representation(props.representation_obj) @classmethod def split_by_loose_parts(cls, obj: bpy.types.Object) -> List[bpy.types.Mesh]: @@ -1936,3 +1994,7 @@ class Geometry(bonsai.core.tool.Geometry): bm = tool.Blender.get_bmesh_for_mesh(obj.data) bm.transform(obj.matrix_world) return BVHTree.FromBMesh(bm) + + @classmethod + def run_edit_object_placement(cls, obj: bpy.types.Object) -> None: + return bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) diff --git a/src/bonsai/bonsai/tool/georeference.py b/src/bonsai/bonsai/tool/georeference.py index 9cd80ce993..7b8ee46d24 100644 --- a/src/bonsai/bonsai/tool/georeference.py +++ b/src/bonsai/bonsai/tool/georeference.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import json import numpy as np @@ -27,24 +28,34 @@ import ifcopenshell.util.unit import bonsai.core.tool import bonsai.tool as tool import bonsai.bim.helper -from typing import Any, Union, Literal +from typing import Any, Union, Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.georeference.prop import BIMGeoreferenceProperties class Georeference(bonsai.core.tool.Georeference): COORDINATE_TYPE = Literal["blender", "local", "map"] + @classmethod + def get_georeference_props(cls) -> BIMGeoreferenceProperties: + return bpy.context.scene.BIMGeoreferenceProperties + @classmethod def add_georeferencing(cls) -> None: + props = cls.get_georeference_props() tool.Ifc.run( "georeference.add_georeferencing", - ifc_class=bpy.context.scene.BIMGeoreferenceProperties.coordinate_operation_class, + ifc_class=props.coordinate_operation_class, ) @classmethod def import_projected_crs(cls) -> None: + props = tool.Georeference.get_georeference_props() + def callback(name, prop, data): if name == "MapUnit": - new = bpy.context.scene.BIMGeoreferenceProperties.projected_crs.add() + new = props.projected_crs.add() new.name = name new.data_type = "enum" new.is_null = data[name] is None @@ -58,9 +69,9 @@ class Georeference(bonsai.core.tool.Georeference): ) if data["MapUnit"]: new.enum_value = str(data["MapUnit"].id()) + new.update = "tool.Georeference.update_map_unit" return True - props = bpy.context.scene.BIMGeoreferenceProperties props.projected_crs.clear() if tool.Ifc.get_schema() == "IFC2X3": @@ -72,11 +83,27 @@ class Georeference(bonsai.core.tool.Georeference): bonsai.bim.helper.import_attributes2(projected_crs, props.projected_crs, callback=callback) return + @classmethod + def update_map_unit(cls, self, context) -> None: + if unit_id := self.get_value(): + map_unit = tool.Ifc.get().by_id(int(unit_id)) + project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") + if map_unit and project_unit: + result = ifcopenshell.util.unit.convert_unit(1, project_unit, map_unit) + else: + result = 1.0 + else: + result = 1.0 + props = cls.get_georeference_props() + for attribute in props.coordinate_operation: + if attribute.name == "Scale": + attribute.set_value(str(result)) + @classmethod def import_coordinate_operation(cls) -> None: def callback(name, prop, data): if name in ("FirstCoordinate", "SecondCoordinate"): - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() if name == "FirstCoordinate": new = props.coordinate_operation.add() new.name = "Measure Type" @@ -94,7 +121,7 @@ class Georeference(bonsai.core.tool.Georeference): prop.string_value = "" if prop.is_null else str(data[name].wrappedValue) return True elif name == "XAxisAbscissa": - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() props.is_changing_angle = True if data["XAxisAbscissa"] is None or data["XAxisOrdinate"] is None: props.x_axis_is_null = True @@ -116,7 +143,7 @@ class Georeference(bonsai.core.tool.Georeference): prop.string_value = "" if prop.is_null else str(data[name]) return True - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() props.coordinate_operation.clear() if tool.Ifc.get_schema() == "IFC2X3": @@ -135,7 +162,7 @@ class Georeference(bonsai.core.tool.Georeference): if tool.Ifc.get_schema() == "IFC2X3": return - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() props.is_changing_angle = True props.true_north_abscissa = "0" props.true_north_ordinate = "1" @@ -159,7 +186,7 @@ class Georeference(bonsai.core.tool.Georeference): attributes[prop.name] = tool.Ifc.get().by_id(int(prop.enum_value)) return True - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() return bonsai.bim.helper.export_attributes(props.projected_crs, callback=callback) @classmethod @@ -175,7 +202,7 @@ class Georeference(bonsai.core.tool.Georeference): attributes[prop.name] = tool.Ifc.get().create_entity(measure_type, float(prop.string_value)) return True elif prop.name == "XAxisAbscissa": - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() if props.x_axis_is_null: attributes["XAxisAbscissa"] = None attributes["XAxisOrdinate"] = None @@ -190,12 +217,12 @@ class Georeference(bonsai.core.tool.Georeference): attributes[prop.name] = float(prop.string_value) return True - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() return bonsai.bim.helper.export_attributes(props.coordinate_operation, callback=callback) @classmethod def get_true_north_attributes(cls) -> Union[list[float], None]: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() try: return [float(props.true_north_abscissa), float(props.true_north_ordinate)] except ValueError: @@ -203,36 +230,42 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def enable_editing(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing = True + props = cls.get_georeference_props() + props.is_editing = True @classmethod def disable_editing(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing = False + props = cls.get_georeference_props() + props.is_editing = False @classmethod def enable_editing_wcs(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing_wcs = True + props = cls.get_georeference_props() + props.is_editing_wcs = True @classmethod def disable_editing_wcs(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing_wcs = False + props = cls.get_georeference_props() + props.is_editing_wcs = False @classmethod def enable_editing_true_north(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing_true_north = True + props = cls.get_georeference_props() + props.is_editing_true_north = True @classmethod def disable_editing_true_north(cls) -> None: - bpy.context.scene.BIMGeoreferenceProperties.is_editing_true_north = False + props = cls.get_georeference_props() + props.is_editing_true_north = False @classmethod def set_coordinates(cls, io: COORDINATE_TYPE, coordinates: list[float]) -> None: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() setattr(props, f"{io}_coordinates", ",".join([str(o) for o in coordinates])) @classmethod def get_coordinates(cls, io: COORDINATE_TYPE) -> list[float]: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() return [float(co) for co in getattr(props, f"{io}_coordinates").split(",")] @classmethod @@ -244,7 +277,7 @@ class Georeference(bonsai.core.tool.Georeference): def xyz2enh( cls, coordinates: tuple[float, float, float], should_return_in_map_units: bool = True ) -> tuple[float, float, float]: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() if props.has_blender_offset: coordinates = ifcopenshell.util.geolocation.xyz2enh( coordinates[0], @@ -263,7 +296,7 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def enh2xyz(cls, coordinates: tuple[float, float, float]) -> tuple[float, float, float]: coordinates = ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), *coordinates) - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() if props.has_blender_offset: coordinates = ifcopenshell.util.geolocation.enh2xyz( coordinates[0], @@ -313,7 +346,7 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def import_wcs(cls) -> None: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() wcs = None for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False): wcs = context.WorldCoordinateSystem @@ -330,7 +363,7 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def export_wcs(cls) -> dict[str, float]: - props = bpy.context.scene.BIMGeoreferenceProperties + props = cls.get_georeference_props() return { "x": float(props.wcs_x), "y": float(props.wcs_y), @@ -345,7 +378,7 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def set_model_origin(cls) -> None: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - gprops = bpy.context.scene.BIMGeoreferenceProperties + gprops = tool.Georeference.get_georeference_props() e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False) gprops.model_origin = f"{e},{n},{h}" gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}" @@ -359,4 +392,4 @@ class Georeference(bonsai.core.tool.Georeference): @classmethod def has_blender_offset(cls) -> bool: - return bpy.context.scene.BIMGeoreferenceProperties.has_blender_offset + return tool.Georeference.get_georeference_props().has_blender_offset diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index 5b3f8bb48a..8a2de3b742 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -56,6 +56,12 @@ class Ifc(bonsai.core.tool.Ifc): def get(cls) -> ifcopenshell.file: return IfcStore.get_file() + @classmethod + def set_path(cls, value: str) -> None: + bim_props = tool.Blender.get_bim_props() + bim_props.ifc_file = value + IfcStore.set_path(value) + @classmethod def get_path(cls) -> str: """Get absolute filepath to the IFC file, return empty string if file is not saved.""" @@ -66,24 +72,32 @@ class Ifc(bonsai.core.tool.Ifc): if IfcStore.get_file(): return IfcStore.get_file().schema + @classmethod + def clear_history(cls) -> None: + IfcStore.last_transaction = "" + IfcStore.history = [] + IfcStore.future = [] + @classmethod def is_edited(cls, obj: bpy.types.Object, *, ignore_scale: bool = False) -> bool: return (not ignore_scale and tool.Geometry.is_scaled(obj)) or obj in IfcStore.edited_objs @classmethod - def is_moved(cls, obj: bpy.types.Object) -> bool: - element = cls.get_entity(obj) - if not element and not tool.Geometry.is_representation_item(obj): - return False - if element and (element.is_a("IfcTypeProduct") or element.is_a("IfcProject")): - return False - if not obj.BIMObjectProperties.location_checksum: + def is_moved(cls, obj: bpy.types.Object, ifc_only: bool = True) -> bool: + if ifc_only: + element = cls.get_entity(obj) + if not element and not tool.Geometry.is_representation_item(obj): + return False + if element and (element.is_a("IfcTypeProduct") or element.is_a("IfcProject")): + return False + oprops = tool.Blender.get_object_bim_props(obj) + if not oprops.location_checksum: return True # Let's be conservative - loc_check = np.frombuffer(eval(obj.BIMObjectProperties.location_checksum)) + loc_check = np.frombuffer(eval(oprops.location_checksum)) loc_real = np.array(obj.matrix_world.translation).flatten() if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm return True - rot_check = np.frombuffer(eval(obj.BIMObjectProperties.rotation_checksum)).reshape(3, 3) + rot_check = np.frombuffer(eval(oprops.rotation_checksum)).reshape(3, 3) rot_real = np.array(obj.matrix_world.to_3x3()) rot_dot = np.dot(rot_check, rot_real.T) angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1)) @@ -107,11 +121,11 @@ class Ifc(bonsai.core.tool.Ifc): props = None if isinstance(obj, bpy.types.Object): - props = obj.BIMObjectProperties + props = tool.Blender.get_object_bim_props(obj) elif isinstance(obj, bpy.types.Material): - props = obj.BIMStyleProperties + props = tool.Style.get_material_style_props(obj) else: - props = obj.BIMMeshProperties + props = tool.Geometry.get_mesh_props(obj) if props and (ifc_definition_id := props.ifc_definition_id): try: @@ -129,9 +143,13 @@ class Ifc(bonsai.core.tool.Ifc): return None @classmethod - def get_object(cls, element: ifcopenshell.entity_instance) -> IFC_CONNECTED_TYPE: + def get_object(cls, element: ifcopenshell.entity_instance) -> Union[IFC_CONNECTED_TYPE, None]: return IfcStore.get_element(element.id()) + @classmethod + def get_object_by_identifier(cls, id_or_guid: Union[int, str]) -> Union[IFC_CONNECTED_TYPE, None]: + return IfcStore.get_element(id_or_guid) + @classmethod def rebuild_element_maps(cls) -> None: """Rebuilds the id_map and guid_map @@ -180,7 +198,7 @@ class Ifc(bonsai.core.tool.Ifc): cls.setup_listeners(obj) IfcStore.edited_objs = set() - edited_objs = bpy.context.scene.BIMProjectProperties.edited_objs + edited_objs = tool.Project.get_project_props().edited_objs for i in range(len(edited_objs))[::-1]: obj = edited_objs[i].obj if obj: @@ -220,7 +238,7 @@ class Ifc(bonsai.core.tool.Ifc): """ if obj in IfcStore.edited_objs: return - edited_objs = bpy.context.scene.BIMProjectProperties.edited_objs + edited_objs = tool.Project.get_project_props().edited_objs edited_objs.add().obj = obj IfcStore.edited_objs.add(obj) IfcStore.history_edit_object(obj, finish_editing=False) @@ -233,7 +251,7 @@ class Ifc(bonsai.core.tool.Ifc): """ if obj not in IfcStore.edited_objs: return - edited_objs = bpy.context.scene.BIMProjectProperties.edited_objs + edited_objs = tool.Project.get_project_props().edited_objs edited_objs.remove(next(i for i, o in enumerate(edited_objs) if o.obj == obj)) IfcStore.edited_objs.discard(obj) IfcStore.history_edit_object(obj, finish_editing=True) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 5b0f9b88e5..1fe721d2f7 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -340,8 +340,8 @@ class IfcGit: @classmethod def get_revisions_step_ids(cls) -> Union[STEP_IDS, None]: - - path_ifc = bpy.data.scenes["Scene"].BIMProperties.ifc_file + props = tool.Blender.get_bim_props() + path_ifc = tool.Blender.get_bim_props().ifc_file props = bpy.context.scene.IfcGitProperties repo = IfcGitRepo.repo item = props.ifcgit_commits[props.commit_index] @@ -370,24 +370,36 @@ class IfcGit: return step_ids @classmethod - def get_modified_shape_object_step_ids(cls, step_ids: STEP_IDS) -> STEP_IDS: + def get_modified_step_ids(cls, step_ids: STEP_IDS) -> STEP_IDS: model = tool.Ifc.get() - modified_shape_object_step_ids = {"modified": []} + modified_step_ids = {"modified": set()} - for step_id in step_ids["modified"]: - if model.by_id(step_id).is_a() == "IfcProductDefinitionShape": - product = model.by_id(step_id).ShapeOfProduct[0] - modified_shape_object_step_ids["modified"].append(product.id()) + for step_id in step_ids["modified"] | step_ids["added"]: + try: + entity = model.by_id(step_id) + except: + continue + if entity.is_a("IfcProductDefinitionShape"): + for product in entity.ShapeOfProduct: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcObjectPlacement"): + for product in entity.PlacesObject: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcTypeProduct") and entity.Types: + for related_object in entity.Types[0].RelatedObjects: + modified_step_ids["modified"].add(related_object.id()) - return modified_shape_object_step_ids + return modified_step_ids @classmethod - def update_step_ids(cls, step_ids: STEP_IDS, modified_shape_object_step_ids: STEP_IDS) -> STEP_IDS: + def update_step_ids(cls, step_ids: STEP_IDS, modified_step_ids: STEP_IDS) -> STEP_IDS: final_step_ids = {} final_step_ids["added"] = step_ids["added"] final_step_ids["removed"] = step_ids["removed"] - final_step_ids["modified"] = step_ids["modified"].union(modified_shape_object_step_ids["modified"]) + final_step_ids["modified"] = ( + step_ids["modified"].union(modified_step_ids["modified"]).difference(step_ids["added"]) + ) return final_step_ids @classmethod @@ -397,9 +409,9 @@ class IfcGit: bpy.ops.object.select_all(action="DESELECT") for obj in bpy.context.visible_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + props = tool.Blender.get_object_bim_props(obj) + if not (step_id := props.ifc_definition_id): continue - step_id = obj.BIMObjectProperties.ifc_definition_id if step_id in step_ids["modified"]: obj.color = (0.3, 0.3, 1.0, 1) obj.select_set(True) diff --git a/src/bonsai/bonsai/tool/library.py b/src/bonsai/bonsai/tool/library.py index c28173482b..f968a21625 100644 --- a/src/bonsai/bonsai/tool/library.py +++ b/src/bonsai/bonsai/tool/library.py @@ -16,52 +16,62 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import ifcopenshell import bpy import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool -from typing import Literal, Any, Union +from typing import Literal, Any, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.library.prop import BIMLibraryProperties class Library(bonsai.core.tool.Library): + @classmethod + def get_library_props(cls) -> BIMLibraryProperties: + return bpy.context.scene.BIMLibraryProperties + @classmethod def clear_editing_mode(cls) -> None: - bpy.context.scene.BIMLibraryProperties.editing_mode = "NONE" + cls.get_library_props().editing_mode = "NONE" @classmethod def export_library_attributes(cls) -> dict[str, Any]: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() return bonsai.bim.helper.export_attributes(props.library_attributes) @classmethod def export_reference_attributes(cls) -> dict[str, Any]: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() return bonsai.bim.helper.export_attributes(props.reference_attributes) @classmethod def get_active_library(cls) -> ifcopenshell.entity_instance: - return tool.Ifc.get().by_id(bpy.context.scene.BIMLibraryProperties.active_library_id) + props = cls.get_library_props() + return tool.Ifc.get().by_id(props.active_library_id) @classmethod def get_active_reference(cls) -> ifcopenshell.entity_instance: - return tool.Ifc.get().by_id(bpy.context.scene.BIMLibraryProperties.active_reference_id) + props = cls.get_library_props() + return tool.Ifc.get().by_id(props.active_reference_id) @classmethod def import_library_attributes(cls, library: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() props.library_attributes.clear() bonsai.bim.helper.import_attributes2(library, props.library_attributes) @classmethod def import_reference_attributes(cls, reference: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() props.reference_attributes.clear() bonsai.bim.helper.import_attributes2(reference, props.reference_attributes) @classmethod def import_references(cls, library: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() props.references.clear() if tool.Ifc.get_schema() == "IFC2X3": references = library.LibraryReference @@ -74,7 +84,7 @@ class Library(bonsai.core.tool.Library): @classmethod def set_active_library(cls, library: Union[ifcopenshell.entity_instance, None]) -> None: - props = bpy.context.scene.BIMLibraryProperties + props = cls.get_library_props() if library is None: props.active_library_id = 0 else: @@ -82,8 +92,10 @@ class Library(bonsai.core.tool.Library): @classmethod def set_active_reference(cls, reference: ifcopenshell.entity_instance) -> None: - bpy.context.scene.BIMLibraryProperties.active_reference_id = reference.id() + props = cls.get_library_props() + props.active_reference_id = reference.id() @classmethod def set_editing_mode(cls, mode: Literal["LIBRARY", "REFERENCES", "REFERENCE"]) -> None: - bpy.context.scene.BIMLibraryProperties.editing_mode = mode + props = cls.get_library_props() + props.editing_mode = mode diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 8e44f29c94..0e8dee0305 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -20,7 +20,6 @@ from __future__ import annotations import os import re import bpy -import math import bmesh import logging import ifcopenshell.geom @@ -36,6 +35,7 @@ import bonsai.bim.import_ifc import numpy as np import numpy.typing as npt from ifcopenshell.util.shape_builder import np_to_4d +from math import atan, radians from mathutils import Vector, Matrix from pathlib import Path from typing import Union, Any, Optional @@ -89,7 +89,7 @@ class Loader(bonsai.core.tool.Loader): @classmethod def get_mesh_name(cls, representation: ifcopenshell.entity_instance) -> str: - context_id = representation.ContextOfItems.id() if hasattr(representation, "ContextOfItems") else 0 + context_id = context.id() if (context := getattr(representation, "ContextOfItems", None)) else 0 return "{}/{}".format(context_id, representation.id()) @classmethod @@ -105,7 +105,7 @@ class Loader(bonsai.core.tool.Loader): mesh: tool.Geometry.TYPES_WITH_MESH_PROPERTIES, ) -> None: geometry = shape.geometry if hasattr(shape, "geometry") else shape - mesh.BIMMeshProperties.ifc_definition_id = int(geometry.id.split("-")[0]) + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = int(geometry.id.split("-")[0]) @classmethod def create_surface_style_shading( @@ -592,33 +592,42 @@ class Loader(bonsai.core.tool.Loader): opacity = opacity if opacity is not None else 1.0 data_list = [d + (opacity,) for d in data_list] - faces_tex_coord_data = {} - for tex_coord_index, face_remap in zip(texture_map, faces_remap, strict=True): - faces_tex_coord_data[tuple(face_remap)] = (tex_coord_index, face_remap) + if index_map.is_a("IfcIndexedColourMap") and len(index_map.Colours.ColourList) == 1: + # Early return scenario in case there is only one colour + data_colour = data_list[0] + for bface in bm.faces: + for loop in bface.loops: + loop[layer] = data_colour + elif len(texture_map) != len(faces_remap): + print(f"Warning: invalid index map found: {index_map}") + else: + faces_tex_coord_data = {} + for tex_coord_index, face_remap in zip(texture_map, faces_remap, strict=True): + faces_tex_coord_data[tuple(face_remap)] = (tex_coord_index, face_remap) - # Apply attribute to each face - for bface in bm.faces: - face = tuple(loop.vert.index for loop in bface.loops) - # Find the corresponding index in data list by matching ifc faceset with blender face. - data_index = None - if tex_coord_data := faces_tex_coord_data.get(face): - tex_coord_index, face_remap = tex_coord_data - # Subtract 1 as tex_coord_index starts with 1. - if map_type == "UV": - data_index = [tex_coord_index[face_remap.index(i)] - 1 for i in face] + # Apply attribute to each face + for bface in bm.faces: + face = tuple(loop.vert.index for loop in bface.loops) + # Find the corresponding index in data list by matching ifc faceset with blender face. + data_index = None + if tex_coord_data := faces_tex_coord_data.get(face): + tex_coord_index, face_remap = tex_coord_data + # Subtract 1 as tex_coord_index starts with 1. + if map_type == "UV": + data_index = [tex_coord_index[face_remap.index(i)] - 1 for i in face] + else: + data_index = [tex_coord_index - 1 for i in face] else: - data_index = [tex_coord_index - 1 for i in face] - else: - # This face may be part of another representation item - # Or we couldn't match it due to georeferencing. - continue + # This face may be part of another representation item + # Or we couldn't match it due to georeferencing. + continue - # apply uv to each loop - for loop, i in zip(bface.loops, data_index): - if map_type == "UV": - loop[layer].uv = data_list[i] - else: - loop[layer] = data_list[i] + # apply uv to each loop + for loop, i in zip(bface.loops, data_index): + if map_type == "UV": + loop[layer].uv = data_list[i] + else: + loop[layer] = data_list[i] # Finish up, write the bmesh back to the mesh bm.to_mesh(mesh) @@ -689,7 +698,7 @@ class Loader(bonsai.core.tool.Loader): project_north = 0 if has_offset or has_rotation: - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.blender_offset_x = str(model_offset[0]) props.blender_offset_y = str(model_offset[1]) props.blender_offset_z = str(model_offset[2]) @@ -738,7 +747,7 @@ class Loader(bonsai.core.tool.Loader): cls, element: ifcopenshell.entity_instance, is_gross: bool = False ) -> Union[ifcopenshell.geom.ShapeElementType, None]: context_settings = cls.settings.gross_context_settings if is_gross else cls.settings.context_settings - geometry_library = bpy.context.scene.BIMProjectProperties.geometry_library + geometry_library = tool.Project.get_project_props().geometry_library for settings in context_settings: try: result = ifcopenshell.geom.create_shape(settings, element, geometry_library=geometry_library) @@ -757,16 +766,15 @@ class Loader(bonsai.core.tool.Loader): coords = None if item.is_a("IfcCartesianPointList3D"): # PointCloud.c coords = np.array(item.CoordList) - vertex_list.extend(Vector(list(coordinates)) * unit_scale for coordinates in item.CoordList) # Is it ever used? In IFC4+ PointCloud is requiring 3D list, before IFC4 there were no coord lists at all. elif item.is_a("IfcCartesianPointList2D"): - vertex_list.extend(Vector(list(coordinates)).to_3d() * unit_scale for coordinates in item.CoordList) - elif item.is_a("IfcPoint"): # Point - if item.is_a("IfcCartesianPoint"): - vertex_list.append(Vector(list(item.Coordinates)) * unit_scale) - else: - # TODO: implement non cartesian point vertices. - continue + coords = np.array(item.CoordList) + coords = np.column_stack((coords, np.zeros(coords.shape[0]))) + elif item.is_a("IfcCartesianPoint"): # Point + coord = np.array(item.Coordinates) + if len(coord) == 2: + coord = np.append(coord, (0.0,)) + coords = np.array((coord,)) else: assert False assert coords is not None @@ -848,9 +856,9 @@ class Loader(bonsai.core.tool.Loader): camera.BIMCameraProperties.height = height if width > height: - fov = 2 * math.atan(width / (2 * abs_min_z)) + fov = 2 * atan(width / (2 * abs_min_z)) else: - fov = 2 * math.atan(height / (2 * abs_min_z)) + fov = 2 * atan(height / (2 * abs_min_z)) camera.angle = fov @@ -920,33 +928,34 @@ class Loader(bonsai.core.tool.Loader): @classmethod def apply_blender_offset_to_matrix_world(cls, obj: bpy.types.Object, matrix: np.ndarray) -> Matrix: - if ( - not obj.data - and tool.Cad.is_x(matrix[0][3], 0) - and tool.Cad.is_x(matrix[1][3], 0) - and tool.Cad.is_x(matrix[2][3], 0) - ): + """ + :param matrix: 4x4 numpy matrix. + """ + # Shouldn't mutate original matrix as we return a different object anyway. + M_TRANSLATION = (slice(0, 3), 3) + oprops = tool.Blender.get_object_bim_props(obj) + translation = matrix[M_TRANSLATION] + if not obj.data and np.allclose(translation, 0.0, atol=1e-5): # We assume any non-geometric matrix at 0,0,0 is not # positionally significant and is left alone. This handles # scenarios where often spatial elements are left at 0,0,0 and # everything else is at map coordinates. - obj.BIMObjectProperties.blender_offset_type = "NOT_APPLICABLE" + oprops.blender_offset_type = "NOT_APPLICABLE" return Matrix(matrix.tolist()) if obj.data and obj.data.get("has_cartesian_point_offset", None): - obj.BIMObjectProperties.blender_offset_type = "CARTESIAN_POINT" + oprops.blender_offset_type = "CARTESIAN_POINT" if cartesian_point_offset := obj.data.get("cartesian_point_offset", None): - obj.BIMObjectProperties.cartesian_point_offset = cartesian_point_offset + oprops.cartesian_point_offset = cartesian_point_offset + matrix = matrix.copy() offset_xyz = list(map(float, cartesian_point_offset.split(","))) + [1.0] offset_xyz = matrix @ offset_xyz - matrix[0][3] = offset_xyz[0] - matrix[1][3] = offset_xyz[1] - matrix[2][3] = offset_xyz[2] + matrix[M_TRANSLATION] = offset_xyz[:3] - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() if props.has_blender_offset: - if obj.BIMObjectProperties.blender_offset_type == "NONE": - obj.BIMObjectProperties.blender_offset_type = "OBJECT_PLACEMENT" + if oprops.blender_offset_type == "NONE": + oprops.blender_offset_type = "OBJECT_PLACEMENT" matrix = ifcopenshell.util.geolocation.global2local( matrix, float(props.blender_offset_x) * cls.unit_scale, @@ -996,11 +1005,7 @@ class Loader(bonsai.core.tool.Loader): else: edges = ifcopenshell.util.shape.get_edges(geometry) mesh.from_pydata(verts.tolist(), edges.tolist(), []) - # TODO: remove error handling after we update build in Bonsai. - try: - edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist() - except AttributeError: - edges_item_ids = [] + edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist() mesh["ios_edges_item_ids"] = edges_item_ids tool.Blender.Attribute.fill_attribute(mesh, "ios_edges_item_ids", "EDGE", "INT", edges_item_ids) tool.Blender.Attribute.fill_attribute(mesh, "ios_material_ids", "EDGE", "INT", geometry.material_ids) @@ -1009,6 +1014,98 @@ class Loader(bonsai.core.tool.Loader): mesh["ios_material_ids"] = ifcopenshell.util.shape.get_faces_material_style_ids(geometry).tolist() return mesh + @classmethod + def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh: + if not (material := ifcopenshell.util.element.get_material(element)): + return mesh + elif material.is_a("IfcMaterialLayerSetUsage"): + usage = material + layer_set = material.ForLayerSet + offset = usage.OffsetFromReferenceLine * cls.unit_scale + sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 + elif material.is_a("IfcMaterialLayerSet"): + usage = None + layer_set = material + offset = 0 + sense_factor = 1 + else: + return mesh + if len(layer_set.MaterialLayers) == 1: + return mesh + bm = bmesh.new() + bm.from_mesh(mesh) + prev_co = None + if not usage: + sense_factor = 1 # Assume the extrusion vector points in the direction sense + no = cls.get_extrusion_vector(element).normalized() + co = Vector((0.0, 0.0, offset)) + elif usage.LayerSetDirection == "AXIS2": + co = Vector((0.0, offset, 0.0)) + no = cls.get_extrusion_vector(element).normalized() + no = no.cross(Vector([1.0, 0.0, 0.0])) + elif usage.LayerSetDirection == "AXIS3": + co = Vector((0.0, 0.0, offset)) + no = cls.get_extrusion_vector(element).normalized() + no = Vector([0.0, 0.0, 1.0]) + elif usage.LayerSetDirection == "AXIS1": + co = Vector((0.0, 0.0, offset)) + no = cls.get_extrusion_vector(element).normalized() + no = Vector([1.0, 0.0, 0.0]) + no *= sense_factor + # Cache this + body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + styles = {} + has_layer_styles = False + for i, material in enumerate(mesh.materials): + if style := tool.Ifc.get_entity(material): + styles[style] = i + last_i = len(layer_set.MaterialLayers) - 1 + for i, layer in enumerate(layer_set.MaterialLayers): + if i != last_i: + prev_co = co.copy() + co += no * layer.LayerThickness * cls.unit_scale + bisect_geom = bmesh.ops.bisect_plane( + bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no + ) + bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"]) + if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)): + continue + if (material_index := styles.get(style, None)) is None: + material_index = len(mesh.materials) + mesh.materials.append(tool.Ifc.get_object(style)) + if i == last_i: + for face in bisect_geom["geom"]: + if isinstance(face, bmesh.types.BMFace): + center = face.calc_center_median() + if (center - co).dot(no) >= 0: + face.material_index = material_index + has_layer_styles = True + else: + for face in bisect_geom["geom"]: + if isinstance(face, bmesh.types.BMFace): + center = face.calc_center_median() + if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0: + face.material_index = material_index + has_layer_styles = True + + bmesh.ops.dissolve_limit(bm, angle_limit=radians(1), verts=bm.verts, edges=bm.edges, delimit={"MATERIAL"}) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + + bm.to_mesh(mesh) + bm.free() + mesh["has_layer_styles"] = has_layer_styles + return mesh + + @classmethod + def get_extrusion_vector(cls, wall): + if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + return Vector(item.ExtrudedDirection.DirectionRatios) + return Vector([0.0, 0.0, 1.0]) + @classmethod def create_mesh_from_shape( cls, @@ -1048,7 +1145,7 @@ class Loader(bonsai.core.tool.Loader): if is_triangulated: loop_start = np.arange(0, num_vertex_indices, 3, dtype="I") loop_total = np.full(total_faces, 3, dtype="I") - use_smooth = np.zeros(num_vertex_indices, dtype="?") + use_smooth = np.zeros(total_faces, dtype="?") mesh.loops.add(num_vertex_indices) mesh.loops.foreach_set("vertex_index", faces.ravel().astype("I")) @@ -1057,16 +1154,16 @@ class Loader(bonsai.core.tool.Loader): mesh.polygons.foreach_set("loop_total", loop_total) mesh.polygons.foreach_set("use_smooth", use_smooth) else: - # TODO: optimize using correct numpy array types. faces_array = np.array(geometry.faces, dtype=object) - loop_total = np.array(tuple(len(face) for face in faces_array), dtype="I") - loop_start = np.cumsum((0,) + loop_total)[:-1] - vertex_index = np.concatenate(faces_array) - use_smooth = np.zeros(num_vertex_indices, dtype="?") + total_faces = len(faces_array) + loop_total = np.fromiter((len(face) for face in faces_array), dtype="I") + loop_start = np.cumsum((0,) + loop_total, dtype="I")[:-1] + vertex_index = np.concatenate(faces_array, dtype="I", casting="unsafe") + use_smooth = np.zeros(total_faces, dtype="?") mesh.loops.add(len(vertex_index)) mesh.loops.foreach_set("vertex_index", vertex_index) - mesh.polygons.add(len(loop_start)) + mesh.polygons.add(total_faces) mesh.polygons.foreach_set("loop_start", loop_start) mesh.polygons.foreach_set("loop_total", loop_total) mesh.polygons.foreach_set("use_smooth", use_smooth) diff --git a/src/bonsai/bonsai/tool/material.py b/src/bonsai/bonsai/tool/material.py index 5dad3cbf63..0139a1b783 100644 --- a/src/bonsai/bonsai/tool/material.py +++ b/src/bonsai/bonsai/tool/material.py @@ -33,12 +33,22 @@ from typing_extensions import assert_never if TYPE_CHECKING: # Avoid circular imports. from bonsai.bim.module.material.prop import Material as MaterialItem + from bonsai.bim.module.material.prop import BIMMaterialProperties, BIMObjectMaterialProperties class Material(bonsai.core.tool.Material): + @classmethod + def get_material_props(cls) -> BIMMaterialProperties: + return bpy.context.scene.BIMMaterialProperties + + @classmethod + def get_object_material_props(cls, obj: bpy.types.Object) -> BIMObjectMaterialProperties: + return obj.BIMObjectMaterialProperties + @classmethod def disable_editing_materials(cls) -> None: - bpy.context.scene.BIMMaterialProperties.is_editing = False + props = tool.Material.get_material_props() + props.is_editing = False @classmethod def duplicate_material(cls, material: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: @@ -55,11 +65,13 @@ class Material(bonsai.core.tool.Material): @classmethod def enable_editing_materials(cls) -> None: - bpy.context.scene.BIMMaterialProperties.is_editing = True + props = tool.Material.get_material_props() + props.is_editing = True @classmethod def get_active_material_type(cls) -> str: - return bpy.context.scene.BIMMaterialProperties.material_type + props = tool.Material.get_material_props() + return props.material_type @classmethod def get_elements_by_material(cls, material: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: @@ -68,7 +80,7 @@ class Material(bonsai.core.tool.Material): @classmethod def get_active_material_item(cls) -> Union[MaterialItem, None]: """Get active material props item if index is valid, otherwise, return None.""" - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() if 0 <= props.active_material_index < len(props.materials): return props.materials[props.active_material_index] return None @@ -80,7 +92,7 @@ class Material(bonsai.core.tool.Material): @classmethod def import_material_definitions(cls, material_type: str) -> None: - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() # Store active category name to reselect it later. # Occurs when we expand/contract all categories. @@ -140,7 +152,8 @@ class Material(bonsai.core.tool.Material): @classmethod def is_editing_materials(cls) -> bool: - return bpy.context.scene.BIMMaterialProperties.is_editing + props = tool.Material.get_material_props() + return props.is_editing @classmethod def is_material_used_in_sets(cls, material: ifcopenshell.entity_instance) -> bool: @@ -156,23 +169,24 @@ class Material(bonsai.core.tool.Material): @classmethod def load_material_attributes(cls, material: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() props.material_attributes.clear() bonsai.bim.helper.import_attributes2(material, props.material_attributes) @classmethod def enable_editing_material(cls, material: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() props.active_material_id = material.id() props.editing_material_type = "ATTRIBUTES" @classmethod def get_material_attributes(cls) -> dict[str, Any]: - return bonsai.bim.helper.export_attributes(bpy.context.scene.BIMMaterialProperties.material_attributes) + props = tool.Material.get_material_props() + return bonsai.bim.helper.export_attributes(props.material_attributes) @classmethod def disable_editing_material(cls) -> None: - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() props.active_material_id = 0 props.editing_material_type = "" @@ -193,11 +207,14 @@ class Material(bonsai.core.tool.Material): @classmethod def get_object_ui_material_type(cls) -> str: active_obj = bpy.context.active_object - return active_obj.BIMObjectMaterialProperties.material_type + assert active_obj + return tool.Material.get_object_material_props(active_obj).material_type @classmethod def get_object_ui_active_material(cls) -> ifcopenshell.entity_instance: - return tool.Ifc.get().by_id(int(bpy.context.active_object.BIMObjectMaterialProperties.material)) + obj = bpy.context.active_object + assert obj + return tool.Ifc.get().by_id(int(tool.Material.get_object_material_props(obj).material)) @classmethod def get_material( diff --git a/src/bonsai/bonsai/tool/misc.py b/src/bonsai/bonsai/tool/misc.py index 87d367895b..db99dd89d2 100644 --- a/src/bonsai/bonsai/tool/misc.py +++ b/src/bonsai/bonsai/tool/misc.py @@ -99,7 +99,7 @@ class Misc(bonsai.core.tool.Misc): bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) @classmethod - def split_objects_with_cutter( + def boolean_objects_with_cutter( cls, objs: list[bpy.types.Object], cutter: bpy.types.Object ) -> list[bpy.types.Object]: cutter_mesh = cutter.data @@ -113,10 +113,11 @@ class Misc(bonsai.core.tool.Misc): new_objs = [] for obj in objs: - if obj.type != "MESH" or obj == cutter: + mesh = obj.data + if not isinstance(mesh, bpy.types.Mesh) or obj == cutter: continue new_obj = obj.copy() - new_obj.data = obj.data.copy() + new_obj.data = mesh.copy() for collection in obj.users_collection: collection.objects.link(new_obj) @@ -143,3 +144,74 @@ class Misc(bonsai.core.tool.Misc): bm.free() bm_flipped.free() return new_objs + + @classmethod + def bisect_objects_with_cutter( + cls, objs: list[bpy.types.Object], cutter: bpy.types.Object + ) -> list[bpy.types.Object]: + cutter_mesh = cutter.data + assert isinstance(cutter_mesh, bpy.types.Mesh) + + bm = bmesh.new() + bm.from_mesh(cutter_mesh) + bm.faces.ensure_lookup_table() + + planes = [] + for face in bm.faces: + no = face.normal.to_4d() + no.w = 0.0 + no = cutter.matrix_world @ no + co = cutter.matrix_world @ face.verts[0].co + planes.append((co, no)) + + bm.free() + + new_objs = [] + for obj in objs: + if not isinstance(obj.data, bpy.types.Mesh) or obj == cutter: + continue + matrix_i = obj.matrix_world.inverted() + bm_obj = bmesh.new() + bm_obj.from_mesh(obj.data) + + bms = [bm_obj.copy()] + for co, no in planes: + co = matrix_i @ co + no = (matrix_i @ no).to_3d() + new_bms = [] + for bm in bms: + bm1 = bm.copy() + bm2 = bm.copy() + geom = bm1.verts[:] + bm1.edges[:] + bm1.faces[:] + bisect1 = bmesh.ops.bisect_plane( + bm1, geom=geom, dist=0.0001, plane_co=co, plane_no=no, clear_inner=True + ) + if not bisect1["geom"] or not [g for g in bisect1["geom"] if isinstance(g, bmesh.types.BMFace)]: + new_bms.append(bm) + continue + edges = [g for g in bisect1["geom_cut"] if isinstance(g, bmesh.types.BMEdge)] + bmesh.ops.triangle_fill(bm1, use_dissolve=True, edges=edges) + geom = bm2.verts[:] + bm2.edges[:] + bm2.faces[:] + bisect2 = bmesh.ops.bisect_plane( + bm2, geom=geom, dist=0.0001, plane_co=co, plane_no=no, clear_outer=True + ) + if not bisect2["geom"] or not [g for g in bisect2["geom"] if isinstance(g, bmesh.types.BMFace)]: + new_bms.append(bm) + continue + edges = [g for g in bisect2["geom_cut"] if isinstance(g, bmesh.types.BMEdge)] + bmesh.ops.triangle_fill(bm2, use_dissolve=True, edges=edges) + + new_bms.append(bm1) + new_bms.append(bm2) + bms = new_bms + + for bm in bms: + mesh = obj.data + new_obj = obj.copy() + new_obj.data = mesh.copy() + bm.to_mesh(new_obj.data) + for collection in obj.users_collection: + collection.objects.link(new_obj) + new_objs.append(new_obj) + + return new_objs diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 8b7a65bd6e..b31dfacfb9 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -26,6 +26,8 @@ import collections.abc import numpy as np import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.geometry +import ifcopenshell.api.grid import ifcopenshell.api.pset import ifcopenshell.geom import ifcopenshell.util.element @@ -36,25 +38,30 @@ import ifcopenshell.util.unit import bonsai.core.geometry import bonsai.core.tool import bonsai.tool as tool -import bonsai.core.geometry as geometry -from math import atan, cos, degrees, radians +from math import atan, cos, degrees, pi, radians from mathutils import Matrix, Vector from copy import deepcopy from functools import partial from bonsai.bim import import_ifc -# TODO: This line is somehow keeping the world from falling apart with a circular import error. -from bonsai.bim.module.geometry.helper import Helper from bonsai.bim.module.model.data import AuthoringData, RailingData, RoofData, WindowData, DoorData from bonsai.bim.module.model.opening import FilledOpeningGenerator from ifcopenshell.util.shape_builder import ShapeBuilder -from typing import Optional, Union, TypeVar, Any, Iterable, Literal, TYPE_CHECKING +from typing import Optional, Union, TypeVar, Any, Iterable, Literal, TYPE_CHECKING, Sequence, TypedDict T = TypeVar("T") V_ = tool.Blender.V_ if TYPE_CHECKING: - from bonsai.bim.module.model.prop import BIMModelProperties, BIMDoorProperties + from bonsai.bim.module.model.prop import ( + BIMModelProperties, + BIMDoorProperties, + BIMArrayProperties, + BIMRoofProperties, + BIMWindowProperties, + BIMStairProperties, + BIMRailingProperties, + ) class Model(bonsai.core.tool.Model): @@ -66,6 +73,26 @@ class Model(bonsai.core.tool.Model): def get_door_props(cls, obj: bpy.types.Object) -> BIMDoorProperties: return obj.BIMDoorProperties + @classmethod + def get_window_props(cls, obj: bpy.types.Object) -> BIMWindowProperties: + return obj.BIMWindowProperties + + @classmethod + def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties: + return obj.BIMStairProperties + + @classmethod + def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties: + return obj.BIMRoofProperties + + @classmethod + def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: + return obj.BIMRailingProperties + + @classmethod + def get_array_props(cls, obj: bpy.types.Object) -> BIMArrayProperties: + return obj.BIMArrayProperties + @classmethod def convert_si_to_unit(cls, value: T) -> T: if isinstance(value, (tuple, list)): @@ -79,7 +106,7 @@ class Model(bonsai.core.tool.Model): return value * cls.unit_scale @classmethod - def convert_data_to_project_units(cls, data: dict[str, Any], non_si_props: list[str] = []) -> dict[str, Any]: + def convert_data_to_project_units(cls, data: dict[str, Any], non_si_props: Sequence[str] = ()) -> dict[str, Any]: si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) for prop_name in data: if prop_name in non_si_props: @@ -92,7 +119,7 @@ class Model(bonsai.core.tool.Model): return data @classmethod - def convert_data_to_si_units(cls, data: dict[str, Any], non_si_props: list[str] = []) -> dict[str, Any]: + def convert_data_to_si_units(cls, data: dict[str, Any], non_si_props: Sequence[str] = ()) -> dict[str, Any]: si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) for prop_name in data: if prop_name in non_si_props: @@ -104,6 +131,19 @@ class Model(bonsai.core.tool.Model): data[prop_name] = prop_value * si_conversion return data + @classmethod + def get_constituents_props_data(cls, element: ifcopenshell.entity_instance) -> dict[str, str]: + constituents = ("lining", "framing", "glazing") + props: dict[str, str] = {f"{constituent}_material": "0" for constituent in constituents} + material = ifcopenshell.util.element.get_material(element) + if not material or not material.is_a("IfcMaterialConstituentSet"): + return props + for constituent in material.MaterialConstituents: + name = (constituent.Name or "").lower() + if name in constituents: + props[f"{name}_material"] = str(constituent.Material.id()) + return props + @classmethod def convert_mesh_to_curve( cls, position: Matrix, edge_indices: list[tuple[int, int]] @@ -241,7 +281,7 @@ class Model(bonsai.core.tool.Model): @classmethod def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: - """return first found IfcExtrudedAreaSolid""" + """Return first found IfcExtrudedAreaSolid""" item = representation.Items[0] while True: if item.is_a("IfcExtrudedAreaSolid"): @@ -251,8 +291,19 @@ class Model(bonsai.core.tool.Model): else: break + unit_scale: float + vertices: list[Vector] + edges: list[Sequence[int]] + arcs: list[Sequence[int]] + circles: list[Sequence[int]] + @classmethod - def import_axis(cls, axis, obj=None, position=None): + def import_axis( + cls, + axis: Union[ifcopenshell.entity_instance, tuple[Vector, Vector]], + obj=None, + position: Optional[Matrix] = None, + ) -> bpy.types.Object: cls.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if position is None: @@ -263,7 +314,7 @@ class Model(bonsai.core.tool.Model): cls.arcs = [] cls.circles = [] - if isinstance(axis, list): + if isinstance(axis, tuple): cls.vertices.extend( [ position @ Vector(cls.convert_unit_to_si(axis[0])).to_3d(), @@ -276,7 +327,7 @@ class Model(bonsai.core.tool.Model): mesh = bpy.data.meshes.new("Axis") mesh.from_pydata(cls.vertices, cls.edges, []) - mesh.BIMMeshProperties.subshape_type = "AXIS" + tool.Geometry.get_mesh_props(mesh).subshape_type = "AXIS" if obj is None: obj = bpy.data.objects.new("Axis", mesh) @@ -333,7 +384,7 @@ class Model(bonsai.core.tool.Model): mesh = bpy.data.meshes.new("Profile") mesh.from_pydata(cls.vertices, cls.edges, []) - mesh.BIMMeshProperties.subshape_type = "PROFILE" + tool.Geometry.get_mesh_props(mesh).subshape_type = "PROFILE" if obj is None: obj = bpy.data.objects.new("Profile", mesh) @@ -375,7 +426,7 @@ class Model(bonsai.core.tool.Model): mesh = bpy.data.meshes.new("Curve") mesh.from_pydata(cls.vertices, cls.edges, []) - mesh.BIMMeshProperties.subshape_type = "PROFILE" + tool.Geometry.get_mesh_props(mesh).subshape_type = "PROFILE" if obj is None: obj = bpy.data.objects.new("Curve", mesh) @@ -416,7 +467,7 @@ class Model(bonsai.core.tool.Model): mesh = bpy.data.meshes.new("Surface") mesh.from_pydata(cls.vertices, cls.edges, []) - mesh.BIMMeshProperties.subshape_type = "PROFILE" + tool.Geometry.get_mesh_props(mesh).subshape_type = "PROFILE" if obj is None: obj = bpy.data.objects.new("Surface", mesh) @@ -433,10 +484,13 @@ class Model(bonsai.core.tool.Model): return obj + class UnsupportedCurveForConversion(Exception): + pass + @classmethod def convert_curve_to_mesh( cls, - obj: bpy.types.Object, + obj: Union[bpy.types.Object, None], # Unused argument. position: Matrix, curve: ifcopenshell.entity_instance, x_angle: Optional[float] = None, @@ -444,19 +498,24 @@ class Model(bonsai.core.tool.Model): offset = len(cls.vertices) if curve.is_a("IfcPolyline"): - total_points = len(curve.Points) - last_index = len(curve.Points) - 1 - for i, point in enumerate(curve.Points): - if i == last_index: - continue + curve_points: tuple[ifcopenshell.entity_instance, ...] = curve.Points + # Polyline must have 2 points to be valid. + is_closed = np.allclose(curve_points[0].Coordinates, curve_points[-1].Coordinates) + + points_to_add = curve_points[:-1] if is_closed else curve_points + for point in points_to_add: global_point = position @ Vector(cls.convert_unit_to_si(point.Coordinates)).to_3d() cls.vertices.append(global_point) - cls.edges.extend([(i, i + 1) for i in range(offset, len(cls.vertices))]) - cls.edges[-1] = (len(cls.vertices) - 1, offset) # Close the loop + + cls.edges.extend([(i, i + 1) for i in range(offset, len(cls.vertices) - 1)]) + if is_closed: + cls.edges[-1] = (len(cls.vertices) - 1, offset) # Close the loop + elif curve.is_a("IfcCompositeCurve"): # This is a first pass incomplete implementation only for simple polylines, and misses many details. for segment in curve.Segments: cls.convert_curve_to_mesh(obj, position, segment.ParentCurve) + elif curve.is_a("IfcIndexedPolyCurve"): for local_point in curve.Points.CoordList: global_point = position @ Vector(cls.convert_unit_to_si(local_point)).to_3d() @@ -492,6 +551,8 @@ class Model(bonsai.core.tool.Model): ) cls.circles.append([offset, offset + 1]) cls.edges.append((offset, offset + 1)) + else: + raise cls.UnsupportedCurveForConversion(f"Profile has unsupported curve type: {curve}.") @classmethod def import_rectangle(cls, obj: bpy.types.Object, position: Matrix, profile: ifcopenshell.entity_instance) -> None: @@ -539,8 +600,16 @@ class Model(bonsai.core.tool.Model): if not openings[i].obj: openings.remove(i) + class MaterialLayerParameters(TypedDict): + """Float values are in project units.""" + + layer_set_direction: Literal["AXIS1", "AXIS2", "AXIS3"] + thickness: float + offset: float + direction_sense: Literal["NEGATIVE", "POSITIVE"] + @classmethod - def get_material_layer_parameters(cls, element: ifcopenshell.entity_instance) -> dict[str, Any]: + def get_material_layer_parameters(cls, element: ifcopenshell.entity_instance) -> MaterialLayerParameters: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) layer_set_direction = "AXIS2" offset = 0.0 @@ -555,12 +624,12 @@ class Model(bonsai.core.tool.Model): material = material.ForLayerSet if material.is_a("IfcMaterialLayerSet"): thickness = sum([l.LayerThickness for l in material.MaterialLayers]) * unit_scale - return { - "layer_set_direction": layer_set_direction, - "thickness": thickness, - "offset": offset, - "direction_sense": direction_sense, - } + return cls.MaterialLayerParameters( + layer_set_direction=layer_set_direction, + thickness=thickness, + offset=offset, + direction_sense=direction_sense, + ) @classmethod def get_booleans( @@ -568,7 +637,10 @@ class Model(bonsai.core.tool.Model): element: Optional[ifcopenshell.entity_instance] = None, representation: Optional[ifcopenshell.entity_instance] = None, ) -> list[ifcopenshell.entity_instance]: + """Either element or representation must be provided.""" + assert element or representation, "Either element or representation must be provided." if representation is None: + assert element representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: return [] @@ -935,8 +1007,11 @@ class Model(bonsai.core.tool.Model): obj: bpy.types.Object, new_representation: ifcopenshell.entity_instance, ) -> None: + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) ifc_file = tool.Ifc.get() ifc_element = tool.Ifc.get_entity(obj) + assert ifc_element old_representation = ifcopenshell.util.representation.get_representation( ifc_element, ifc_context.ContextType, ifc_context.ContextIdentifier, ifc_context.TargetView ) @@ -950,7 +1025,7 @@ class Model(bonsai.core.tool.Model): ifcopenshell.api.run( "geometry.assign_representation", ifc_file, product=ifc_element, representation=new_representation ) - geometry.switch_representation( + bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, obj=obj, @@ -1469,9 +1544,9 @@ class Model(bonsai.core.tool.Model): after material assignment or material unassignment. """ for element in elements: - if not (obj := tool.Ifc.get_object(element)) or not obj.data: + if not (obj := tool.Ifc.get_object(element)) or not (data := obj.data): continue - representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + representation = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(data).ifc_definition_id) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -1498,12 +1573,14 @@ class Model(bonsai.core.tool.Model): def add_body_representation(cls, obj: bpy.types.Object) -> None: ifc_file = tool.Ifc.get() body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") - representation = ifcopenshell.api.run( - "geometry.add_representation", + assert body + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + representation = ifcopenshell.api.geometry.add_representation( ifc_file, context=body, blender_object=obj, - geometry=obj.data, + geometry=mesh, coordinate_offset=tool.Geometry.get_cartesian_point_offset(obj), total_items=tool.Geometry.get_total_representation_items(obj), should_force_faceted_brep=tool.Geometry.should_force_faceted_brep(), @@ -1512,6 +1589,7 @@ class Model(bonsai.core.tool.Model): ifc_representation_class=None, profile_set_usage=None, ) + assert representation tool.Model.replace_object_ifc_representation(body, obj, representation) @classmethod @@ -1535,7 +1613,7 @@ class Model(bonsai.core.tool.Model): mesh: bpy.types.Mesh, position: Matrix | None = None, x_angle: Optional[float] = None, - ) -> Union[tuple, dict]: + ) -> tuple | dict | None: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) if position is None: @@ -1588,7 +1666,7 @@ class Model(bonsai.core.tool.Model): loop_edges = list(bm.edges) # Create loops from edges - loops = [] + loops: list[list[bmesh.types.BMEdge]] = [] while loop_edges: edge = loop_edges.pop() loop = [edge] @@ -1609,19 +1687,19 @@ class Model(bonsai.core.tool.Model): tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) - def is_in_group(v, group_name): + def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool: for group_index in groups[group_name]: if group_index in v[deform_layer]: return True return False - def get_group_index(v, group_name): + def get_group_index(v: bmesh.types.BMVert, group_name: str) -> Union[int, None]: for group_index in groups[group_name]: if group_index in v[deform_layer]: return group_index # Convert all loops into IFC curves - curves = [] + curves: list[ifcopenshell.entity_instance] = [] for loop in loops: if len(loop) == 1 and all([is_in_group(v, "IFCCIRCLE") for v in loop[0].verts]): @@ -1634,7 +1712,7 @@ class Model(bonsai.core.tool.Model): tmp.createIfcCircle(tmp.createIfcAxis2Placement2D(tmp.createIfcCartesianPoint(list(mid))), radius) ) else: - loop_verts = [] + loop_verts: list[bmesh.types.BMVert] = [] for i, edge in enumerate(loop): if i == 0 and len(loop) == 1: loop_verts.append(edge.verts[0]) @@ -1710,7 +1788,7 @@ class Model(bonsai.core.tool.Model): curves.append(tmp.createIfcIndexedPolyCurve(points)) # Sort IFC curves into either closed, or closed with void profile defs - profile_defs = [] + profile_defs: list[ifcopenshell.entity_instance] = [] settings = ifcopenshell.geom.settings() settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) @@ -1750,7 +1828,9 @@ class Model(bonsai.core.tool.Model): else: profile_defs.append(tmp.createIfcArbitraryClosedProfileDef("AREA", None, curve)) - if len(profile_defs) == 1: + if (total_profile_defs := len(profile_defs)) == 0: + return + elif total_profile_defs == 1: profile_def = profile_defs[0] else: profile_def = tmp.createIfcCompositeProfileDef("AREA", None, profile_defs) @@ -1937,7 +2017,9 @@ class Model(bonsai.core.tool.Model): or it's not referring to an object (e.g. potential boolean object).""" if obj.type != "MESH": return - return obj.data.BIMMeshProperties.obj + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + return tool.Geometry.get_mesh_props(mesh).obj @classmethod def get_tracked_opening_type(cls, obj: bpy.types.Object) -> Union[Literal["OPENING", "BOOLEAN"], None]: @@ -1968,20 +2050,133 @@ class Model(bonsai.core.tool.Model): FilledOpeningGenerator().generate(filling_obj, voided_obj) @classmethod - def add_extrusion_position(cls, extrusion: ifcopenshell.entity_instance, offset: float) -> None: + def add_extrusion_position(cls, extrusion: ifcopenshell.entity_instance, position: Vector) -> None: ifc_file = tool.Ifc.get() - position = ifc_file.createIfcAxis2Placement3D( - ifc_file.createIfcCartesianPoint((0.0, 0.0, offset)), + new_position = ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint(position), ifc_file.createIfcDirection((0.0, 0.0, 1.0)), ifc_file.createIfcDirection((1.0, 0.0, 0.0)), ) - extrusion.Position = position + extrusion.Position = new_position @classmethod - def get_existing_x_angle(cls, extrusion): - x, y, z = extrusion.ExtrudedDirection.DirectionRatios - x_angle = Vector((0, 1)).angle_signed(Vector((y, z))) + def reset_extrusion_position(cls, extrusion: ifcopenshell.entity_instance) -> None: + ifc_file = extrusion.file - return x_angle + if ifc_file.schema == "IFC2X3": + # Position is not optional. + extrusion.Position.Location.Coordinates = (0.0, 0.0, 0.0) + return + + position = extrusion.Position + if position is None: + return + extrusion.Position = None + ifcopenshell.util.element.remove_deep2(ifc_file, position) + + @classmethod + def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float: + x, y, z = extrusion.ExtrudedDirection.DirectionRatios + vector = Vector((0, 1)) + x_angle = vector.angle_signed(Vector((y, z))) + return x_angle if z > 0 else (x_angle + pi) + + @classmethod + def create_axis_curve(cls, obj: bpy.types.Object, grid_axis: ifcopenshell.entity_instance) -> None: + m = tool.Surveyor.get_absolute_matrix(obj) + points = [m @ np.array(v.co.to_4d()) for v in obj.data.vertices[0:2]] + ifcopenshell.api.grid.create_axis_curve( + tool.Ifc.get(), p1=points[0], p2=points[1], is_si=True, grid_axis=grid_axis + ) + + @classmethod + def draw_material_ui_select(cls, layout: bpy.types.UILayout, material_id: str) -> None: + material_id_int = int(material_id) + if not material_id_int: + return + op = layout.operator("bim.material_ui_select", icon="ZOOM_SELECTED", text="") + op.material_id = material_id_int + + @classmethod + def get_slab_clipping_bmesh(cls, obj: bpy.types.Object) -> bpy.types.BMesh | None: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + bm = bmesh.new() + bm.from_mesh(obj.data) + bmesh.ops.dissolve_limit(bm, angle_limit=radians(1), verts=bm.verts, edges=bm.edges) + bm.faces.ensure_lookup_table() + + clipping_bm = bmesh.new() + vertex_map = {} + + for face in bm.faces: + face.normal_update() + normal = face.normal.to_4d() + normal.w = 0 + if (obj.matrix_world @ normal).z >= -0.5: + continue + new_verts = [] + for vert in face.verts: + if not (new_vert := vertex_map.get(vert.index, None)): + new_vert = clipping_bm.verts.new(obj.matrix_world @ vert.co / unit_scale) + vertex_map[vert.index] = new_vert + new_verts.append(new_vert) + clipping_bm.faces.new(new_verts) + + if not len(clipping_bm.faces): + return + + bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces) + return clipping_bm # clipping_bm is in project units + + @classmethod + def clip_wall_to_slab(cls, wall: ifcopenshell.entity_instance, clipping_bm: bpy.types.BMesh) -> None: + matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall.ObjectPlacement)) + bm = clipping_bm.copy() + bmesh.ops.transform(bm, matrix=Matrix(matrix_i.tolist()), verts=bm.verts) + + bm.verts.ensure_lookup_table() + zs = [v.co.z for v in bm.verts] + min_z = min(zs) + max_z = max(zs) + + operand = None + if (z := max_z - min_z) and not np.isclose(z, 0.0): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + + result = bmesh.ops.extrude_face_region(bm, geom=bm.faces) + extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z)) + + verts = [v.co for v in bm.verts] + faces = [[v.index for v in p.verts] for p in bm.faces] + operand = builder.mesh(verts, faces) + + for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []: + if extrusion.Position: + position = ifcopenshell.util.placement.get_axis2placement(extrusion.Position) + else: + position = np.eye(4) + + direction = np.array(extrusion.ExtrudedDirection[0]) + direction /= np.linalg.norm(direction) + direction = position @ np.append(direction, 0.0) + + if direction[2] <= 0 or position[2][3] > max_z: + continue + + extrusion.Depth = max_z / direction[2] + + if operand: + booleans = ifcopenshell.api.geometry.add_boolean( + tool.Ifc.get(), first_item=extrusion, second_items=[operand] + ) + tool.Model.mark_manual_booleans(wall, booleans) + + @classmethod + def connect_wall_to_slab(cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance) -> None: + ifcopenshell.api.geometry.connect_element( + tool.Ifc.get(), relating_element=slab, related_element=wall, description="TOP" + ) diff --git a/src/bonsai/bonsai/tool/patch.py b/src/bonsai/bonsai/tool/patch.py index 356a5001de..ad12d65e41 100644 --- a/src/bonsai/bonsai/tool/patch.py +++ b/src/bonsai/bonsai/tool/patch.py @@ -16,14 +16,22 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import ifcopenshell import ifcpatch import bonsai.core.tool -from typing import Any +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.patch.prop import BIMPatchProperties class Patch(bonsai.core.tool.Patch): + @classmethod + def get_patch_props(cls) -> BIMPatchProperties: + return bpy.context.scene.BIMPatchProperties + @classmethod def run_migrate_patch(cls, infile: str, outfile: str, schema: str) -> None: output = ifcpatch.execute( @@ -32,13 +40,16 @@ class Patch(bonsai.core.tool.Patch): ifcpatch.write(output, outfile) @classmethod - def is_filepath_argument(cls, recipe: str, arg_name: str) -> bool: + def is_filepath_argument(cls, arg_info: ifcpatch.InputDoc) -> bool: # There is probably a more explicit way to do this - return "filepath" in arg_name + return "filepath" in arg_info["name"] or "filter_glob" in arg_info @classmethod def does_patch_has_output(cls, recipe: str) -> bool: - return recipe != "SplitByBuildingStorey" + return recipe not in ( + "Ifc2Sql", + "SplitByBuildingStorey", + ) @classmethod def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]: diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py index a3585c5a9d..870cd1b278 100644 --- a/src/bonsai/bonsai/tool/polyline.py +++ b/src/bonsai/bonsai/tool/polyline.py @@ -20,18 +20,18 @@ import bpy import bmesh import math import ifcopenshell +import ifcopenshell.util.unit import bonsai.core.tool import bonsai.tool as tool from bonsai.bim.module.drawing.helper import format_distance -from dataclasses import dataclass +from dataclasses import dataclass, field from lark import Lark, Transformer from math import degrees, radians, sin, cos, tan from mathutils import Vector, Matrix -from typing import Optional, Union, Literal +from typing import Optional, Union, Literal, List class Polyline(bonsai.core.tool.Polyline): - @dataclass class PolylineUI: _D: str = "" @@ -39,16 +39,9 @@ class Polyline(bonsai.core.tool.Polyline): _WORLD_ANGLE: str = "" # Relative to World Origin. Only used for specific operation. Not used on the UI. _X: str = "" _Y: str = "" - _Z: Optional[str] = None - _AREA: Optional[str] = None - init_z: bool = False - init_area: bool = False - - def __post_init__(self): - if self.init_z: - self._Z = "" - if self.init_area: - self._AREA = "0" + _Z: str = "" + _AREA: str = "0" + input_options: List[str] = field(default_factory=list) def set_value(self, attribute_name, value): value = str(value) @@ -73,11 +66,11 @@ class Polyline(bonsai.core.tool.Polyline): if attribute_name == "A": value = float(self.get_text_value(attribute_name)) return f"{value:.2f}°" + if attribute_name == "AREA": + return Polyline.format_input_ui_units(value, True) else: return Polyline.format_input_ui_units(value) - InputType = Literal["D", "A", "X", "Y", None] - @dataclass class ToolState: use_default_container: bool = None @@ -101,8 +94,8 @@ class Polyline(bonsai.core.tool.Polyline): input_type: "Polyline.InputType" = None @classmethod - def create_input_ui(cls, init_z: bool = False, init_area: bool = False) -> PolylineUI: - return cls.PolylineUI(init_z=init_z, init_area=init_area) + def create_input_ui(cls, input_options: List[str] = []) -> PolylineUI: + return cls.PolylineUI(input_options=input_options) @classmethod def create_tool_state(cls) -> ToolState: @@ -113,17 +106,24 @@ class Polyline(bonsai.core.tool.Polyline): cls, context: bpy.types.Context, input_ui: PolylineUI, tool_state: ToolState, should_round: bool = False ) -> None: - try: - polyline_data = context.scene.BIMPolylineProperties.insertion_polyline[0] + polyline_data = context.scene.BIMPolylineProperties.insertion_polyline + if len(polyline_data) > 0: + polyline_data = polyline_data[0] polyline_points = polyline_data.polyline_points - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - last_point_data = polyline_points[len(polyline_points) - 1] - except: + if len(polyline_points) > 0: + last_point_data = polyline_points[len(polyline_points) - 1] + else: + last_point_data = None + else: polyline_points = [] - default_container_elevation = 0 last_point_data = None - snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] + if tool.Ifc.get(): + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + default_container_elevation = 0 + + mouse_point = context.scene.BIMPolylineProperties.snap_mouse_point[0] if last_point_data: last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) @@ -132,18 +132,18 @@ class Polyline(bonsai.core.tool.Polyline): if tool_state.is_input_on: if tool_state.use_default_container: - snap_vector = Vector( + mouse_vector = Vector( (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), default_container_elevation) ) else: - snap_vector = Vector( + mouse_vector = Vector( (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), input_ui.get_number_value("Z")) ) else: if tool_state.use_default_container: - snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + mouse_vector = Vector((mouse_point.x, mouse_point.y, default_container_elevation)) else: - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + mouse_vector = Vector((mouse_point.x, mouse_point.y, mouse_point.z)) second_to_last_point = None if len(polyline_points) > 1: @@ -154,23 +154,29 @@ class Polyline(bonsai.core.tool.Polyline): else: # Creates a fake "second to last" point away from the first point but in the same x axis # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) + second_to_last_point = Vector((last_point.x + 1000000000, last_point.y, last_point.z)) + if tool_state.plane_method == "YZ": + second_to_last_point = Vector((last_point.x, last_point.y + 1000000000, last_point.z)) + second_to_last_point = tool.Polyline.use_transform_orientations(second_to_last_point) - world_second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) + world_second_to_last_point = Vector((last_point.x + 1000000000, last_point.y, last_point.z)) + if tool_state.plane_method == "YZ": + world_second_to_last_point = Vector((last_point.x, last_point.y + 1000000000, last_point.z)) + world_second_to_last_point = tool.Polyline.use_transform_orientations(world_second_to_last_point) - distance = (snap_vector - last_point).length + distance = (mouse_vector - last_point).length if distance < 0: return if distance > 0: angle = tool.Cad.angle_3_vectors( - second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True + second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True ) # Round angle to the nearest 0.05 angle = round(angle / 0.05) * 0.05 orientation_angle = tool.Cad.angle_3_vectors( - world_second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True + world_second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True ) # Round angle to the nearest 0.05 @@ -184,10 +190,10 @@ class Polyline(bonsai.core.tool.Polyline): angle = 5 * round(angle / 5) factor = tool.Snap.get_increment_snap_value(context) distance = factor * round(distance / factor) - input_ui.set_value("X", snap_vector.x) - input_ui.set_value("Y", snap_vector.y) + input_ui.set_value("X", mouse_vector.x) + input_ui.set_value("Y", mouse_vector.y) if input_ui.get_number_value("Z") is not None: - input_ui.set_value("Z", snap_vector.z) + input_ui.set_value("Z", mouse_vector.z) input_ui.set_value("D", distance) input_ui.set_value("A", angle) @@ -247,24 +253,41 @@ class Polyline(bonsai.core.tool.Polyline): @classmethod def calculate_x_y_and_z(cls, context: bpy.types.Context, input_ui: PolylineUI, tool_state: ToolState) -> None: - try: + polyline_data = context.scene.BIMPolylineProperties.insertion_polyline + if len(polyline_data) > 0: polyline_data = context.scene.BIMPolylineProperties.insertion_polyline[0] polyline_points = polyline_data.polyline_points - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - last_point_data = polyline_points[len(polyline_points) - 1] - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - except: + if len(polyline_points) > 0: + last_point_data = polyline_points[len(polyline_points) - 1] + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) + else: + last_point = Vector((0, 0, 0)) + else: polyline_points = [] - default_container_elevation = 0 last_point = Vector((0, 0, 0)) + if tool.Ifc.get(): + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + default_container_elevation = 0 + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - if tool_state.use_default_container: - snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + if tool_state.is_input_on: + if tool_state.use_default_container: + mouse_vector = Vector( + (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), default_container_elevation) + ) + else: + mouse_vector = Vector( + (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), input_ui.get_number_value("Z")) + ) else: - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + if tool_state.use_default_container: + snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + else: + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) if len(polyline_points) > 1: second_to_last_point_data = polyline_points[len(polyline_points) - 2] @@ -274,7 +297,10 @@ class Polyline(bonsai.core.tool.Polyline): else: # Creates a fake "second to last" point away from the first point but in the same x axis # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) + second_to_last_point = Vector((last_point.x + 1000000000, last_point.y, last_point.z)) + if tool_state.plane_method == "YZ": + second_to_last_point = Vector((last_point.x, last_point.y + 1000000000, last_point.z)) + second_to_last_point = tool.Polyline.use_transform_orientations(second_to_last_point) distance = input_ui.get_number_value("D") @@ -284,8 +310,8 @@ class Polyline(bonsai.core.tool.Polyline): rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) # When the angle in 180 degrees it might create a rotation vector that is equal to - # when the angle is 0 degress, leading the insertion point to the opposite direction - # This prevents the issue by ensuring the the negative x direction + # when the angle is 0 degrees, leading the insertion point to the opposite direction + # This prevents the issue by ensuring the negative x direction if round(angle, 4) == round(math.pi, 4): rot_vector.x = -1.0 @@ -310,7 +336,7 @@ class Polyline(bonsai.core.tool.Polyline): return @classmethod - def validate_input(cls, input_number: str, input_type: InputType) -> tuple[bool, str]: + def validate_input(cls, input_number: str, input_type: str) -> tuple[bool, str]: """ :return: Tuple with a boolean indicating if the input is valid and the final string output. @@ -345,7 +371,7 @@ class Polyline(bonsai.core.tool.Polyline): FORMULA: "=" - metric: NUMBER "mm"? "m"? "°"? + metric: NUMBER "mm"? "cm"? "dm"? "m"? "°"? expr: (ADD | SUB | MUL | DIV) dim @@ -421,7 +447,10 @@ class Polyline(bonsai.core.tool.Polyline): return dimension * unit_scale try: - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + if tool.Ifc.get(): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + unit_scale = tool.Blender.get_unit_scale() if bpy.context.scene.unit_settings.system == "IMPERIAL": parser = Lark(grammar_imperial) else: @@ -435,16 +464,22 @@ class Polyline(bonsai.core.tool.Polyline): transformer = InputTransform() result = transformer.transform(parse_tree) - result = round(result, 5) + result = round(result, 4) return True, str(result) except: return False, "0" @classmethod def format_input_ui_units(cls, value: float, is_area: bool = False) -> str: - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + if tool.Ifc.get(): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + unit_scale = tool.Blender.get_unit_scale() if bpy.context.scene.unit_settings.system == "IMPERIAL": - precision = bpy.context.scene.DocProperties.imperial_precision + dprops = tool.Drawing.get_document_props() + precision = dprops.imperial_precision + if is_area: + unit_scale = 1 else: precision = None @@ -497,13 +532,14 @@ class Polyline(bonsai.core.tool.Polyline): for point in polyline_points[1:]: # The first can be repeated to form a wall loop if (x, y, z) == (point.x, point.y, point.z): return "Cannot create two points at the same location" - # TODO move this limitation to be Wall tool specific. Right now it also affects Measure tool - # Avoids creating segments smaller then 0.1. This is a limitation from create_wall_from_2_points - length = ( - Vector((x, y, z)) - Vector((polyline_points[-1].x, polyline_points[-1].y, polyline_points[-1].z)) - ).length - if round(length, 4) < 0.1: - return "Cannot create a segment smaller then 10cm" + # Avoids creating overlapping edges + if len(polyline_points) > 1: + v1 = Vector((x, y, z)) + v2 = Vector((polyline_points[-1].x, polyline_points[-1].y, polyline_points[-1].z)) + v3 = Vector((polyline_points[-2].x, polyline_points[-2].y, polyline_points[-2].z)) + angle = tool.Cad.angle_3_vectors(v1, v2, v3, new_angle=None, degrees=True) + if tool.Cad.is_x(angle, 0): + return polyline_point = polyline_points.add() polyline_point.x = x @@ -524,22 +560,6 @@ class Polyline(bonsai.core.tool.Polyline): total_length = tool.Polyline.format_input_ui_units(total_length) polyline_data.total_length = total_length - @classmethod - def close_polyline(cls) -> None: - polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) > 2: - first_point = polyline_points[0] - last_point = polyline_points[-1] - if not (first_point.x == last_point.x and first_point.y == last_point.y and first_point.z == last_point.z): - polyline_point = polyline_points.add() - polyline_point.x = first_point.x - polyline_point.y = first_point.y - polyline_point.z = first_point.z - polyline_point.dim = first_point.dim - polyline_point.angle = first_point.angle - polyline_point.position = first_point.position - @classmethod def clear_polyline(cls) -> None: bpy.context.scene.BIMPolylineProperties.insertion_polyline.clear() @@ -570,3 +590,15 @@ class Polyline(bonsai.core.tool.Polyline): measurement_data.total_length = polyline_data[0].total_length measurement_data.area = polyline_data[0].area + + @classmethod + def use_transform_orientations(cls, value: Union[Vector, Matrix]) -> Union[Vector, Matrix]: + custom_orientation = bpy.context.scene.transform_orientation_slots[0].custom_orientation + if custom_orientation: + custom_matrix = custom_orientation.matrix + if isinstance(value, Vector): + result = custom_matrix @ value + else: + result = custom_matrix.inverted() @ value + return result + return value diff --git a/src/bonsai/bonsai/tool/profile.py b/src/bonsai/bonsai/tool/profile.py index 9100383769..0838ef9a6e 100644 --- a/src/bonsai/bonsai/tool/profile.py +++ b/src/bonsai/bonsai/tool/profile.py @@ -25,6 +25,8 @@ import ifcopenshell.util.element import ifcopenshell.util.unit import ifcopenshell.util.placement import ifcopenshell.util.representation +import ifcopenshell.util.shape +import numpy as np import bonsai.core.tool import bonsai.tool as tool import PIL.ImageDraw @@ -33,9 +35,14 @@ from typing import Union, TYPE_CHECKING if TYPE_CHECKING: import bonsai.bim.module.profile.prop + from bonsai.bim.module.profile.prop import BIMProfileProperties class Profile(bonsai.core.tool.Profile): + @classmethod + def get_profile_props(cls) -> BIMProfileProperties: + return bpy.context.scene.BIMProfileProperties + @classmethod def draw_image_for_ifc_profile( cls, draw: PIL.ImageDraw.ImageDraw, profile: ifcopenshell.entity_instance, size: float @@ -45,31 +52,30 @@ class Profile(bonsai.core.tool.Profile): settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) shape = ifcopenshell.geom.create_shape(settings, profile) - verts = shape.verts - if not verts: + verts = ifcopenshell.util.shape.get_vertices(shape) + if verts.size == 0: raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.") - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1]] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - max_x = max([v[0] for v in grouped_verts]) - min_x = min([v[0] for v in grouped_verts]) - max_y = max([v[1] for v in grouped_verts]) - min_y = min([v[1] for v in grouped_verts]) + edges = ifcopenshell.util.shape.get_edges(shape) + verts_flat = verts.ravel() + max_x = np.max(verts_flat[0::3]).item() + min_x = np.min(verts_flat[0::3]).item() + max_y = np.max(verts_flat[1::3]).item() + min_y = np.min(verts_flat[1::3]).item() dim_x = max_x - min_x dim_y = max_y - min_y max_dim = max([dim_x, dim_y]) scale = 100 / max_dim + dim = np.array([dim_x, dim_y]) - for vert in grouped_verts: - vert[0] = round(scale * (vert[0] - min_x)) + ((size / 2) - scale * (dim_x / 2)) - vert[1] = round(scale * (vert[1] - min_y)) + ((size / 2) - scale * (dim_y / 2)) - - for e in grouped_edges: - draw.line((tuple(grouped_verts[e[0]]), tuple(grouped_verts[e[1]])), fill="white", width=2) + verts = verts[:, :2] + verts = np.round(scale * (verts - [min_x, min_y]) + (size / 2) - scale * dim / 2) + for verts_ in verts[edges]: + # draw.line seem to support only tuple of tuples. + verts_tuple: tuple[tuple[float, ...], ...] + verts_tuple = tuple(tuple(i) for i in verts_) + draw.line(verts_tuple, fill="white", width=2) @classmethod def is_editing_profile(cls) -> bool: @@ -116,17 +122,22 @@ class Profile(bonsai.core.tool.Profile): @classmethod def get_active_profile_ui(cls) -> Union[bonsai.bim.module.profile.prop.Profile, None]: - props = bpy.context.scene.BIMProfileProperties - index = props.active_profile_index - if len(props.profiles) > index >= 0: - return props.profiles[index] + props = cls.get_profile_props() + return tool.Blender.get_active_uilist_element(props.profiles, props.active_profile_index) + + @classmethod + def replace_profile_in_profiles_ui(cls, old_profile_id: int, new_profile_id: int) -> None: + props = cls.get_profile_props() + for profile in props.profiles: + if profile.ifc_definition_id == old_profile_id: + profile.ifc_definition_id = new_profile_id + return # Lengths are in meters. DEFAULT_PROFILE_ATTRS = { "IfcCircleProfileDef": { "Radius": 0.05, }, - # TODO: test after debug "IfcAsymmetricIShapeProfileDef": { "BottomFlangeWidth": 0.1, "BottomFlangeThickness": 0.01, diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 96a5c58c36..9e5e646da7 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -31,14 +31,17 @@ import bonsai.core.unit import bonsai.core.owner import bonsai.bim.schema import bonsai.tool as tool +from collections import defaultdict from bonsai.bim.ifc import IfcStore from ifcopenshell.api.project.append_asset import APPENDABLE_ASSET_TYPES from pathlib import Path -from typing import Optional, Union, TYPE_CHECKING, Generator, Callable +from typing import Optional, Union, TYPE_CHECKING, Generator if TYPE_CHECKING: from bonsai.bim.module.project.prop import BIMProjectProperties +HiearchyDict = dict[ifcopenshell.entity_instance, "HiearchyDict"] + class Project(bonsai.core.tool.Project): @classmethod @@ -62,12 +65,12 @@ class Project(bonsai.core.tool.Project): @classmethod def load_default_thumbnails(cls): if tool.Ifc.get().by_type("IfcElementType"): - ifc_class = sorted(tool.Ifc.get().by_type("IfcElementType"), key=lambda e: e.is_a())[0].is_a() - bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class, offset=0, limit=9) + bpy.ops.bim.load_type_thumbnails() @classmethod def load_pset_templates(cls): - pset_dir = tool.Ifc.resolve_uri(bpy.context.scene.BIMProperties.pset_dir) + props = tool.Blender.get_bim_props() + pset_dir = tool.Ifc.resolve_uri(props.pset_dir) if os.path.isdir(pset_dir): for path in Path(pset_dir).glob("*.ifc"): bonsai.bim.schema.ifc.psetqto.templates.append(ifcopenshell.open(path)) @@ -133,13 +136,15 @@ class Project(bonsai.core.tool.Project): @classmethod def set_context(cls, context): bonsai.bim.handler.refresh_ui_data() - bpy.context.scene.BIMRootProperties.contexts = str(context.id()) + rprops = tool.Root.get_root_props() + rprops.contexts = str(context.id()) @classmethod def set_default_context(cls): context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") if context: - bpy.context.scene.BIMRootProperties.contexts = str(context.id()) + rprops = tool.Root.get_root_props() + rprops.contexts = str(context.id()) @classmethod def set_default_modeling_dimensions(cls): @@ -232,7 +237,7 @@ class Project(bonsai.core.tool.Project): @classmethod def load_linked_models_from_ifc(cls) -> None: - links = bpy.context.scene.BIMProjectProperties.links + links = tool.Project.get_project_props().links links.clear() links_document = cls.get_linked_models_document() if not links_document: @@ -249,7 +254,7 @@ class Project(bonsai.core.tool.Project): @classmethod def save_linked_models_to_ifc(cls) -> None: ifc_file = tool.Ifc.get() - links = bpy.context.scene.BIMProjectProperties.links + links = tool.Project.get_project_props().links filepaths: set[Path] = set() for link in links: filepaths.add(Path(link.name)) @@ -296,54 +301,45 @@ class Project(bonsai.core.tool.Project): ifcopenshell.api.document.remove_reference(ifc_file, reference) @classmethod - def get_filter_for_active_library( + def get_project_library_elements( + cls, project_library: ifcopenshell.entity_instance + ) -> set[ifcopenshell.entity_instance]: + return set(element for rel in project_library.Declares for element in rel.RelatedDefinitions) + + @classmethod + def get_project_library_rels(cls, ifc_file: ifcopenshell.file) -> set[ifcopenshell.entity_instance]: + if tool.Ifc.get_schema() == "IFC2X3": + return set() + return set(rel for lib in ifc_file.by_type("IfcProjectLibrary") for rel in lib.Declares) + + @classmethod + def is_element_assigned_to_project_library( cls, - ) -> Callable[[list[ifcopenshell.entity_instance]], Generator[ifcopenshell.entity_instance, None, None]]: - props = cls.get_project_props() - library_file = IfcStore.library_file - assert library_file - - selected_project_library = props.selected_project_library if props.filter_by_library else "*" - - if selected_project_library == "*": - - def condition(elements: list[ifcopenshell.entity_instance]): - yield from elements - - elif selected_project_library == "-": - - def condition(elements: list[ifcopenshell.entity_instance]): - for element in elements: - if not getattr(element, "HasContext", False): - yield element - - else: - project_library = library_file.by_id(int(selected_project_library)) - project_library_rels = set(project_library.Declares) - if project_library_rels: - - def condition(elements: list[ifcopenshell.entity_instance]): - for element in elements: - for rel in getattr(element, "HasContext", ()): - if rel in project_library_rels: - yield element - break - - else: - - def condition(elements: list[ifcopenshell.entity_instance]): - # Hacky way to create empty generator. - return - yield - - return condition + element: ifcopenshell.entity_instance, + project_library_rels: set[ifcopenshell.entity_instance], + ) -> bool: + if not (has_context := getattr(element, "HasContext", ())): + return False + return any(rel in project_library_rels for rel in has_context) @classmethod def update_current_library_page(cls): props = cls.get_project_props() - element_name = props.active_library_element + active_library_breadcrumb = props.get_active_library_breadcrumb() + change_back = False + if active_library_breadcrumb: + name = active_library_breadcrumb.name + breadcrumb_type = active_library_breadcrumb.breadcrumb_type + library_id = active_library_breadcrumb.library_id + change_back = True + bpy.ops.bim.rewind_library() - bpy.ops.bim.change_library_element(element_name=element_name) + if change_back: + bpy.ops.bim.change_library_element( + element_name=name, + breadcrumb_type=breadcrumb_type, + library_id=library_id, + ) @classmethod def get_parent_library(cls, project_library: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: @@ -352,3 +348,46 @@ class Project(bonsai.core.tool.Project): return nests[0].RelatingObject # IfcProject. return project_library.HasContext[0].RelatingContext + + @classmethod + def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict: + """Get project hierarchy in the following form: + + { + IfcProject: { IfcProjectLibrary A: { ... }, }, + IfcProjectLibrary A: { IfcProjectLibrary B: { ... } }, + IfcProjectLibrary B: { ... }, + } + + Use IfcProject to get hierarchy root. + + """ + hierarchy: HiearchyDict = defaultdict(dict) + if tool.Ifc.get_schema() == "IFC2X3": + return hierarchy + for project_library in ifc_file.by_type("IfcProjectLibrary"): + parent_library = cls.get_parent_library(project_library) + hierarchy[parent_library][project_library] = hierarchy[project_library] + return hierarchy + + @classmethod + def load_project_libraries_to_ui( + cls, parent_library: ifcopenshell.entity_instance, hierarchy: HiearchyDict + ) -> None: + libraries = hierarchy[parent_library] + props = cls.get_project_props() + for project_library in libraries: + library_elements = tool.Project.get_project_library_elements(project_library) + subhierarchy = libraries[project_library] + for sublibrary in subhierarchy: + sublibrary_elements = tool.Project.get_project_library_elements(sublibrary) + library_elements.update(sublibrary_elements) + props.add_library_project_library( + project_library.Name or "Unnamed", len(library_elements), project_library.id(), bool(subhierarchy) + ) + + @classmethod + def get_library_element_attr_name(cls, library_element: ifcopenshell.entity_instance) -> str: + if library_element.is_a("IfcProfileDef"): + return "ProfileName" + return "Name" diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 39e4b0d721..d979fdb1ac 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -31,12 +31,16 @@ from typing_extensions import assert_never if TYPE_CHECKING: - from bonsai.bim.module.pset.prop import PsetProperties + from bonsai.bim.module.pset.prop import PsetProperties, GlobalPsetProperties class Pset(bonsai.core.tool.Pset): PSET_TYPE = Literal["PSET", "QTO"] + @classmethod + def get_global_pset_props(cls) -> GlobalPsetProperties: + return bpy.context.scene.GlobalPsetProperties + @classmethod def get_element_pset( cls, element: ifcopenshell.entity_instance, pset_name: str diff --git a/src/bonsai/bonsai/tool/pset_template.py b/src/bonsai/bonsai/tool/pset_template.py index e2b69705b6..8977d1e32c 100644 --- a/src/bonsai/bonsai/tool/pset_template.py +++ b/src/bonsai/bonsai/tool/pset_template.py @@ -133,7 +133,8 @@ class PsetTemplate(bonsai.core.tool.PsetTemplate): for f in tool.Blender.get_data_dir_paths("pset", "*.ifc"): paths.append((f, "Global Pset Template")) - pset_dir = Path(tool.Ifc.resolve_uri(bpy.context.scene.BIMProperties.pset_dir)) + props = tool.Blender.get_bim_props() + pset_dir = Path(tool.Ifc.resolve_uri(props.pset_dir)) if pset_dir.is_dir(): for path in Path(pset_dir).glob("*.ifc"): paths.append((path, "Project Pset Template")) diff --git a/src/bonsai/bonsai/tool/qto.py b/src/bonsai/bonsai/tool/qto.py index 803a00e560..fdd9c038d7 100644 --- a/src/bonsai/bonsai/tool/qto.py +++ b/src/bonsai/bonsai/tool/qto.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import bonsai.core.tool import bonsai.bim.schema @@ -24,12 +25,19 @@ import ifcopenshell import ifcopenshell.util.unit import ifcopenshell.util.element from mathutils import Vector -from typing import Optional, Union, Literal +from typing import Optional, Union, Literal, TYPE_CHECKING, Any + +if TYPE_CHECKING: + from bonsai.bim.module.qto.prop import BIMQtoProperties QuantityTypes = Literal["Q_LENGTH", "Q_AREA", "Q_VOLUME"] class Qto(bonsai.core.tool.Qto): + @classmethod + def get_qto_props(cls) -> BIMQtoProperties: + return bpy.context.scene.BIMQtoProperties + @classmethod def get_radius_of_selected_vertices(cls, obj: bpy.types.Object) -> float: selected_verts = [v.co for v in obj.data.vertices if v.select] @@ -41,7 +49,8 @@ class Qto(bonsai.core.tool.Qto): @classmethod def set_qto_result(cls, result: float) -> None: - bpy.context.scene.BIMQtoProperties.qto_result = str(round(result, 3)) + props = cls.get_qto_props() + props.qto_result = str(round(result, 3)) @classmethod def get_rounded_value(cls, new_quantity: float) -> float: @@ -158,3 +167,23 @@ class Qto(bonsai.core.tool.Qto): } ) return result + + @classmethod + def get_qto_rules(cls) -> dict[str, dict[str, Any]]: + import ifc5d.qto + + ifc_file = tool.Ifc.get() + is_ifc4x3 = ifc_file.schema == "IFC4X3" + return {rule_id: rule for rule_id, rule in ifc5d.qto.rules.items() if rule_id.startswith("IFC4X3") == is_ifc4x3} + + @classmethod + def get_not_quantified_elements_message(cls, not_quantified_elements: set[ifcopenshell.entity_instance]) -> str: + not_quantified_message = "" + if not_quantified_elements: + print("Elements that were not quantified:") + for element in not_quantified_elements: + print(f"- {element}") + not_quantified_message = ( + f" {len(not_quantified_elements)} of them were not quantified, see system console for the details." + ) + return not_quantified_message diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 4b2d16c4ea..628660077a 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -22,14 +22,27 @@ import copy from bpy_extras import view3d_utils import bonsai.core.tool import bonsai.tool as tool -import math import mathutils -from mathutils import Matrix, Vector +from mathutils import Vector +from typing import Union class Raycast(bonsai.core.tool.Raycast): + offset = 10 + mouse_offset = ( + (-offset, offset), + (0, offset), + (offset, offset), + (-offset, 0), + (0, 0), + (offset, 0), + (-offset, -offset), + (0, -offset), + (offset, -offset), + ) + @classmethod - def get_visible_objects(cls, context): + def get_visible_objects(cls, context: bpy.types.Context): depsgraph = context.evaluated_depsgraph_get() all_objs = [] for dup in depsgraph.object_instances: @@ -42,7 +55,7 @@ class Raycast(bonsai.core.tool.Raycast): return all_objs @classmethod - def get_on_screen_2d_bounding_boxes(cls, context, obj): + def get_on_screen_2d_bounding_boxes(cls, context: bpy.types.Context, obj: bpy.types.Object): obj_matrix = obj.matrix_world.copy() bbox = [obj_matrix @ Vector(v) for v in obj.bound_box] @@ -59,6 +72,8 @@ class Raycast(bonsai.core.tool.Raycast): for i, axis in enumerate(zip(*transposed_bbox)): min_point = min(axis) max_point = max(axis) + if min_point == max_point: + min_point = 0 if min_point < borders[i] and max_point > 0: bbox_2d.extend([min_point, max_point]) else: @@ -67,16 +82,16 @@ class Raycast(bonsai.core.tool.Raycast): return (obj, bbox_2d) @classmethod - def intersect_mouse_2d_bounding_box(cls, mouse_pos, bbox, offset=None): + def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]): x, y = mouse_pos xmin, xmax, ymin, ymax = bbox # extends bbox boundaries to improve snap - if offset: - xmin -= offset - xmax += offset - ymin -= offset - ymax += offset + if cls.offset: + xmin -= cls.offset + xmax += cls.offset + ymin -= cls.offset + ymax += cls.offset if xmin < x < xmax and ymin < y < ymax: return True @@ -84,7 +99,9 @@ class Raycast(bonsai.core.tool.Raycast): return False @classmethod - def get_viewport_ray_data(cls, context, event, mouse_pos=None): + def get_viewport_ray_data( + cls, context: bpy.types.Context, event: bpy.types.Event, mouse_pos: tuple[int, int] = None + ): region = context.region rv3d = context.region_data original_perspective = rv3d.view_perspective @@ -107,7 +124,13 @@ class Raycast(bonsai.core.tool.Raycast): return ray_origin, ray_target, ray_direction @classmethod - def get_object_ray_data(cls, context, event, obj_matrix, mouse_pos=None): + def get_object_ray_data( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + obj_matrix: mathutils.Matrix, + mouse_pos: tuple[int, int] = None, + ): if mouse_pos: ray_origin, ray_target, _ = cls.get_viewport_ray_data(context, event, mouse_pos) else: @@ -120,7 +143,13 @@ class Raycast(bonsai.core.tool.Raycast): return ray_origin_obj, ray_target_obj, ray_direction_obj @classmethod - def obj_ray_cast(cls, context, event, obj, mouse_pos=None): + def obj_ray_cast( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + obj: bpy.types.Object, + mouse_pos: tuple[int, int] = None, + ): if mouse_pos: ray_origin_obj, _, ray_direction_obj = cls.get_object_ray_data( context, event, obj.matrix_world.copy(), mouse_pos @@ -134,23 +163,50 @@ class Raycast(bonsai.core.tool.Raycast): return None, None, None @classmethod - def ray_cast_by_proximity(cls, context, event, obj, face=None, custom_bmesh=None): + def ray_cast_by_proximity( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + obj: bpy.types.Object, + face: bpy.types.MeshPolygon = None, + custom_bmesh: bmesh.types.BMesh = None, + ): region = context.region rv3d = context.region_data mouse_pos = event.mouse_region_x, event.mouse_region_y ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) points = [] - # Makes the snapping point more or less sticky then others + # Makes the snapping point more or less sticky than others # It changes the distance and affects how the snapping point are sorted - reference = 0.2 - stick_factor = 0.02 + # We multiply by the increment snap which is based on the viewport zoom + snap_threshold = 10 * tool.Snap.get_increment_snap_value(bpy.context) + if face: + snap_threshold = tool.Snap.get_increment_snap_value(bpy.context) try: loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_pos, ray_direction) except: loc = Vector((0, 0, 0)) + # For empty object we just get the object location and return + + if obj and obj.type == "EMPTY": + v = obj.location + intersection = tool.Cad.point_on_edge(v, (ray_target, loc)) + distance = (v - intersection).length + if distance < snap_threshold: + snap_point = { + "object": obj, + "type": "Vertex", + "point": v.copy(), + "distance": distance, + } + points.append(snap_point) + return points + if obj and obj.type == "CURVE": + obj = bpy.data.objects.new("new_object", obj.to_mesh().copy()) + if not custom_bmesh: bm = bmesh.new() if face is None: # Object without faces @@ -168,12 +224,14 @@ class Raycast(bonsai.core.tool.Raycast): v = obj.matrix_world.copy() @ v intersection = tool.Cad.point_on_edge(v, (ray_target, loc)) distance = (v - intersection).length - if distance < 0.2: + if distance < snap_threshold: snap_point = { + "object": obj, "type": "Vertex", - "point": v, + "point": v.copy(), + "distance": distance, } - points.append([(distance - stick_factor), snap_point]) + points.append(snap_point) for edge in bm.edges: v1 = edge.verts[0].co @@ -185,40 +243,39 @@ class Raycast(bonsai.core.tool.Raycast): intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc)) distance = (division_point - intersection).length - if distance < 0.2: + if distance < snap_threshold: snap_point = { + "object": obj, "type": "Edge Center", - "point": division_point, + "point": division_point.copy(), + "distance": distance, } - points.append([distance, snap_point]) + points.append(snap_point) intersection = tool.Cad.intersect_edges_v2((ray_target, loc), (v1, v2)) if intersection[0]: if tool.Cad.is_point_on_edge(intersection[1], (v1, v2)): distance = (intersection[1] - intersection[0]).length - if distance < 0.2: + if distance < snap_threshold: snap_point = { + "object": obj, "type": "Edge", - "point": intersection[1], + "point": intersection[1].copy(), "edge_verts": (v1, v2), + "distance": distance, } - points.append([(distance + 2 * stick_factor), snap_point]) - + points.append(snap_point) bm.free() - snapping_points = [] - sorted_points = sorted(points, key=lambda x: x[0]) - for p in sorted_points: - point = copy.deepcopy(p) - snapping_points.append(point[1]) - return snapping_points + return points @classmethod - def ray_cast_to_polyline(cls, context, event): + def ray_cast_to_polyline(cls, context: bpy.types.Context, event: bpy.types.Event): region = context.region rv3d = context.region_data mouse_pos = event.mouse_region_x, event.mouse_region_y ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + snap_threshold = tool.Snap.get_increment_snap_value(bpy.context) try: loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_pos, ray_direction) @@ -236,17 +293,19 @@ class Raycast(bonsai.core.tool.Raycast): intersection, _ = mathutils.geometry.intersect_point_line(vertex, ray_target, loc) distance = (vertex - intersection).length - if distance < 0.2: + if distance < snap_threshold: snap_point = { "type": "Vertex", "point": vertex, + "distance": distance, + "object": None, } polyline_verts.append(snap_point) return polyline_verts @classmethod - def ray_cast_to_measure(cls, context, event, points): + def ray_cast_to_measure(cls, context: bpy.types.Context, event: bpy.types.Event, points: bpy.types.Collection): bm = bmesh.new() bm.verts.index_update() bm.edges.index_update() @@ -263,13 +322,18 @@ class Raycast(bonsai.core.tool.Raycast): return snapping_points @classmethod - def ray_cast_to_plane(cls, context, event, plane_origin, plane_normal): + def ray_cast_to_plane( + cls, context: bpy.types.Context, event: bpy.types.Event, plane_origin: Vector, plane_normal: Vector + ): region = context.region rv3d = context.region_data mouse_pos = event.mouse_region_x, event.mouse_region_y ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + if tool.Ifc.get(): + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + default_container_elevation = 0.0 intersection = Vector((0, 0, default_container_elevation)) try: loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_pos, ray_direction) @@ -281,3 +345,126 @@ class Raycast(bonsai.core.tool.Raycast): intersection = Vector((0, 0, default_container_elevation)) return intersection + + @classmethod + def ray_cast_to_edge_intersection(cls, context: bpy.types.Context, event: bpy.types.Event, edges: list[dict]): + region = context.region + rv3d = context.region_data + mouse_pos = event.mouse_region_x, event.mouse_region_y + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + snap_threshold = tool.Snap.get_increment_snap_value(bpy.context) + + try: + loc = view3d_utils.region_2d_to_location_3d(region, rv3d, mouse_pos, ray_direction) + except: + loc = Vector((0, 0, 0)) + + for e1, e2 in zip(edges, edges[1:] + [edges[0]]): + if tool.Cad.are_vectors_equal(e1["point"], e2["point"], tolerance=0.1): + edge_intersection = tool.Cad.intersect_edges_v2(e1["edge_verts"], e2["edge_verts"]) + if edge_intersection[1]: + mouse_intersection, _ = mathutils.geometry.intersect_point_line( + edge_intersection[1], ray_target, loc + ) + distance = (edge_intersection[1] - mouse_intersection).length + if distance < snap_threshold: + snap_point = { + "object": None, + "type": "Edge Intersection", + "point": edge_intersection[1], + "distance": distance, + } + return snap_point + + @classmethod + def filter_objects_to_raycast( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + objs_2d_bbox: Union[tuple[bpy.types.Object, list[float]]], + ) -> list[bpy.types.Object]: + mouse_pos = event.mouse_region_x, event.mouse_region_y + objs_to_raycast = [] + for obj, bbox_2d in objs_2d_bbox: + if obj.type in {"MESH", "EMPTY", "CURVE"} and bbox_2d: + if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d): + if ( + obj.visible_in_viewport_get(bpy.context.space_data) or obj.library + ): # Check for local view and local collections for this viewport and object + objs_to_raycast.append(obj) + return objs_to_raycast + + @classmethod + def cast_rays_to_single_object( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + obj: bpy.types.Object, + ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]: + + mouse_pos = event.mouse_region_x, event.mouse_region_y + hit = None + face_index = None + # Wireframes + if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0): + snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj) + if snap_points: + hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"] + if hit: + hit_world = obj.original.matrix_world @ hit + return obj, hit_world, face_index + return None, None, None + # Meshes + else: + hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj) + if hit is None: + # Tried original mouse position. Now it will try the offsets. + original_mouse_pos = mouse_pos + for value in cls.mouse_offset: + mouse_pos = tuple(x + y for x, y in zip(original_mouse_pos, value)) + hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj, mouse_pos) + if hit: + break + mouse_pos = original_mouse_pos + if hit: + hit_world = obj.original.matrix_world @ hit + return obj, hit_world, face_index + else: + return None, None, None + + @classmethod + def cast_rays_and_get_best_object( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + objs_to_raycast: list[bpy.types.Object], + include_wireframes: bool = True, + ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]: + best_length_squared = 1.0 + best_obj = None + best_hit = None + best_face_index = None + + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + + for obj in objs_to_raycast: + if not include_wireframes and ( + obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0) + ): + continue + + snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj) + + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if best_obj is None or length_squared < best_length_squared: + best_length_squared = length_squared + best_obj = snap_obj + best_hit = hit + best_face_index = face_index + + if best_obj is not None: + return best_obj, best_hit, best_face_index + + else: + return None, None, None diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index b18e649338..b8269f4134 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -16,9 +16,11 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.style import ifcopenshell.util.representation import ifcopenshell.util.element import ifcopenshell.util.placement @@ -26,12 +28,19 @@ import bonsai.core.tool import bonsai.core.aggregate import bonsai.core.geometry import bonsai.tool as tool -from typing import Union, Optional, Any, Literal +from typing import Union, Optional, Any, Literal, TYPE_CHECKING from bonsai.bim.module.spatial.decorator import GridDecorator from bonsai.bim.module.geometry.decorator import ItemDecorator +if TYPE_CHECKING: + from bonsai.bim.module.root.prop import BIMRootProperties + class Root(bonsai.core.tool.Root): + @classmethod + def get_root_props(cls) -> BIMRootProperties: + return bpy.context.scene.BIMRootProperties + @classmethod def add_tracked_opening(cls, obj: bpy.types.Object, opening_type: Literal["OPENING", "BOOLEAN"]) -> None: """Add tracked opening or boolean object.""" @@ -49,12 +58,12 @@ class Root(bonsai.core.tool.Root): tool.Geometry.run_style_add_style(obj=mat) for mat in tool.Geometry.get_object_materials_without_styles(obj) ] - ifcopenshell.api.run( - "style.assign_representation_styles", + props = tool.Geometry.get_geometry_props() + ifcopenshell.api.style.assign_representation_styles( tool.Ifc.get(), shape_representation=body, styles=tool.Geometry.get_styles(obj), - should_use_presentation_style_assignment=bpy.context.scene.BIMGeometryProperties.should_use_presentation_style_assignment, + should_use_presentation_style_assignment=props.should_use_presentation_style_assignment, ) @classmethod @@ -111,7 +120,7 @@ class Root(bonsai.core.tool.Root): @classmethod def get_default_container(cls) -> Optional[ifcopenshell.entity_instance]: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = tool.Spatial.get_spatial_props() if container := props.default_container: try: return tool.Ifc.get().by_id(container) @@ -169,8 +178,8 @@ class Root(bonsai.core.tool.Root): @classmethod def get_object_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: - if obj.data and obj.data.BIMMeshProperties.ifc_definition_id: - return tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) + if obj.data and (mesh_props := tool.Geometry.get_mesh_props(obj.data)).ifc_definition_id: + return tool.Ifc.get().by_id(mesh_props.ifc_definition_id) element = tool.Ifc.get_entity(obj) if element.is_a("IfcTypeProduct"): if element.RepresentationMaps: @@ -232,7 +241,7 @@ class Root(bonsai.core.tool.Root): @classmethod def reload_grid_decorator(cls) -> None: - axes = bpy.context.scene.BIMGridProperties.grid_axes + axes = tool.Spatial.get_grid_props().grid_axes axes.clear() for axis in tool.Ifc.get().by_type("IfcGridAxis"): if obj := tool.Ifc.get_object(axis): @@ -302,8 +311,8 @@ class Root(bonsai.core.tool.Root): voided_objs.append(subobj) for voided_obj in voided_objs: - if voided_obj.data: - representation = tool.Ifc.get().by_id(voided_obj.data.BIMMeshProperties.ifc_definition_id) + if data := voided_obj.data: + representation = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(data).ifc_definition_id) bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, @@ -400,7 +409,8 @@ class Root(bonsai.core.tool.Root): def set_object_name(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None: name = tool.Loader.get_name(element) if obj.name != name: - obj.BIMObjectProperties.is_renaming = True + props = tool.Blender.get_object_bim_props(obj) + props.is_renaming = True obj.name = name # The handler will trigger, and reset is_renaming to False @classmethod @@ -408,8 +418,8 @@ class Root(bonsai.core.tool.Root): """Rename material without triggerring name callback and unnecessary writing to IFC.""" if material.name == name: return - props = material.BIMStyleProperties - props.is_renaming = True + msprops = tool.Style.get_material_style_props(material) + msprops.is_renaming = True material.name = name # The handler will trigger, and reset is_renaming to False. @classmethod @@ -421,10 +431,36 @@ class Root(bonsai.core.tool.Root): to unlink them. """ tool.Ifc.unlink(obj=obj) - if hasattr(obj.data, "BIMMeshProperties"): - obj.data.BIMMeshProperties.ifc_definition_id = 0 + if tool.Geometry.has_mesh_properties((data := obj.data)): + tool.Geometry.get_mesh_props(data).ifc_definition_id = 0 for material_slot in obj.material_slots: if material := material_slot.material: tool.Ifc.unlink(obj=material) if "Ifc" in obj.name and "/" in obj.name: obj.name = obj.name.split("/", 1)[1] + + @classmethod + def get_ifc_products(cls) -> tuple[str, ...]: + version = tool.Ifc.get_schema() + if version == "IFC2X3": + products = ( + "IfcElementType", + "IfcElement", + "IfcFeatureElement", + "IfcSpatialStructureElement", + "IfcStructuralItem", + "IfcAnnotation", + "IfcRelSpaceBoundary", + ) + else: + products = ( + "IfcElementType", + "IfcElement", + "IfcFeatureElement", + "IfcSpatialElement", + "IfcSpatialElementType", + "IfcStructuralItem", + "IfcAnnotation", + "IfcRelSpaceBoundary", + ) + return products diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index 3b90961559..635b1c01dd 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -30,30 +30,34 @@ from typing import Union, Literal, TYPE_CHECKING if TYPE_CHECKING: from bonsai.bim.prop import BIMFilterGroup + from bonsai.bim.module.search.prop import BIMSearchProperties class Search(bonsai.core.tool.Search): + @classmethod + def get_search_props(cls) -> BIMSearchProperties: + return bpy.context.scene.BIMSearchProperties + @classmethod def get_group_query(cls, group: ifcopenshell.entity_instance) -> str: return json.loads(group.Description)["query"] @classmethod - def get_filter_groups(cls, module: str) -> bpy.types.bpy_prop_collection_idprop[BIMFilterGroup]: + def get_filter_groups(cls, module: FilterModule) -> bpy.types.bpy_prop_collection_idprop[BIMFilterGroup]: if module == "search": - return bpy.context.scene.BIMSearchProperties.filter_groups + return cls.get_search_props().filter_groups elif module == "csv": - return bpy.context.scene.CsvProperties.filter_groups + return tool.Blender.get_csv_props().filter_groups elif module == "diff": - return bpy.context.scene.DiffProperties.filter_groups + return tool.Blender.get_diff_props().filter_groups elif module == "drawing_include": return bpy.context.scene.camera.data.BIMCameraProperties.include_filter_groups elif module == "drawing_exclude": return bpy.context.scene.camera.data.BIMCameraProperties.exclude_filter_groups elif module.startswith("clash"): _, clash_set_index, ab, clash_source_index = module.split("_") - return getattr(bpy.context.scene.BIMClashProperties.clash_sets[int(clash_set_index)], ab)[ - int(clash_source_index) - ].filter_groups + props = tool.Clash.get_clash_props() + return getattr(props.clash_sets[int(clash_set_index)], ab)[int(clash_source_index)].filter_groups assert False, f"Unsupported module: {module}" @classmethod diff --git a/src/bonsai/bonsai/tool/sequence.py b/src/bonsai/bonsai/tool/sequence.py index 4dd4cfbb99..046906387e 100644 --- a/src/bonsai/bonsai/tool/sequence.py +++ b/src/bonsai/bonsai/tool/sequence.py @@ -1158,7 +1158,7 @@ class Sequence(bonsai.core.tool.Sequence): bpy.context.scene.frame_start = 1 bpy.context.scene.frame_end = 2 for obj in bpy.data.objects: - if not obj.BIMObjectProperties.ifc_definition_id: + if not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): continue obj.color = (1.0, 1.0, 1.0, 1) obj.hide_viewport = False @@ -1317,7 +1317,7 @@ class Sequence(bonsai.core.tool.Sequence): @classmethod def clear_objects_animation(cls, include_blender_objects=True): for obj in bpy.data.objects: - if not include_blender_objects and not obj.BIMObjectProperties.ifc_definition_id: + if not include_blender_objects and not (ifc_id := tool.Blender.get_ifc_definition_id(obj)): continue cls.clear_object_animation(obj) cls.clear_object_color(obj) @@ -1326,13 +1326,14 @@ class Sequence(bonsai.core.tool.Sequence): @classmethod def animate_objects(cls, settings, frames, animation_type=""): for obj in bpy.data.objects: - if not obj.BIMObjectProperties.ifc_definition_id: + element = tool.Ifc.get_entity(obj) + if not element: continue - if tool.Ifc.get().by_id(obj.BIMObjectProperties.ifc_definition_id).is_a("IfcSpace"): + if element.is_a("IfcSpace"): cls.hide_object(obj) continue cls.earliest_frame = None - product_frames = frames.get(obj.BIMObjectProperties.ifc_definition_id, []) + product_frames = frames.get(element.id(), []) for product_frame in product_frames: if product_frame["relationship"] == "input": cls.animate_input(obj, settings["start_frame"], product_frame, animation_type) diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index a54edb1559..f1b3f04464 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -25,6 +25,7 @@ import math import mathutils from mathutils import Matrix, Vector from lark import Lark, Transformer +from typing import Union class Snap(bonsai.core.tool.Snap): @@ -52,7 +53,10 @@ class Snap(bonsai.core.tool.Snap): distances = [3, 5, 15, 30] unit_system = tool.Drawing.get_unit_system() - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + if tool.Ifc.get(): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + unit_scale = tool.Blender.get_unit_scale() if unit_system == "IMPERIAL": factor = unit_scale fractions = [24, 12, 6, 2] @@ -140,7 +144,7 @@ class Snap(bonsai.core.tool.Snap): bpy.context.scene.BIMPolylineProperties.snap_mouse_ref.clear() @classmethod - def snap_on_axis(cls, intersection, tool_state, lock_angle=False): + def snap_on_axis(cls, intersection, tool_state): def create_axis_line_data(rot_mat, origin): length = 1000 direction = Vector((1, 0, 0)) @@ -152,31 +156,15 @@ class Snap(bonsai.core.tool.Snap): return start, end - def create_axis_rectangle_data(origin): - size = 0.5 - direction = Vector((1, 0, 0)) - if tool_state.plane_method == "YZ": - direction = Vector((0, 0, 1)) - rot_mat = Matrix.Rotation(math.radians(360), 3, pivot_axis) - rot_dir = rot_mat.inverted() @ direction - v1 = origin + rot_dir * 0 - v2 = origin + rot_dir * size - if tool_state.plane_method == "XY": - angle = 270 - else: - angle = 90 - rot_mat = Matrix.Rotation(math.radians(angle), 3, pivot_axis) - rot_dir = rot_mat.inverted() @ direction - v3 = origin + rot_dir * size - v4 = v2 + rot_dir * size - - return (v1, v2, v3, v4) - # Makes the snapping point more or less sticky than others # It changes the distance and affects how the snapping point is sorted - stick_factor = 0.15 + # We multiply by the increment snap which is based on the viewport zoom + snap_threshold = 1 * cls.get_increment_snap_value(bpy.context) - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + if tool.Ifc.get(): + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + default_container_elevation = 0.0 polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline polyline_points = polyline_data[0].polyline_points if polyline_data else [] if polyline_points: @@ -208,24 +196,35 @@ class Snap(bonsai.core.tool.Snap): if not axis: continue rot_mat = Matrix.Rotation(math.radians(360 - axis), 3, pivot_axis) + rot_mat = tool.Polyline.use_transform_orientations(rot_mat) rot_intersection = rot_mat @ translated_intersection proximity = rot_intersection.y if tool_state.plane_method == "XZ": - proximity = rot_intersection.z + proximity = rot_intersection.x - is_on_rot_axis = abs(proximity) <= stick_factor + is_on_rot_axis = abs(proximity) <= snap_threshold if is_on_rot_axis: elegible_axis.append((abs(proximity), axis)) - # Get the elegible axis with the lowest proximity + # Get the eligible axis with the lowest proximity if elegible_axis: proximity, axis = sorted(elegible_axis)[0] else: pass - # If lock axis is on it will use the snap angle so there is no need to search for elegible axis + # If lock axis is on it will use the snap angle so there is no need to search for eligible axis if elegible_axis or tool_state.lock_axis: + # Adapt axis to make snap angle work with other plane method + if elegible_axis: + if tool_state.plane_method == "XZ": + axis = 90 - (axis * -1) + else: + if tool_state.plane_method == "XZ": + axis = -axis + if tool_state.plane_method == "YZ": + axis = 90 - (axis * -1) rot_mat = Matrix.Rotation(math.radians(360 - axis), 3, pivot_axis) + rot_mat = tool.Polyline.use_transform_orientations(rot_mat) rot_intersection = rot_mat @ translated_intersection start, end = create_axis_line_data(rot_mat, last_point) PolylineDecorator.set_angle_axis_line(start, end) @@ -245,54 +244,56 @@ class Snap(bonsai.core.tool.Snap): # Creates a mixed snap point between the locked axis and the object snap # Then it sorts them to get the shortest first intersections = [] - intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((1, 0, 0)))) - intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((0, 1, 0)))) - intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point[0], Vector((0, 0, 1)))) - sorted_intersections = sorted(((i, "Mix") for i in intersections if i is not None), reverse=False) + if snap_point["type"] == "Face": + face_normal = ( + snap_point["object"].rotation_euler.to_matrix() + @ snap_point["object"].data.polygons[snap_point["face_index"]].normal + ) + if face_normal.z == 0: + intersections.append( + tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point["point"], face_normal.normalized()) + ) + if not intersections: + x_axis = tool.Polyline.use_transform_orientations(Vector((1, 0, 0))) + y_axis = tool.Polyline.use_transform_orientations(Vector((0, 1, 0))) + z_axis = tool.Polyline.use_transform_orientations(Vector((0, 0, 1))) + intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point["point"], x_axis)) + intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point["point"], y_axis)) + intersections.append(tool.Cad.intersect_edge_plane(axis_start, axis_end, snap_point["point"], z_axis)) + + polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if polyline_points: + last_point_data = polyline_points[-1] + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) + else: + last_point = Vector(0, 0, 0) + + valid_intersections = [] + for i in intersections: + if i is not None: + distance = (i - last_point).length + if not math.isclose(distance, 0.0, abs_tol=1e-4): + valid_intersections.append(i) + + sorted_intersections = sorted(valid_intersections, key=lambda x: (x - last_point).length, reverse=True) return sorted_intersections @classmethod def detect_snapping_points(cls, context: bpy.types.Context, event: bpy.types.Event, objs_2d_bbox, tool_state): rv3d = context.region_data space = context.space_data - mouse_pos = event.mouse_region_x, event.mouse_region_y detected_snaps = [] - snap_threshold = 0.3 - offset = 10 - mouse_offset = ( - (-offset, offset), - (0, offset), - (offset, offset), - (-offset, 0), - (0, 0), - (offset, 0), - (-offset, -offset), - (0, -offset), - (offset, -offset), - ) - - # TODO Snap like Blender snap increment. Enable this when we have a proper snap settings. - # Still need adjustments to improve the feel - def round_vector_with_increment(intersection, relative_point): - for i in range(len(intersection)): - interval = round(abs(relative_point[i] % increment), 4) - dist = round(abs(intersection[i] % increment), 4) - reference = interval - dist - if reference <= 0.5: - intersection[i] = intersection[i] + reference - else: - intersection[i] = intersection[i] - (1 - reference) - def select_plane_method(): if not last_polyline_point: plane_origin = Vector((0, 0, 0)) plane_normal = Vector((0, 0, 1)) - if not tool_state.plane_method: - camera_rotation = rv3d.view_rotation - plane_origin = Vector((0, 0, 0)) - view_direction = Vector((0, 0, -1)) @ camera_rotation.to_matrix().transposed() + view_rotation = rv3d.view_rotation + view_location = rv3d.view_location + view_direction = Vector((0, 0, -1)) @ view_rotation.to_matrix().transposed() + plane_origin = view_location + view_direction * 10 plane_normal = view_direction.normalized() if tool_state.plane_method == "XY" or ( @@ -316,61 +317,9 @@ class Snap(bonsai.core.tool.Snap): plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((1, 0, 0)) + plane_normal = tool.Polyline.use_transform_orientations(plane_normal) return plane_origin, plane_normal - def cast_rays_to_single_object(obj, mouse_pos): - if obj.type != "MESH": - return None, None, None - hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj) - if hit is None: - # Tried original mouse position. Now it will try the offsets. - original_mouse_pos = mouse_pos - for value in mouse_offset: - mouse_pos = tuple(x + y for x, y in zip(original_mouse_pos, value)) - hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj, mouse_pos) - if hit: - break - mouse_pos = original_mouse_pos - if hit: - hit_world = obj.original.matrix_world @ hit - return obj, hit_world, face_index - else: - return None, None, None - - def cast_rays_and_get_best_object(objs_to_raycast, mouse_pos): - best_length_squared = 1.0 - best_obj = None - best_hit = None - best_face_index = None - - for obj in objs_to_raycast: - snap_obj, hit, face_index = cast_rays_to_single_object(obj, mouse_pos) - - if hit is not None: - length_squared = (hit - ray_origin).length_squared - if best_obj is None or length_squared < best_length_squared: - best_length_squared = length_squared - best_obj = snap_obj - best_hit = hit - best_face_index = face_index - - if best_obj is not None: - return best_obj, best_hit, best_face_index - - else: - return None, None, None - - ray_origin, ray_target, ray_direction = tool.Raycast.get_viewport_ray_data(context, event) - - objs_to_raycast = [] - for obj, bbox_2d in objs_2d_bbox: - if obj.type in {"MESH", "EMPTY", "CURVE"} and bbox_2d: - if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d, offset): - if obj.visible_in_viewport_get( - context.space_data - ): # Check for local view and local collections for this viewport and object - objs_to_raycast.append(obj) - # Polyline try: polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline[0] @@ -382,12 +331,9 @@ class Snap(bonsai.core.tool.Snap): if polyline_points: snap_points = tool.Raycast.ray_cast_to_polyline(context, event) if snap_points: - detected_snaps.append( - { - "group": "Polyline", - "points": snap_points, - } - ) + for point in snap_points: + point["group"] = "Polyline" + detected_snaps.append(point) # Measure measure_data = context.scene.BIMPolylineProperties.measurement_polyline @@ -395,72 +341,64 @@ class Snap(bonsai.core.tool.Snap): measure_points = measure.polyline_points snap_points = tool.Raycast.ray_cast_to_measure(context, event, measure_points) if snap_points: - detected_snaps.append( - { - "group": "Measure", - "points": snap_points, - } - ) + for point in snap_points: + point["group"] = "Measure" + detected_snaps.append(point) - # Edge-Vertex - for obj in objs_to_raycast: - if obj.type == "MESH": - if len(obj.data.polygons) == 0: - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj) - if snap_points: - detected_snaps.append( - { - "group": "Edge-Vertex", - "object": obj, - "points": snap_points, - } - ) - if obj.type == "CURVE": - new_object = bpy.data.objects.new("new_object", obj.to_mesh().copy()) - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, new_object) - if snap_points: - detected_snaps.append( - { - "group": "Edge-Vertex", - "object": obj, - "points": snap_points, - } - ) - if obj.type == "EMPTY": - snap_point = { - "points": [{"type": "Vertex", "point": obj.location}], - "group": "Edge-Vertex", - "object": obj, - } - detected_snaps.append(snap_point) - - # Obj + # Objects + objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox) if (space.shading.type == "SOLID" and space.shading.show_xray) or ( space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe ): + results = [] for obj in objs_to_raycast: - snap_obj, hit, face_index = cast_rays_to_single_object(obj, mouse_pos) - if hit is not None: + results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj)) + else: + results = [] + results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast)) + + for result in results: + snap_obj = result[0] + hit = result[1] + face_index = result[2] + if hit is not None: + # Wireframes + if snap_obj.type in {"EMPTY", "CURVE"} or ( + snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0 + ): + snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj) + if snap_points: + for point in snap_points: + point["group"] = "Wireframe" + detected_snaps.append(point) + + # Meshes + else: + # Add face snap snap_point = { "point": hit, + "type": "Face", "group": "Object", "object": snap_obj, "face_index": face_index, + "distance": 9, # High value so it has low priority } detected_snaps.append(snap_point) - else: - snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast, mouse_pos) - if hit is not None: - snap_point = { - "point": hit, - "group": "Object", - "object": snap_obj, - "face_index": face_index, - } - detected_snaps.append(snap_point) + + # Add vertex and edge snap + snap_points = tool.Raycast.ray_cast_by_proximity( + context, event, snap_obj, snap_obj.data.polygons[face_index] + ) + if snap_points: + for point in snap_points: + point["group"] = "Object" + detected_snaps.append(point) # Axis and Plane - elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + if tool.Ifc.get(): + elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + else: + elevation = 0.0 plane_origin, plane_normal = select_plane_method() tool_state.plane_origin = plane_origin # This will be used along with plane method @@ -470,7 +408,6 @@ class Snap(bonsai.core.tool.Snap): axis_start = None axis_end = None - # TODO It only work for XY plane. Make it work also for None plane_method rot_intersection = None if not tool_state.plane_method: if tool_state.axis_method == "X": @@ -481,37 +418,43 @@ class Snap(bonsai.core.tool.Snap): tool_state.snap_angle = 90 if tool_state.axis_method: # Doesn't update snap_angle so that it keeps in the same axis - rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state, True) + rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state) if tool_state.plane_method: if tool_state.plane_method in {"XY", "XZ"} and tool_state.axis_method == "X": tool_state.snap_angle = 180 - if tool_state.plane_method in {"XY", "YZ"} and tool_state.axis_method == "Y": + if tool_state.plane_method in {"XY"} and tool_state.axis_method == "Y": tool_state.snap_angle = 90 - if tool_state.plane_method in {"YZ"} and tool_state.axis_method == "Z": + if tool_state.plane_method in {"YZ"} and tool_state.axis_method == "Y": tool_state.snap_angle = 180 - if tool_state.plane_method in {"XZ"} and tool_state.axis_method == "Z": + if tool_state.plane_method in {"XZ", "YZ"} and tool_state.axis_method == "Z": tool_state.snap_angle = 90 if tool_state.lock_axis or tool_state.axis_method: # Doesn't update snap_angle so that it keeps in the same axis - rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state, True) + rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state) else: rot_intersection, tool_state.snap_angle, axis_start, axis_end = cls.snap_on_axis( - intersection, tool_state, False + intersection, tool_state ) if rot_intersection and polyline_points: snap_point = { "point": rot_intersection, + "object": None, "group": "Axis", + "type": "Axis", "axis_start": axis_start, "axis_end": axis_end, + "distance": 10, # High value so it has low priority } detected_snaps.append(snap_point) snap_point = { "point": intersection, + "object": None, "group": "Plane", + "type": "Plane", + "distance": 10, # High value so it has low priority } detected_snaps.append(snap_point) @@ -519,18 +462,18 @@ class Snap(bonsai.core.tool.Snap): @classmethod def select_snapping_points(cls, context, event, tool_state, detected_snaps): - def filter_snapping_points_based_on_settings(snapping_points): + def filter_snapping_points_by_type(snapping_points): options = ["Plane", "Axis"] props = context.scene.BIMSnapProperties for prop in props.__annotations__.keys(): if getattr(props, prop): options.append(props.rna_type.properties[prop].name) - filtered_points = [point for point in snapping_points if point[1] in options] + filtered_points = [point for point in snapping_points if point["type"] in options] return filtered_points - def filter_snapping_groups_based_on_settings(detected_snaps): - options = ["Edge-Vertex", "Axis", "Plane"] + def filter_snapping_points_by_group(detected_snaps): + options = ["Wireframe", "Axis", "Plane"] props = context.scene.BIMSnapGroups for prop in props.__annotations__.keys(): if getattr(props, prop): @@ -538,90 +481,71 @@ class Snap(bonsai.core.tool.Snap): filtered_groups = [group for group in detected_snaps if group["group"] in options] return filtered_groups - filtered_snaps = filter_snapping_groups_based_on_settings(detected_snaps) - snapping_points = [] + def sort_points_by_weighted_distance(snapping_points): + for snap in snapping_points: + weight_factor = 100 * cls.get_increment_snap_value(context) + if snap["type"] == "Vertex": + snap["distance"] *= weight_factor / 12 + if snap["type"] == "Edge": + snap["distance"] *= weight_factor + if snap["type"] == "Edge Center": + snap["distance"] *= weight_factor / 5 + if snap["type"] == "Edge Intersection": + snap["distance"] *= weight_factor / 10 + if snap["type"] in ["Plane", "Axis", "Face"]: + snap["distance"] = weight_factor / 50 + return sorted(snapping_points, key=lambda x: x["distance"]) + + snaps_by_group = filter_snapping_points_by_group(detected_snaps) edges = [] # Get edges to create edge-intersection snap - for snap_group in filtered_snaps: - snap_obj = None - if snap_group["group"] == "Polyline": - for p in snap_group["points"]: - snapping_points.append((p["point"], p["type"], None)) - break - if snap_group["group"] == "Measure": - for p in snap_group["points"]: - snapping_points.append((p["point"], p["type"], None)) - if p["type"] == "Edge": - edges.append(p) - break - if snap_group["group"] == "Edge-Vertex": - for p in snap_group["points"]: - snapping_points.append((p["point"], p["type"], snap_group["object"])) - if p["type"] == "Edge": - edges.append(p) - - if snap_group["group"] == "Object": - obj = snap_group["object"] - matrix = obj.matrix_world.copy() - face = obj.data.polygons[snap_group["face_index"]] - verts = [] - for i in face.vertices: - verts.append(matrix @ obj.data.vertices[i].co) - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj, face) - if not snap_points: - snapping_points.append((snap_group["point"], "Face", obj)) - else: - for p in snap_points: - snapping_points.append((p["point"], p["type"], obj)) - if p["type"] == "Edge": - edges.append(p) - break - - for snap_group in filtered_snaps: - if snap_group["group"] == "Axis": - axis_start = snap_group["axis_start"] - axis_end = snap_group["axis_end"] - snapping_points.append((snap_group["point"], "Axis", snap_obj)) - - if snap_group["group"] == "Plane": - snapping_points.append((snap_group["point"], "Plane", snap_obj)) + for snapping_point in snaps_by_group: + if snapping_point["group"] in {"Polyline", "Measure", "Wireframe", "Object"}: + if snapping_point["type"] == "Edge": + edges.append(snapping_point) + if snapping_point["group"] == "Axis": + axis_start = snapping_point["axis_start"] + axis_end = snapping_point["axis_end"] # Edges intersection snap if edges: - for e1, e2 in zip(edges, edges[1:] + [edges[0]]): - if tool.Cad.are_vectors_equal(e1["point"], e2["point"], tolerance=0.1): - intersection = tool.Cad.intersect_edges_v2(e1["edge_verts"], e2["edge_verts"]) - if intersection[1]: - snapping_points.insert(0, (intersection[1], "Edge Intersection", None)) + snap_point = tool.Raycast.ray_cast_to_edge_intersection(context, event, edges) + if snap_point: + snaps_by_group.insert(0, snap_point) - filtered_snapping_points = filter_snapping_points_based_on_settings(snapping_points) + snaps_by_type = filter_snapping_points_by_type(snaps_by_group) + ordered_snaps = sort_points_by_weighted_distance(snaps_by_type) # Make Axis first priority if tool_state.lock_axis or tool_state.axis_method in {"X", "Y", "Z"}: - cls.update_snapping_ref(filtered_snapping_points[0][0], filtered_snapping_points[0][1]) - for point in filtered_snapping_points: - if point[1] == "Axis": - if filtered_snapping_points[0][1] not in {"Axis", "Plane"}: - mixed_snap = cls.mix_snap_and_axis(filtered_snapping_points[0], axis_start, axis_end) + cls.update_snapping_ref(ordered_snaps[0]["point"], ordered_snaps[0]["type"]) + for point in ordered_snaps: + if point["type"] == "Axis": + if ordered_snaps[0]["type"] not in {"Axis", "Plane"}: + obj = ordered_snaps[0]["object"] + mixed_snap = cls.mix_snap_and_axis(ordered_snaps[0], axis_start, axis_end) for mixed_point in mixed_snap: - filtered_snapping_points.insert(0, mixed_point) - cls.update_snapping_point(mixed_snap[0][0], mixed_snap[0][1]) - return filtered_snapping_points - cls.update_snapping_point(point[0], point[1]) - return filtered_snapping_points + snap_point = { + "point": mixed_point, + "type": "Mix", + "object": obj, + } + ordered_snaps.insert(0, snap_point) + cls.update_snapping_point(snap_point["point"], snap_point["type"]) + return ordered_snaps + cls.update_snapping_point(point["point"], point["type"]) + return ordered_snaps - cls.update_snapping_point( - filtered_snapping_points[0][0], filtered_snapping_points[0][1], filtered_snapping_points[0][2] - ) - return filtered_snapping_points + cls.update_snapping_point(ordered_snaps[0]["point"], ordered_snaps[0]["type"], ordered_snaps[0]["object"]) + return ordered_snaps @classmethod def modify_snapping_point_selection(cls, snapping_points, lock_axis=False): shifted_list = snapping_points[1:] + snapping_points[:1] if lock_axis: # Will only cycle through mix or axis - non_axis_snap = [point for point in snapping_points if point[1] not in {"Axis", "Mix"}] - axis_snap = [point for point in snapping_points if point[1] in {"Axis", "Mix"}] + non_axis_snap = [point for point in snapping_points if point["type"] not in {"Axis", "Mix"}] + axis_snap = [point for point in snapping_points if point["type"] in {"Axis", "Mix"}] shifted_list = axis_snap[1:] + axis_snap[:1] shifted_list.extend(non_axis_snap) - cls.update_snapping_point(shifted_list[0][0], shifted_list[0][1]) + cls.update_snapping_point(shifted_list[0]["point"], shifted_list[0]["type"]) return shifted_list diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index f2c784c464..93d0478f4a 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -38,15 +38,35 @@ import bonsai.core.geometry import bonsai.core.unit import bonsai.tool as tool import json +import numpy as np from math import pi from mathutils import Vector, Matrix from shapely import Polygon -from typing import Generator, Optional, Union, Literal, List, Any, Iterable +from typing import Generator, Optional, Union, Literal, List, Any, Iterable, TYPE_CHECKING from collections import defaultdict from natsort import natsorted +if TYPE_CHECKING: + from bonsai.bim.module.spatial.prop import ( + BIMGridProperties, + BIMSpatialDecompositionProperties, + BIMObjectSpatialProperties, + ) + class Spatial(bonsai.core.tool.Spatial): + @classmethod + def get_spatial_props(cls) -> BIMSpatialDecompositionProperties: + return bpy.context.scene.BIMSpatialDecompositionProperties + + @classmethod + def get_object_spatial_props(cls, obj: bpy.types.Object) -> BIMObjectSpatialProperties: + return obj.BIMObjectSpatialProperties + + @classmethod + def get_grid_props(cls) -> BIMGridProperties: + return bpy.context.scene.BIMGridProperties + @classmethod def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool: if not (element := tool.Ifc.get_entity(element_obj)): @@ -81,7 +101,8 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def disable_editing(cls, obj: bpy.types.Object) -> None: - obj.BIMObjectSpatialProperties.is_editing = False + props = cls.get_object_spatial_props(obj) + props.is_editing = False @classmethod def duplicate_object_and_data(cls, obj: bpy.types.Object) -> bpy.types.Object: @@ -92,8 +113,9 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def enable_editing(cls, obj: bpy.types.Object) -> None: - obj.BIMObjectSpatialProperties.is_editing = True - obj.BIMObjectSpatialProperties.relating_container_object = None + props = cls.get_object_spatial_props(obj) + props.is_editing = True + props.relating_container_object = None @classmethod def get_container(cls, element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: @@ -207,7 +229,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def get_container_elements_grouped_by_classification(cls, container: ifcopenshell.entity_instance) -> dict: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() results = {} if props.should_include_children: elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True) @@ -254,7 +276,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def get_container_elements_grouped_by_type(cls, container: ifcopenshell.entity_instance) -> dict: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() results: defaultdict[str, dict[int, Any]] = defaultdict(dict) if props.should_include_children: elements = ifcopenshell.util.element.get_decomposition(container, is_recursive=True) @@ -280,7 +302,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def load_contained_elements(cls) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() props.elements.clear() if not (container := props.active_container): return @@ -295,7 +317,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def load_contained_elements_by_type(cls, container: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() results = cls.get_container_elements_grouped_by_type(container) expanded_elements = json.loads(props.expanded_elements) @@ -350,7 +372,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def load_contained_elements_by_decomposition(cls, container: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() expanded_elements = json.loads(props.expanded_elements) expanded_ifc_ids = expanded_elements.get("IFC_ID", []) @@ -381,7 +403,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def load_contained_elements_by_classification(cls, container: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() expanded_elements = json.loads(props.expanded_elements) expanded_classifications = expanded_elements.get("CLASSIFICATION", []) expanded_classifications_r = expanded_elements.get("CLASSIFICATION_R", []) @@ -445,7 +467,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def import_spatial_decomposition(cls) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() previous_container_index = props.active_container_index props.containers.clear() cls.contracted_containers = json.loads(props.contracted_containers) @@ -456,7 +478,7 @@ class Spatial(bonsai.core.tool.Spatial): def import_spatial_element(cls, element: ifcopenshell.entity_instance, level_index: int) -> None: if not element.is_a("IfcProject") and not tool.Root.is_spatial_element(element): return - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() new = props.containers.add() new.ifc_class = element.is_a() new["name"] = element.Name or "Unnamed" @@ -480,13 +502,49 @@ class Spatial(bonsai.core.tool.Spatial): cls.import_spatial_element(child, level_index + 1) @classmethod - def edit_container_attributes(cls, entity: ifcopenshell.entity_instance) -> None: - # TODO - obj = tool.Ifc.get_object(entity) - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - name = bpy.context.scene.BIMSpatialDecompositionProperties.container_name - if name != entity.Name: - cls.edit_container_name(entity, name) + def create_orientation_slot(cls, container: ifcopenshell.entity_instance) -> None: + active_slot = bpy.context.scene.transform_orientation_slots[0] + placement = container.ObjectPlacement + combined_matrix = ifcopenshell.util.placement.get_local_placement(placement)[:3, :3] + + if np.allclose(combined_matrix, np.eye(3), atol=1e-6): + # this spatial element has global orientation + active_slot.type = "GLOBAL" + return + elif ( + hasattr(container, "Decomposes") + and container.Decomposes + and hasattr(container.Decomposes[0].RelatingObject, "ObjectPlacement") + ): + # this spatial element is part of a decomposition + parent_placement = container.Decomposes[0].RelatingObject.ObjectPlacement + parent_matrix = ifcopenshell.util.placement.get_local_placement(parent_placement)[:3, :3] + if np.allclose(combined_matrix, parent_matrix, atol=1e-6): + # this spatial element has the same orientation as its parent + cls.create_orientation_slot(container=container.Decomposes[0].RelatingObject) + return + + # this spatial element has a unique orientation + orientation_name = container.is_a() + "/" + container.Name + + # stash selected objects + active_object = bpy.context.view_layer.objects.active + selected_objects = list(bpy.context.view_layer.objects.selected) + + # bpy.ops.transform.create_orientation() requires a dummy object + bpy.ops.object.empty_add(type="PLAIN_AXES") + bpy.ops.transform.create_orientation(name=orientation_name, overwrite=True) + active_slot.type = orientation_name + active_slot.custom_orientation.matrix = np.linalg.inv(combined_matrix) + + # delete dummy object + bpy.ops.object.delete() + + # reinstate selected objects + for obj in selected_objects: + obj.select_set(True) + if active_object: + bpy.context.view_layer.objects.active = active_object @classmethod def edit_container_name(cls, container: ifcopenshell.entity_instance, name: str) -> None: @@ -494,28 +552,28 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def get_active_container(cls) -> Union[ifcopenshell.entity_instance, None]: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() if props.active_container_index < len(props.containers): container = tool.Ifc.get().by_id(props.containers[props.active_container_index].ifc_definition_id) return container @classmethod def contract_container(cls, container: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() contracted_containers = json.loads(props.contracted_containers) contracted_containers.append(container.id()) props.contracted_containers = json.dumps(contracted_containers) @classmethod def expand_container(cls, container: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() contracted_containers = json.loads(props.contracted_containers) contracted_containers.remove(container.id()) props.contracted_containers = json.dumps(contracted_containers) @classmethod def toggle_container_element(cls, element_index: int, is_recursive: bool) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() if props.element_mode == "TYPE": cls.toggle_container_element_by_type(element_index, is_recursive) elif props.element_mode == "DECOMPOSITION": @@ -525,7 +583,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def toggle_container_element_by_type(cls, element_index: int, is_recursive: bool) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() expanded_elements: dict[str, list[Union[str, int]]] = json.loads(props.expanded_elements) element = props.elements[element_index] @@ -574,7 +632,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def toggle_container_element_by_decomposition(cls, element_index: int, is_recursive: bool) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() element = props.elements[element_index] expanded_elements: dict[str, list[Union[str, int]]] = json.loads(props.expanded_elements) expanded_elements_list: list[Union[str, int]] = expanded_elements.setdefault("IFC_ID", []) @@ -607,7 +665,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def toggle_container_element_by_classification(cls, element_index: int, is_recursive: bool) -> None: - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() expanded_elements: dict[str, list[str]] = json.loads(props.expanded_elements) expanded_elements_list: list[str] = expanded_elements.setdefault("CLASSIFICATION", []) @@ -688,7 +746,7 @@ class Spatial(bonsai.core.tool.Spatial): @classmethod def get_boundary_lines_from_context_visible_objects(cls) -> list[shapely.LineString]: - props = props = tool.Model.get_model_props() + props = tool.Model.get_model_props() calculation_rl = props.rl3 container = tool.Root.get_default_container() container_obj = tool.Ifc.get_object(container) @@ -699,9 +757,10 @@ class Spatial(bonsai.core.tool.Spatial): for obj in bpy.context.visible_objects: visible_element = tool.Ifc.get_entity(obj) + old_mesh = obj.data if ( not visible_element - or obj.type != "MESH" + or not isinstance(old_mesh, bpy.types.Mesh) or not cls.is_bounding_class(visible_element) or not tool.Drawing.is_intersecting_plane(obj, cut_point, cut_normal) ): @@ -959,7 +1018,7 @@ class Spatial(bonsai.core.tool.Spatial): old_mesh = active_obj.data old_mesh_name = old_mesh.name assert active_obj and isinstance(old_mesh, bpy.types.Mesh) - mesh.BIMMeshProperties.ifc_definition_id = old_mesh.BIMMeshProperties.ifc_definition_id + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id tool.Geometry.change_object_data(active_obj, mesh, is_global=True) tool.Ifc.edit(active_obj) tool.Blender.remove_data_block(old_mesh) @@ -1162,14 +1221,14 @@ class Spatial(bonsai.core.tool.Spatial): def set_default_container(cls, container: ifcopenshell.entity_instance) -> None: from bonsai.bim.module.spatial.data import SpatialDecompositionData - assert bpy.context - bpy.context.scene.BIMSpatialDecompositionProperties.default_container = container.id() + props = cls.get_spatial_props() + props.default_container = container.id() SpatialDecompositionData.data["default_container"] = SpatialDecompositionData.default_container() project = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) - project_collection = project.BIMObjectProperties.collection + project_collection = tool.Blender.get_object_bim_props(project).collection obj = tool.Ifc.get_object(container) - if obj and (collection := obj.BIMObjectProperties.collection): + if obj and (collection := tool.Blender.get_object_bim_props(obj).collection): for layer_collection in bpy.context.view_layer.layer_collection.children: if layer_collection.collection == project_collection: for layer_collection2 in layer_collection.children: @@ -1220,16 +1279,16 @@ class Spatial(bonsai.core.tool.Spatial): def set_target_container_as_default(cls) -> None: if ( (container := tool.Root.get_default_container()) - and (obj := tool.Ifc.get_object(container)) - and bpy.context.active_object + and (container_obj := tool.Ifc.get_object(container)) + and (obj := bpy.context.active_object) ): - props = bpy.context.active_object.BIMObjectSpatialProperties - props.container_obj = obj + props = cls.get_object_spatial_props(obj) + props.container_obj = container_obj @classmethod def get_filtered_elements(cls, should_filter: bool = True) -> Iterable[ifcopenshell.entity_instance]: ifc_file = tool.Ifc.get() - props = bpy.context.scene.BIMSpatialDecompositionProperties + props = cls.get_spatial_props() container = ifc_file.by_id(props.active_container.ifc_definition_id) element_filter = props.element_filter active_element = props.active_element diff --git a/src/bonsai/bonsai/tool/structural.py b/src/bonsai/bonsai/tool/structural.py index 7dfa3dbdd0..54c73ca537 100644 --- a/src/bonsai/bonsai/tool/structural.py +++ b/src/bonsai/bonsai/tool/structural.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import ifcopenshell import ifcopenshell.util.representation @@ -23,32 +24,46 @@ import json import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool -from bonsai.bim.ifc import IfcStore -from pprint import pprint -from typing import Union, Any +from typing import Union, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.structural.prop import BIMStructuralProperties, BIMObjectStructuralProperties class Structural(bonsai.core.tool.Structural): + @classmethod + def get_structural_props(cls) -> BIMStructuralProperties: + return bpy.context.scene.BIMStructuralProperties + + @classmethod + def get_object_structural_props(cls, obj: bpy.types.Object) -> BIMObjectStructuralProperties: + return obj.BIMStructuralProperties + @classmethod def disable_editing_structural_analysis_model(cls) -> None: - bpy.context.scene.BIMStructuralProperties.active_structural_analysis_model_id = 0 + props = cls.get_structural_props() + props.active_structural_analysis_model_id = 0 @classmethod def disable_structural_analysis_model_editing_ui(cls) -> None: - bpy.context.scene.BIMStructuralProperties.is_editing = False + props = cls.get_structural_props() + props.is_editing = False @classmethod def enable_editing_structural_analysis_model(cls, model: Union[int, None]) -> None: if model: - bpy.context.scene.BIMStructuralProperties.active_structural_analysis_model_id = model + props = cls.get_structural_props() + props.active_structural_analysis_model_id = model @classmethod def enable_structural_analysis_model_editing_ui(cls) -> None: - bpy.context.scene.BIMStructuralProperties.is_editing = True + props = cls.get_structural_props() + props.is_editing = True @classmethod def enabled_structural_analysis_model_editing_ui(cls) -> bool: - return bpy.context.scene.BIMStructuralProperties.is_editing + props = cls.get_structural_props() + return props.is_editing @classmethod def ensure_representation_contexts(cls) -> None: @@ -73,7 +88,7 @@ class Structural(bonsai.core.tool.Structural): @classmethod def get_active_structural_analysis_model(cls) -> ifcopenshell.entity_instance: - props = bpy.context.scene.BIMStructuralProperties + props = cls.get_structural_props() model = tool.Ifc.get().by_id(props.active_structural_analysis_model_id) return model @@ -112,7 +127,8 @@ class Structural(bonsai.core.tool.Structural): def get_product_or_active_object(cls, product: str) -> Union[bpy.types.Object, None]: product = bpy.data.objects.get(product) if product else bpy.context.active_object try: - if product.BIMObjectProperties.ifc_definition_id: + props = tool.Blender.get_object_bim_props(product) + if props.ifc_definition_id: return product else: return None @@ -121,15 +137,16 @@ class Structural(bonsai.core.tool.Structural): @classmethod def get_structural_analysis_model_attributes(cls) -> dict[str, Any]: - props = bpy.context.scene.BIMStructuralProperties + props = cls.get_structural_props() attributes = bonsai.bim.helper.export_attributes(props.structural_analysis_model_attributes) return attributes @classmethod def load_structural_analysis_model_attributes(cls, data: dict[str, Any]) -> None: - props = bpy.context.scene.BIMStructuralProperties + props = cls.get_structural_props() props.structural_analysis_model_attributes.clear() - for attribute in IfcStore.get_schema().declaration_by_name("IfcStructuralAnalysisModel").all_attributes(): + schema = tool.Ifc.schema() + for attribute in schema.declaration_by_name("IfcStructuralAnalysisModel").all_attributes(): data_type = str(attribute.type_of_attribute) if " None: models = tool.Structural.get_ifc_structural_analysis_models() - props = bpy.context.scene.BIMStructuralProperties + props = cls.get_structural_props() props.structural_analysis_models.clear() for ifc_definition_id, model in models.items(): new = props.structural_analysis_models.add() diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index c41669fa81..42a3dc207a 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -16,9 +16,11 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import numpy as np import ifcopenshell +import ifcopenshell.api.style import ifcopenshell.util.element import ifcopenshell.util.representation import bonsai.core.style @@ -26,7 +28,10 @@ import bonsai.core.tool import bonsai.tool as tool import bonsai.bim.helper from mathutils import Color -from typing import Union, Any, Optional, Literal +from typing import Union, Any, Optional, Literal, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.style.prop import BIMStylesProperties, BIMStyleProperties # fmt: off TEXTURE_MAPS_BY_METHODS = { @@ -44,10 +49,18 @@ STYLE_PROPS_MAP = { "specular_colour": "SpecularColour", } -STYLE_TYPES = Literal["Shading", "External"] - class Style(bonsai.core.tool.Style): + StyleType = Literal["Shading", "External"] + + @classmethod + def get_style_props(cls) -> BIMStylesProperties: + return bpy.context.scene.BIMStylesProperties + + @classmethod + def get_material_style_props(cls, material: bpy.types.Material) -> BIMStyleProperties: + return material.BIMStyleProperties + @classmethod def can_support_rendering_style(cls, obj: bpy.types.Material) -> bool: return obj.use_nodes and hasattr(obj.node_tree, "nodes") @@ -58,19 +71,19 @@ class Style(bonsai.core.tool.Style): @classmethod def enable_adding_presentation_style(cls) -> None: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_adding = True props.update_graph = False @classmethod def disable_adding_presentation_style(cls) -> None: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_adding = False props.update_graph = True @classmethod def disable_editing(cls) -> None: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_editing_style = 0 props.is_editing_class = "" props.attributes.clear() @@ -81,7 +94,7 @@ class Style(bonsai.core.tool.Style): @classmethod def disable_editing_styles(cls) -> None: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_editing = False props.styles.clear() @@ -100,27 +113,29 @@ class Style(bonsai.core.tool.Style): @classmethod def enable_editing(cls, style: ifcopenshell.entity_instance) -> None: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_editing_style = style.id() props.is_editing_class = "IfcSurfaceStyle" @classmethod def enable_editing_styles(cls) -> None: - bpy.context.scene.BIMStylesProperties.is_editing = True + props = cls.get_style_props() + props.is_editing = True @classmethod def export_surface_attributes(cls) -> dict[str, Any]: - props = bpy.context.scene.BIMStylesProperties + props = cls.get_style_props() return bonsai.bim.helper.export_attributes(props.attributes) @classmethod def get_active_style_in_ui(cls) -> Union[bpy.types.PropertyGroup, None]: - props = bpy.context.scene.BIMStylesProperties + props = cls.get_style_props() return props.active_style @classmethod def get_active_style_type(cls) -> str: - return bpy.context.scene.BIMStylesProperties.style_type + props = cls.get_style_props() + return props.style_type @classmethod def get_context(cls) -> Union[ifcopenshell.entity_instance, None]: @@ -136,7 +151,7 @@ class Style(bonsai.core.tool.Style): @classmethod def get_currently_edited_material(cls) -> bpy.types.Material: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = tool.Ifc.get().by_id(props.is_editing_style) obj = tool.Ifc.get_object(style) assert isinstance(obj, bpy.types.Material) @@ -147,14 +162,14 @@ class Style(bonsai.core.tool.Style): cls, blender_material_or_style: Union[bpy.types.Material, ifcopenshell.entity_instance] ) -> dict[str, ifcopenshell.entity_instance]: if isinstance(blender_material_or_style, bpy.types.Material): - if not (ifc_definition_id := blender_material_or_style.BIMStyleProperties.ifc_definition_id): + style = tool.Ifc.get_entity(blender_material_or_style) + if not style: return {} - style = tool.Ifc.get().by_id(ifc_definition_id) else: style = blender_material_or_style style_elements = {} - for style in style.Styles: - style_elements[style.is_a()] = style + for style_ in style.Styles: + style_elements[style_.is_a()] = style_ return style_elements @classmethod @@ -162,7 +177,7 @@ class Style(bonsai.core.tool.Style): """returns style data from blender props in similar way to `Loader.surface_style_to_dict` to be compatible with `Loader.create_surface_style_rendering`""" surface_style_data = dict() - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() available_props = props.bl_rna.properties.keys() for prop_blender, prop_ifc in STYLE_PROPS_MAP.items(): @@ -189,7 +204,7 @@ class Style(bonsai.core.tool.Style): def get_texture_style_data_from_props(cls) -> list[dict[str, Any]]: """returns style data from blender props in similar way to `Loader.surface_texture_to_dict` to be compatible with `Loader.create_surface_style_with_textures`""" - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() textures = [] for texture in props.textures: @@ -210,7 +225,7 @@ class Style(bonsai.core.tool.Style): """set blender style props based on currently edited IfcSurfaceStyle, reset unrelated props to default values""" - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = tool.Ifc.get().by_id(props.is_editing_style) # make sure won't be updating while we changing it prev_update_graph_value = props.update_graph @@ -290,7 +305,7 @@ class Style(bonsai.core.tool.Style): return next((l.from_node for l in input_pin.links if l.from_node.type == of_type), None) return next((l.from_node for l in input_pin.links), None) - props = obj.BIMStyleProperties + props = tool.Style.get_material_style_props(obj) transparency = 1 - obj.diffuse_color[3] diffuse_color = obj.diffuse_color viewport_color = color_to_ifc_format(obj.diffuse_color) @@ -472,16 +487,14 @@ class Style(bonsai.core.tool.Style): @classmethod def get_surface_shading_style(cls, obj: bpy.types.Material) -> Union[ifcopenshell.entity_instance, None]: - if ifc_definition_id := obj.BIMStyleProperties.ifc_definition_id: - style = tool.Ifc.get().by_id(ifc_definition_id) + if style := tool.Ifc.get_entity(obj): items = [s for s in style.Styles if s.is_a() == "IfcSurfaceStyleShading"] if items: return items[0] @classmethod def get_surface_texture_style(cls, obj: bpy.types.Material) -> Union[ifcopenshell.entity_instance, None]: - if ifc_definition_id := obj.BIMStyleProperties.ifc_definition_id: - style = tool.Ifc.get().by_id(ifc_definition_id) + if style := tool.Ifc.get_entity(obj): items = [s for s in style.Styles if s.is_a("IfcSurfaceStyleWithTextures")] if items: return items[0] @@ -503,7 +516,7 @@ class Style(bonsai.core.tool.Style): @classmethod def get_style_ui_props_attributes(cls, style_type: str) -> Union[bpy.types.PropertyGroup, None]: - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() if style_type == "IfcExternallyDefinedSurfaceStyle": return props.external_style_attributes elif style_type == "IfcSurfaceStyleRefraction": @@ -514,7 +527,7 @@ class Style(bonsai.core.tool.Style): @classmethod def import_presentation_styles(cls, style_type: str) -> None: color_to_tuple = lambda x: (x.Red, x.Green, x.Blue) - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.styles.clear() styles = sorted(tool.Ifc.get().by_type(style_type), key=lambda x: x.Name or "Unnamed") for style in styles: @@ -537,7 +550,8 @@ class Style(bonsai.core.tool.Style): @classmethod def import_surface_attributes(cls, style: ifcopenshell.entity_instance) -> None: - attributes = bpy.context.scene.BIMStylesProperties.attributes + props = cls.get_style_props() + attributes = props.attributes attributes.clear() bonsai.bim.helper.import_attributes2(style, attributes) @@ -548,11 +562,13 @@ class Style(bonsai.core.tool.Style): @classmethod def is_editing_styles(cls) -> bool: - return bpy.context.scene.BIMStylesProperties.is_editing + props = cls.get_style_props() + return props.is_editing @classmethod def is_editing_style(cls) -> bool: - return bpy.context.scene.BIMStylesProperties.is_editing_style + props = cls.get_style_props() + return bool(props.is_editing_style) @classmethod def select_elements(cls, elements: list[ifcopenshell.entity_instance]) -> None: @@ -563,7 +579,8 @@ class Style(bonsai.core.tool.Style): @classmethod def change_current_style_type(cls, blender_material: bpy.types.Material, style_type: str) -> None: - blender_material.BIMStyleProperties.active_style_type = style_type + props = cls.get_material_style_props(blender_material) + props.active_style_type = style_type @classmethod def get_styled_items(cls, style: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: @@ -614,10 +631,11 @@ class Style(bonsai.core.tool.Style): @classmethod def reload_material_from_ifc(cls, blender_material: bpy.types.Material) -> None: - blender_material.BIMStyleProperties.active_style_type = blender_material.BIMStyleProperties.active_style_type + props = cls.get_material_style_props(blender_material) + props.active_style_type = props.active_style_type @classmethod - def switch_shading(cls, blender_material: bpy.types.Material, style_type: STYLE_TYPES) -> None: + def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None: if style_type == "External": try: bpy.ops.bim.activate_external_style(material_name=blender_material.name) diff --git a/src/bonsai/bonsai/tool/surveyor.py b/src/bonsai/bonsai/tool/surveyor.py index 80aa96a341..7c203586f2 100644 --- a/src/bonsai/bonsai/tool/surveyor.py +++ b/src/bonsai/bonsai/tool/surveyor.py @@ -32,8 +32,8 @@ class Surveyor(bonsai.core.tool.Surveyor): def get_absolute_matrix(cls, obj: bpy.types.Object) -> npt.NDArray[np.float64]: M_TRANSLATION = (slice(0, 3), 3) matrix = np.array(obj.matrix_world) - props = bpy.context.scene.BIMGeoreferenceProperties - if props.has_blender_offset and obj.BIMObjectProperties.blender_offset_type != "NOT_APPLICABLE": + props = tool.Georeference.get_georeference_props() + if props.has_blender_offset and tool.Blender.get_object_bim_props(obj).blender_offset_type != "NOT_APPLICABLE": unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) coordinate_offset = tool.Geometry.get_cartesian_point_offset(obj) if coordinate_offset is not None: diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 51334be3a2..649cd22a2b 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -30,12 +30,11 @@ import re from math import pi, cos, sin from mathutils import Matrix, Vector from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData -from bonsai.bim.module.drawing.decoration import profile_consequential from enum import Enum from typing import TYPE_CHECKING, Optional, Any, Union if TYPE_CHECKING: - from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties + from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties, BIMZoneProperties class System(bonsai.core.tool.System): @@ -165,7 +164,7 @@ class System(bonsai.core.tool.System): continue new = props.systems.add() new.ifc_definition_id = system.id() - new.name = system.Name or "Unnamed" + new["name"] = system.Name or "Unnamed" new.ifc_class = system.is_a() @classmethod @@ -189,7 +188,7 @@ class System(bonsai.core.tool.System): container = ifcopenshell.util.element.get_container(element) if container: - collection = tool.Ifc.get_object(container).BIMObjectProperties.collection + collection = tool.Blender.get_object_bim_props(tool.Ifc.get_object(container)).collection ifc_importer.collections[container.GlobalId] = collection ifc_importer.place_objects_in_collections() @@ -341,32 +340,37 @@ class System(bonsai.core.tool.System): # for now it's hardcoded to local Y axis to avoid using viewport data # for performance reasons - edge_ortho = obj.matrix_world.col[1].to_3d().normalized() - second_ortho = edge_dir.cross(edge_ortho) - edge_ortho = second_ortho.cross(edge_dir) + for j in range(2): + edge_ortho = obj.matrix_world.col[j].to_3d().normalized() + second_ortho = edge_dir.cross(edge_ortho) + edge_ortho = second_ortho.cross(edge_dir) - # direction lines should be around the edge center - n_direction_lines, start_offset = divmod(edge_length, direction_lines_offset) - n_direction_lines = int(n_direction_lines) + 1 - start_offset /= 2 - start_offset = edge_dir * start_offset + base_vert - cur_vert_index = start_vert_i + len(port_data) + # direction lines should be around the edge center + n_direction_lines, start_offset = divmod(edge_length, direction_lines_offset) + n_direction_lines = int(n_direction_lines) + 1 + start_offset /= 2 + start_offset = edge_dir * start_offset + base_vert - for i in range(n_direction_lines): - cur_offset = start_offset + edge_dir * i * direction_lines_offset if both_directions: - verts_pos.append(cur_offset + edge_ortho * direction_lines_width) - verts_pos.append(cur_offset - edge_ortho * direction_lines_width) - edges.append((cur_vert_index, cur_vert_index + 1)) - cur_vert_index += 2 + cur_vert_index = start_vert_i + len(port_data) + j * 2 * n_direction_lines else: - arrow_base = cur_offset - edge_dir * direction_lines_width - verts_pos.append(arrow_base + edge_ortho * direction_lines_width) - verts_pos.append(cur_offset) - verts_pos.append(arrow_base - edge_ortho * direction_lines_width) - edges.append((cur_vert_index, cur_vert_index + 1)) - edges.append((cur_vert_index + 1, cur_vert_index + 2)) - cur_vert_index += 3 + cur_vert_index = start_vert_i + len(port_data) + j * 3 * n_direction_lines + + for i in range(n_direction_lines): + cur_offset = start_offset + edge_dir * i * direction_lines_offset + if both_directions: + verts_pos.append(cur_offset + edge_ortho * direction_lines_width) + verts_pos.append(cur_offset - edge_ortho * direction_lines_width) + edges.append((cur_vert_index, cur_vert_index + 1)) + cur_vert_index += 2 + else: + arrow_base = cur_offset - edge_dir * direction_lines_width + verts_pos.append(arrow_base + edge_ortho * direction_lines_width) + verts_pos.append(cur_offset) + verts_pos.append(arrow_base - edge_ortho * direction_lines_width) + edges.append((cur_vert_index, cur_vert_index + 1)) + edges.append((cur_vert_index + 1, cur_vert_index + 2)) + cur_vert_index += 3 all_vertices.extend(verts_pos) @@ -427,3 +431,14 @@ class System(bonsai.core.tool.System): if not element.AssignedToFlowElement: return return element.AssignedToFlowElement[0].RelatingFlowElement + + @classmethod + def draw_system_ui(cls, layout: bpy.types.UILayout, system_id: int, system_name: str, system_class: str) -> None: + from bonsai.bim.module.system.ui import SYSTEM_ICONS + + row = layout.row(align=True) + row.label(text=system_name, icon=SYSTEM_ICONS[system_class]) + op = row.operator("bim.select_system_products", text="", icon="RESTRICT_SELECT_OFF") + op.system = system_id + op = row.operator("bim.unassign_system", text="", icon="X") + op.system = system_id diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 37597cf95c..808ea5ead0 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bpy import json import math @@ -23,24 +24,34 @@ import ifcopenshell import bonsai.bim.helper import bonsai.core.tool import bonsai.tool as tool -from typing import Union, Literal, Any +from typing import Union, Literal, Any, TYPE_CHECKING from typing_extensions import assert_never +if TYPE_CHECKING: + from bonsai.bim.module.unit.prop import BIMUnitProperties + class Unit(bonsai.core.tool.Unit): UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT"] + @classmethod + def get_unit_props(cls) -> BIMUnitProperties: + return bpy.context.scene.BIMUnitProperties + @classmethod def clear_active_unit(cls) -> None: - bpy.context.scene.BIMUnitProperties.active_unit_id = 0 + props = cls.get_unit_props() + props.active_unit_id = 0 @classmethod def disable_editing_units(cls) -> None: - bpy.context.scene.BIMUnitProperties.is_editing = False + props = cls.get_unit_props() + props.is_editing = False @classmethod def enable_editing_units(cls) -> None: - bpy.context.scene.BIMUnitProperties.is_editing = True + props = cls.get_unit_props() + props.is_editing = True @classmethod def export_unit_attributes(cls) -> dict[str, Any]: @@ -52,11 +63,12 @@ class Unit(bonsai.core.tool.Unit): attributes[prop.name] = (0, 0, 0, 0, 0, 0, 0) return True - props = bpy.context.scene.BIMUnitProperties + props = cls.get_unit_props() return bonsai.bim.helper.export_attributes(props.unit_attributes, callback=callback) @classmethod def get_scene_unit_name(cls, unit_type: UNIT_TYPE) -> str: + bim_props = tool.Blender.get_bim_props() if unit_type == "LENGTHUNIT": props = bpy.context.scene.unit_settings if props.length_unit == "MILES": @@ -69,23 +81,24 @@ class Unit(bonsai.core.tool.Unit): return "thou" return "foot" elif unit_type == "AREAUNIT": - return bpy.context.scene.BIMProperties.area_unit + return bim_props.area_unit elif unit_type == "VOLUMEUNIT": - return bpy.context.scene.BIMProperties.volume_unit + return bim_props.volume_unit else: assert_never() @classmethod def get_scene_unit_si_prefix(cls, unit_type: UNIT_TYPE) -> Union[str, None]: + bim_props = tool.Blender.get_bim_props() if unit_type == "LENGTHUNIT": props = bpy.context.scene.unit_settings if props.length_unit == "ADAPTIVE" or props.length_unit == "METERS": return return props.length_unit.replace("METERS", "") elif unit_type == "AREAUNIT": - unit = bpy.context.scene.BIMProperties.area_unit + unit = bim_props.area_unit elif unit_type == "VOLUMEUNIT": - unit = bpy.context.scene.BIMProperties.volume_unit + unit = bim_props.volume_unit else: assert_never(unit_type) if "/" in unit: @@ -93,9 +106,11 @@ class Unit(bonsai.core.tool.Unit): @classmethod def import_unit_attributes(cls, unit: ifcopenshell.entity_instance) -> None: + props = cls.get_unit_props() + def callback(name, prop, data): if name == "Dimensions" and data["type"] != "IfcSIUnit": - new = bpy.context.scene.BIMUnitProperties.unit_attributes.add() + new = props.unit_attributes.add() new.name = name new.is_null = data[name] is None new.is_optional = False @@ -103,13 +118,12 @@ class Unit(bonsai.core.tool.Unit): new.string_value = json.dumps([e for e in tool.Ifc.get().by_id(data["id"]).Dimensions]) return True - props = bpy.context.scene.BIMUnitProperties props.unit_attributes.clear() bonsai.bim.helper.import_attributes2(unit, props.unit_attributes, callback=callback) @classmethod def import_units(cls) -> None: - props = bpy.context.scene.BIMUnitProperties + props = tool.Unit.get_unit_props() props.units.clear() units = [] @@ -158,7 +172,8 @@ class Unit(bonsai.core.tool.Unit): @classmethod def set_active_unit(cls, unit: ifcopenshell.entity_instance) -> None: - bpy.context.scene.BIMUnitProperties.active_unit_id = unit.id() + props = cls.get_unit_props() + props.active_unit_id = unit.id() @classmethod def get_project_currency_unit(cls) -> Union[ifcopenshell.entity_instance, None]: diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 13f5ff7897..e37911b75e 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -730,7 +730,8 @@ class Web(bonsai.core.tool.Web): if operator_data["type"] == "getDrawings": drawings_data = [] sheets_data = [] - ifc_file_dir = os.path.dirname(bpy.context.scene.BIMProperties.ifc_file) + props = tool.Blender.get_bim_props() + ifc_file_dir = os.path.dirname(props.ifc_file) sheets = [d for d in tool.Ifc.get().by_type("IfcDocumentInformation") if d.Scope == "SHEET"] for sheet in sorted(sheets, key=lambda s: getattr(s, "Identification", getattr(s, "DocumentId", None))): diff --git a/src/bonsai/docs/guides/alignment.rst b/src/bonsai/docs/guides/alignment.rst new file mode 100644 index 0000000000..279fd11437 --- /dev/null +++ b/src/bonsai/docs/guides/alignment.rst @@ -0,0 +1,37 @@ +Road and Rail Alignments +======================== + +.. Note:: + + Bonsai lacks modeling features for road and rail alignments. This feature is intended to be a stop-gap measure to allow alignments + to be defined and imported into an IFC model. This feature is most likely temporary and will be phased out as robust alignment + modeling capabilities are developed. + +Alignments may be defined by the PI method in a CSV file for import into an IFC4X3 Bonsai project. The format of the CSV file is as follows: + +.. csv-table:: Alignment by PI Method + + "X1","Y1","R1","X2","Y2","R2","...,","Xn-1","Yn-1","Rn-1","Xn","Yn","Rn" + "D1","Z1","L1","D2","Z2","L2","...,","Dn-1","Zn-1","Ln-1","Dn","Zn","Ln" + "D1","Z1","L1","D2","Z2","L2","...,","Dn-1","Zn-1","Ln-1","Dn","Zn","Ln" + + +where: + Xi,Yi are horizontal alignment PI points + Ri are horizontal curve radii. + Di,Zi are vertical alignment PI points as Distance_Along,Elevation + Li are the horizontal length of parabolic vertical transition curves + +R1 and Rn, as well as L1 and Ln, are placeholder values and should be set to 0.0 + +The CSV file must contain exactly one horizontal alignment definition with a minimum of three points. +X1,Y1 is the Point of Beginning (POB). Xn,Yn is the Point of Ending (POE). + +The CSV file may contain zero, one or more vertical alignment definitions. + +Alignments with a single horizontal layout and zero or one vertical layout are modeled per `IFC Concept Template 4.1.4.4.1.1, Alignment Layout - Horizontal, Vertical, and Cant, `_. Alignments with multiple vertical layouts are modeled per `IFC Concept Template 4.1.4.4.1.2, Alignment Layout - Reusing Horizontal Layout, `_. + +Example based on the `FHWA Bridge Geometry Manual `_: + +500,2500,0.0,3340,660,1000,4340,5000,1250,7600,4560,950,8480,2010,0 +0,100,0,2000,135,1600,5000,105,1200,7400,153,2000,9800,105,800,12800,90,0 \ No newline at end of file diff --git a/src/bonsai/docs/guides/development/debugging.rst b/src/bonsai/docs/guides/development/debugging.rst index 4cecc46fef..a74331e1ec 100644 --- a/src/bonsai/docs/guides/development/debugging.rst +++ b/src/bonsai/docs/guides/development/debugging.rst @@ -5,6 +5,13 @@ This is a mini-guide to setting up an IDE and configuring it to debug Bonsai more easily. It is currently specific to this writers own system (Ubuntu) but this can be expanded by others. +.. warning:: + Attaching debugger to Blender may significantly decrease performance due to + the debugger overhead. + +VSCode Extension +-------------- + 1. **Install VSCode/VSCodium**: This will be system specific. I used the available snap package. @@ -125,4 +132,20 @@ this can be expanded by others. If you get to this point, congratulations! You will now be 1000% more effective when troubleshooting issues, and able to make many more contributions, fixes -and patches. \ No newline at end of file +and patches. + +Blender Addon + VS Code Debugger +------------------------------ + +Setting up debugging with Blender Addon is a bit simpler as it doesn't require Blender to be started in a special way +and debugger can be always attached later when it's needed. + +1. Install Hextant Python Debugger Blender addon from `official repository `_. + +2. Open IfcOpenShell repository in VS Code and setup configuration for attaching debugger `per the instructions `_. + +3. Start Debug Server in Blender (Blender -> System -> Start Debug Server) + +4. VS Code -> Run and Debug -> "Python Debugger: Remote Attach" in dropdown -> Start Debugging + +5. Now debugger is attached to Blender. You can set breakpoints in VS Code and use Debug Console. diff --git a/src/bonsai/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index 3992c50701..a471c456ee 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -83,7 +83,7 @@ that they are typically updated every day. To install the **Unstable** version: .. warning:: Make sure the extension you install has ``raw.githubusercontent.com`` as - it's "Repository" (not ``extensions.blender.org``). + the "Repository" (not ``extensions.blender.org``). .. image:: images/unstable-repo.png @@ -143,19 +143,14 @@ and install. Live development environment ---------------------------- -One option for developers who want to actively develop from source is to follow -the instructions from :ref:`guides/development/installation:Bundling for Blender`. However, -creating a build, uninstalling the old add-on, and installing a new build is a -slow process. Although it works, it is very slow, so we do not recommend it. +First, install using the :ref:`guides/development/installation:Unstable +installation` method. This will provide all compiled dependencies for you out +of the box. -A more rapid approach is to follow the -:ref:`guides/development/installation:Unstable installation` method, as this -provides all dependencies for you out of the box. - -Once you've done this, you can replace certain Python files that tend to be -updated frequently with those from the Git repository. We're going to use -symbolic links, so we can code in our Git repository, and see the changes in -our Blender installation (you will need to restart Blender to see changes). +Once you've done this, we'll replace the installed Python files with those from +our Git repository. We're going to use symbolic links, so we can code in our +Git repository, and see the changes in our Blender installation (you will need +to restart Blender to see changes). For Linux or Mac: @@ -163,9 +158,8 @@ For Linux or Mac: :language: bash :caption: dev_environment.sh -Or, if you're on Windows, you can use the batch script below. You need to run -it as an administrator. Before running it follow the instructions descibed -in the `rem` tags. +For Windows, run this batch script as an administrator. Before running it +follow the instructions described in the `rem` tags. .. literalinclude:: ../../../scripts/installation/dev_environment.bat :language: bat @@ -174,17 +168,17 @@ in the `rem` tags. After you modify your code in the Git repository, you will need to restart Blender for the changes to take effect. +Note that this only links Python code to the Git repository. If there are any +major changes such as new dependencies or newly compiled C++ code, you will +need to make the updates manually. This is relatively rare. Reviewing the +`Makefile history +`__, +is one quick way to see if a dependency has changed. + If there are changes to the IfcOpenShell binaries, you may replace the two ``*ifcopenshell_wrapper*`` files with new ones downloaded from the automated `IfcOpenShell builds directory `__. -The downside with this approach is that if a new dependency is added, or a -compiled dependency has changed (that is not available via the build -directory), or the build system changes, you'll need to fix your setup -manually. But this is relatively rare. Reviewing the Makefile history, `here -`__, -is one quick way to see if a dependency has changed. - .. seealso:: There is a `useful Blender Addon diff --git a/src/bonsai/docs/guides/development/undo_system.rst b/src/bonsai/docs/guides/development/undo_system.rst index c4e95bf28a..a123e8f96e 100644 --- a/src/bonsai/docs/guides/development/undo_system.rst +++ b/src/bonsai/docs/guides/development/undo_system.rst @@ -62,7 +62,7 @@ Instead, stuff happens in Blender operators. return IfcStore.execute_ifc_operator(self, context) def _execute(self, context): - ifcopenshell.api.run("foo.bar", IfcStore.get_file()) + ifcopenshell.api.run("foo.bar", tool.Ifc.get()) return {"FINISHED"} When your operator manipulates (creates, removes, or edits) IFC data directly or diff --git a/src/bonsai/docs/guides/troubleshooting.rst b/src/bonsai/docs/guides/troubleshooting.rst index 72b33691fd..15b52634db 100644 --- a/src/bonsai/docs/guides/troubleshooting.rst +++ b/src/bonsai/docs/guides/troubleshooting.rst @@ -131,6 +131,25 @@ incompatible features may result in data loss. objects that have been scaled in object mode will have their scale reset to 1, and scaling an object may result in unpredictable operations. Instead, scale objects within edit mode. +3. **Copy and Paste for IFC objects**. Copying and pasting objects preserving their IFC data + is not currently supported as pasting object may be unsafe: + object may come from a different Blender session or from current session but it's earlier state. + + If you copy and paste IFC object, you will find that pasted IFC object is unlinked to any IFC data + to keep it safe. + + Current workarounds: + + - copying IFC objects in current session instead use "IFC Duplicate Object" operator (:kbd:`Ctrl+D`). + + - copying IFC occurrences objects from other projects - link IFC project, + query element using Explore Tool and then append it from Links UI. + - copying any IFC objects to a new separate project - use IfcPatch with ExtractElements recipe. + + - copying IFC types from a different project use Project Library UI. + + WARNING. Manually using Blender's "Paste Objects" operator instead of "IFC Paste BIM Objects" to paste IFC objects + will have unpredictable results and will lead to data corruption. Where is the add-on installed? ------------------------------ diff --git a/src/bonsai/docs/index.rst b/src/bonsai/docs/index.rst index f9a8c490ef..664a619e72 100644 --- a/src/bonsai/docs/index.rst +++ b/src/bonsai/docs/index.rst @@ -56,6 +56,7 @@ and data-rich OpenBIM with Blender :) guides/authoring/georeferencing guides/authoring/git_support guides/development/index + guides/alignment guides/authoring/other_addons guides/troubleshooting guides/debugging diff --git a/src/bonsai/docs/reference/topbar.rst b/src/bonsai/docs/reference/topbar.rst index 555c413757..8744e55809 100644 --- a/src/bonsai/docs/reference/topbar.rst +++ b/src/bonsai/docs/reference/topbar.rst @@ -98,3 +98,4 @@ Imports data from external sources into the Blender session or IFC model. - **P6 (.xer)**: Imports a P6 XER file containing a work schedule into the active IFC model. - **Powerproject (.pp)**: Imports a Powerproject file containing a work schedule into the active IFC model. - **Microsoft Project (.xml)**: Imports a Microsoft Project XML file containing a work schedule into the active IFC model. +- **Alignment (.csv)**: Imports a CSV containing horizontal and vertical alignments defined by the PI method into the active IFC model. \ No newline at end of file diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index eee9c46ded..f3a641a5c4 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -2,6 +2,7 @@ markers = aggregate attribute + boolean brick classification context diff --git a/src/bonsai/scripts/bonsai_translations.py b/src/bonsai/scripts/bonsai_translations.py index 36292eff15..7e3e746934 100644 --- a/src/bonsai/scripts/bonsai_translations.py +++ b/src/bonsai/scripts/bonsai_translations.py @@ -75,7 +75,7 @@ class Message: translations: Optional[Dict[str, str]] = field(default_factory=dict) -def bonsai_strings_parse(addon_directory=None, po_directory=None): +def bonsai_strings_parse(addon_directory: Optional[Path] = None, po_directory: Optional[Path] = None): # NOTE: we decided to use our own parser due bug in Blender parser # as it tends to pick up strings from other addons and other Blender parts # and this bug probably would be to low of a priority for Blender to fix @@ -153,7 +153,7 @@ def bonsai_strings_parse(addon_directory=None, po_directory=None): def update_translations_from_po(po_directory: Path, translations_module: Path): translation_data: Dict[str, Message] = dict() - def process_po_entry(language, current_chunk: list[str]): + def process_po_entry(language: str, current_chunk: list[str]) -> None: sources = [] msgid = None msgstr = None @@ -171,6 +171,7 @@ def update_translations_from_po(po_directory: Path, translations_module: Path): elif line.startswith("#:"): sources.append(line.removeprefix("# ").strip()) + assert msgid is not None and msgstr is not None msg = translation_data.get(msgid) if msg is None: msg = Message(msgid, msgctxt, sources, {language: msgstr}) @@ -179,12 +180,12 @@ def update_translations_from_po(po_directory: Path, translations_module: Path): msg.sources.extend(sources) msg.translations[language] = msgstr - # load data from .po files - langs = set() + # load data from .po files to translation_data. + langs: set[str] = set() for po_file_path in po_directory.glob("**/*.po"): lang = po_file_path.stem langs.add(lang) - with open(po_file_path, "r") as po_file: + with open(po_file_path, "r", encoding="utf-8") as po_file: current_chunk = [] for line in po_file: current_chunk.append(line) @@ -200,7 +201,8 @@ def update_translations_from_po(po_directory: Path, translations_module: Path): for lang in langs: ret.append(f'{tab}"{lang}": {{') for msgid, msg in translation_data.items(): - if (msgstr := msg.translations[lang]) in (None, ""): + # World isn't perfect and .po files can get out of sync, so let's make it permissive. + if (msgstr := msg.translations.get(lang)) in (None, ""): continue msgctxt = msg.msgctxt if not msgctxt: @@ -210,7 +212,7 @@ def update_translations_from_po(po_directory: Path, translations_module: Path): ret.append("}") - with open(translations_module / "translations.py", "w") as fo: + with open(translations_module / "translations.py", "w", encoding="utf-8") as fo: fo.write("\n".join(ret)) diff --git a/src/bonsai/scripts/generate_util_type_json.py b/src/bonsai/scripts/generate_util_type_json.py index f48b35dcbf..c162800f54 100644 --- a/src/bonsai/scripts/generate_util_type_json.py +++ b/src/bonsai/scripts/generate_util_type_json.py @@ -24,7 +24,7 @@ import ifcopenshell.util.schema def generate_ifc4_entity_map(filepath, schema_name, manual_corrections={}): filepath = filepath - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema_name) + schema = ifcopenshell.schema_by_name(schema_name) entity_to_type_map = {} @@ -154,7 +154,7 @@ ifc4x3_corrections = { "IfcDistributionElement": ["IfcDistributionElementType"], "IfcBuiltElement": ["IfcBuiltElementType"], } -entity_to_type_map4x3 = generate_ifc4_entity_map("IFC4X3_TC1.exp", "ifc4x3", ifc4x3_corrections) +entity_to_type_map4x3 = generate_ifc4_entity_map("IFC4X3_TC1.exp", "IFC4X3", ifc4x3_corrections) # some manual IFC4 corrections (more details in entity_to_type_map_4.json commits history) ifc4_corrections = { @@ -165,6 +165,6 @@ ifc4_corrections = { "IfcDistributionElement": ["IfcDistributionElementType"], } # -entity_to_type_map4 = generate_ifc4_entity_map("IFC4.exp", "ifc4", ifc4_corrections) +entity_to_type_map4 = generate_ifc4_entity_map("IFC4.exp", "IFC4", ifc4_corrections) entity_to_type_map2x3 = generate_ifc2x3_entity_map() diff --git a/src/bonsai/scripts/get_all_qtos.py b/src/bonsai/scripts/get_all_qtos.py index ae0787335d..7f23dca55a 100644 --- a/src/bonsai/scripts/get_all_qtos.py +++ b/src/bonsai/scripts/get_all_qtos.py @@ -1,6 +1,12 @@ +"""Update ifc5d json files with qtos from the provided pset templates path.""" + import json import ifcopenshell.util.pset import ifcopenshell.util.type +import ifc5d +from collections import defaultdict +from pathlib import Path +from typing import Union def order_dict(dictionary): @@ -8,20 +14,110 @@ def order_dict(dictionary): return {k: order_dict(v) if isinstance(v, dict) else v for k, v in sorted(dictionary.items())} -results = {} +PSET_TEMPLATES_FOLDER = Path(ifcopenshell.util.__file__).parent / "schema" +JSON_FOLDER = Path(ifc5d.__file__).parent +QueriesData = dict[str, dict[str, dict[str, Union[str, None]]]] -psetqto = ifcopenshell.util.pset.get_template("IFC4") -for template in psetqto.templates: - for pset_template in template.by_type("IfcPropertySetTemplate"): - if not pset_template.Name.startswith("Qto_"): + +def main() -> None: + # Update IFC4X3 json files from pset templates. + update_json_with_qtos_from_template_file( + JSON_FOLDER / "IFC4X3QtoBaseQuantities.json", PSET_TEMPLATES_FOLDER / "Pset_IFC4X3.ifc" + ) + update_json_with_qtos_from_template_file( + JSON_FOLDER / "IFC4X3QtoBaseQuantitiesBlender.json", PSET_TEMPLATES_FOLDER / "Pset_IFC4X3.ifc" + ) + # Reuse methods defined in IFC4 in IFC4X3 calculators. + reuse_methods_from_other_json_file( + JSON_FOLDER / "IFC4QtoBaseQuantities.json", JSON_FOLDER / "IFC4X3QtoBaseQuantities.json" + ) + reuse_methods_from_other_json_file( + JSON_FOLDER / "IFC4QtoBaseQuantitiesBlender.json", JSON_FOLDER / "IFC4X3QtoBaseQuantitiesBlender.json" + ) + + +def update_json_with_qtos_from_template_file(json_filepath: Path, ifc_filepath: Path) -> None: + """Add missing qtos and properties to the json file from the provided pset template file.""" + qto_templates: dict[str, list[ifcopenshell.entity_instance]] = defaultdict(list) + qto_props: dict[str, set[str]] = defaultdict(set) + ifc_file: ifcopenshell.file + ifc_file = ifcopenshell.open(ifc_filepath) + for template in ifc_file.by_type("IfcPropertySetTemplate"): + template_name = template.Name + if not template_name.startswith("Qto_"): continue - query = pset_template.ApplicableEntity - results.setdefault(query, {}) - results[query].setdefault(pset_template.Name, {}) - for quantity in pset_template.HasPropertyTemplates: - results[query][pset_template.Name][quantity.Name] = None + qto_templates[template_name].append(template) + for prop in template.HasPropertyTemplates: + qto_props[template_name].add(prop.Name) -results = order_dict(results) -print(results) -with open("results.json", "w") as f: - json.dump(results, f, indent=4) + qto_base_quantities_data = json.loads(json_filepath.read_text()) + + calculator_queries_data: QueriesData + added_qtos: set[str] = set() + + for calculator, calculator_queries_data in qto_base_quantities_data["calculators"].items(): + # Gather all supported QTOs. + calculator_supported_qtos: set[str] = set() + for selector_query, query_qtos_data in calculator_queries_data.items(): + for qto_name in query_qtos_data: + calculator_supported_qtos.add(qto_name) + + # Check for missing QTOs. + for template_name in qto_templates: + template = qto_templates[template_name][0] + + applicable_entity_value: str + applicable_entity_value = template.ApplicableEntity + applicable_entities = ifcopenshell.util.pset.parse_applicable_entity(applicable_entity_value) + + selector_query = ifcopenshell.util.pset.convert_applicable_entities_to_query(applicable_entities) + # Add missing selector queries and qtos. + query_qtos_data = calculator_queries_data.setdefault(selector_query, {}) + qto_props_data = query_qtos_data.setdefault(template_name, {}) + + # Add missing properties. + for prop_name in qto_props[template_name]: + if prop_name in qto_props_data: + continue + qto_props_data[prop_name] = None + added_qtos.add(template_name) + + if not added_qtos: + print(f"No Qtos updates for calculator '{calculator}'.") + continue + + # Sort dict alphabetically to keep it looking nice. + # Don't sort the entire json to keep the header structure. + qto_base_quantities_data["calculators"][calculator] = order_dict(calculator_queries_data) + print(f"Added Qtos for {calculator}: {added_qtos}") + json_filepath.write_text(json.dumps(qto_base_quantities_data, indent=4) + "\n") + + +def reuse_methods_from_other_json_file(json_source_filepath: Path, json_target_filepath: Path) -> None: + json_source_data = json.loads(json_source_filepath.read_text()) + json_target_data = json.loads(json_target_filepath.read_text()) + queries_data: QueriesData + queries_data_target: QueriesData + for calculator, queries_data in json_source_data["calculators"].items(): + for selector_query, query_qtos_data in queries_data.items(): + queries_data_target = json_target_data["calculators"][calculator] + # Don't match exactly since queries between IFC4 and IFC4X3 are not in sync currently. + target_query = next((q for q in queries_data_target if q.startswith(selector_query)), None) + if target_query is None: + continue + query_qtos_data_target = queries_data_target[target_query] + for qto_name in query_qtos_data: + if qto_name not in query_qtos_data_target: + continue + props_data = query_qtos_data[qto_name] + props_data_target = query_qtos_data_target[qto_name] + for prop_name in props_data: + if prop_name not in props_data_target: + continue + props_data_target[prop_name] = props_data[prop_name] + + json_target_filepath.write_text(json.dumps(json_target_data, indent=4) + "\n") + + +if __name__ == "__main__": + main() diff --git a/src/bonsai/scripts/headless_import.py b/src/bonsai/scripts/headless_import.py index cda13e3878..111b07d78b 100644 --- a/src/bonsai/scripts/headless_import.py +++ b/src/bonsai/scripts/headless_import.py @@ -1,12 +1,12 @@ # This can be run using `blender -b -P headless_import.py` import bpy -from bonsai.bim.ifc import IfcStore +import bonsai.tool as tool # When federating, you may wish to manually specify the origin to ensure models # with different or arbitrary origin conventions will turn up in the right spot. -props = bpy.context.scene.BIMGeoreferenceProperties +props = tool.Georeference.get_georeference_props() # A good idea it to test import a portion of the model (or grids only) and check # georeferencing coordinates in the IFC Georeferencing panel before filling out @@ -19,7 +19,7 @@ props = bpy.context.scene.BIMGeoreferenceProperties # props.blender_x_axis_ordinate = '0.989063862448262' # props.has_blender_offset = True -props = bpy.context.scene.BIMProjectProperties +props = tool.Project.get_project_props() # Generally recommended to disable caching for stability right now props.should_cache = False diff --git a/src/bonsai/scripts/replace_drawing_path.py b/src/bonsai/scripts/replace_drawing_path.py index 97f00c8986..b874614502 100644 --- a/src/bonsai/scripts/replace_drawing_path.py +++ b/src/bonsai/scripts/replace_drawing_path.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . -""" This script regenerates all drawing paths in case they have been created in a different operating system. +"""This script regenerates all drawing paths in case they have been created in a different operating system. It is useful when an annotation drawing has been created on windows and you want to recreate the drawig in linux. diff --git a/src/bonsai/scripts/waldo.py b/src/bonsai/scripts/waldo.py new file mode 100644 index 0000000000..15d8efcaa5 --- /dev/null +++ b/src/bonsai/scripts/waldo.py @@ -0,0 +1,245 @@ +import numpy as np +import ifcopenshell +import ifcopenshell.api.root +import ifcopenshell.api.type +import ifcopenshell.api.unit +import ifcopenshell.api.project +import ifcopenshell.api.context +import ifcopenshell.api.spatial +import ifcopenshell.api.material +import ifcopenshell.api.geometry +import ifcopenshell.util.shape_builder +import ifcopenshell.util.element + +# https://stackoverflow.com/a/9184560/9627415 +# Possible optimisation to linalg.norm? + +# from ifcopenshell.util.shape_builder import VectorType, SequenceOfVectors +from itertools import cycle +from collections import namedtuple +from math import sin, cos, radians + +f = ifcopenshell.api.project.create_file() + +project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject") +meters = ifcopenshell.api.unit.add_si_unit(f) +ifcopenshell.api.unit.assign_unit(f, units=[meters]) + +model = ifcopenshell.api.context.add_context(f, context_type="Model") +plan = ifcopenshell.api.context.add_context(f, context_type="Plan") +axis = ifcopenshell.api.context.add_context( + f, context_type="Plan", context_identifier="Axis", target_view="GRAPH_VIEW", parent=plan +) +body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model +) +material1 = ifcopenshell.api.material.add_material(f, name="material1", category="material1") +material2 = ifcopenshell.api.material.add_material(f, name="material2", category="material2") +site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite") +ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) +builder = ifcopenshell.util.shape_builder.ShapeBuilder(f) + +style = ifcopenshell.api.style.add_style(f) +attributes = {"SurfaceColour": {"Name": None, "Red": 1.0, "Green": 0.5, "Blue": 0.5}, "Transparency": 0.0} +ifcopenshell.api.style.add_surface_style(f, style=style, ifc_class="IfcSurfaceStyleShading", attributes=attributes) +ifcopenshell.api.style.assign_material_style(f, material=material1, style=style, context=body) + +style = ifcopenshell.api.style.add_style(f) +attributes = {"SurfaceColour": {"Name": None, "Red": 0.5, "Green": 0.5, "Blue": 1.0}, "Transparency": 0.0} +ifcopenshell.api.style.add_surface_style(f, style=style, ifc_class="IfcSurfaceStyleShading", attributes=attributes) +ifcopenshell.api.style.assign_material_style(f, material=material2, style=style, context=body) + + +def test_wall(offset, p1, p2, p3, p4, a1=None, a2=None): + offset *= 1.5 + wall_type_a = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="A") + wall_type_b = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="B") + + set_a = ifcopenshell.api.material.add_material_set(f, set_type="IfcMaterialLayerSet") + structure = ifcopenshell.api.material.add_layer(f, layer_set=set_a, material=material1, name="structure") + structure.Priority = p1 + structure.LayerThickness = 0.1 + cladding = ifcopenshell.api.material.add_layer(f, layer_set=set_a, material=material2, name="cladding") + cladding.Priority = p2 + cladding.LayerThickness = 0.05 + + set_b = ifcopenshell.api.material.add_material_set(f, set_type="IfcMaterialLayerSet") + structure = ifcopenshell.api.material.add_layer(f, layer_set=set_b, material=material1, name="structure") + structure.Priority = p3 + structure.LayerThickness = 0.1 + cladding = ifcopenshell.api.material.add_layer(f, layer_set=set_b, material=material2, name="cladding") + cladding.Priority = p4 + cladding.LayerThickness = 0.05 + + ifcopenshell.api.material.assign_material(f, products=[wall_type_a], material=set_a) + ifcopenshell.api.material.assign_material(f, products=[wall_type_b], material=set_b) + + for i, rotation in enumerate((-90, -75, -105, 90, 75, 105)): + for i2, connection in enumerate(("ATEND", "ATSTART", "MIX")): + # if rotation != -90: + # continue + # if connection != "ATEND": + # continue + wall_a = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"A{p1}{p2}") + wall_b = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"B{p3}{p4}") + wall_c = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"C{p3}{p4}") + + ifcopenshell.api.spatial.assign_container(f, products=[wall_a, wall_b, wall_c], relating_structure=site) + + ifcopenshell.api.type.assign_type(f, related_objects=[wall_a], relating_type=wall_type_a) + ifcopenshell.api.type.assign_type(f, related_objects=[wall_b], relating_type=wall_type_b) + ifcopenshell.api.type.assign_type(f, related_objects=[wall_c], relating_type=wall_type_b) + + axis_a = builder.polyline(((0.0, 0.0), (1.0, 0.0))) + axis_b = builder.polyline(((0.0, 0.0), (1.0, 0.0))) + axis_c = builder.polyline(((0.0, 0.0), (1.0, 0.0))) + rep_a = builder.get_representation(axis, [axis_a]) + rep_b = builder.get_representation(axis, [axis_b]) + rep_c = builder.get_representation(axis, [axis_c]) + + ifcopenshell.api.geometry.assign_representation(f, product=wall_a, representation=rep_a) + ifcopenshell.api.geometry.assign_representation(f, product=wall_b, representation=rep_b) + ifcopenshell.api.geometry.assign_representation(f, product=wall_c, representation=rep_c) + + x_offset = i * 2 + x_offset += i2 * (2 * 6) + if connection == "ATEND": + sign_offset = 0 if rotation < 0 else 1 + matrix_a = np.eye(4) + matrix_a[:, 3][0:3] = (0 + x_offset, 0 + offset + sign_offset, 0) + matrix_b = np.eye(4) + matrix_b = ifcopenshell.util.placement.rotation(rotation, "Z") @ matrix_b + matrix_b[:, 3][0:3] = (1 + x_offset, 1 + offset - sign_offset, 0) + matrix_c = np.eye(4) + matrix_c = ifcopenshell.util.placement.rotation(rotation, "Z") @ matrix_c + matrix_c[:, 3][0:3] = (0.5 + x_offset, 0.5 + offset, 0) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_a, matrix=matrix_a) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_b, matrix=matrix_b) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_c, matrix=matrix_c) + + ifcopenshell.api.geometry.connect_path( + f, + relating_element=wall_a, + related_element=wall_b, + relating_connection="ATEND", + related_connection="ATEND", + ) + ifcopenshell.api.geometry.connect_path( + f, + relating_element=wall_c, + related_element=wall_a, + relating_connection="ATEND", + related_connection="ATPATH", + ) + elif connection == "ATSTART": + sign_offset = 0 if rotation < 0 else 1 + matrix_a = np.eye(4) + matrix_a[:, 3][0:3] = (0 + x_offset, 1 + offset - sign_offset, 0) + matrix_b = np.eye(4) + matrix_b = ifcopenshell.util.placement.rotation(rotation, "Z") @ matrix_b + matrix_b[:, 3][0:3] = (0 + x_offset, 1 + offset - sign_offset, 0) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_a, matrix=matrix_a) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_b, matrix=matrix_b) + + ifcopenshell.api.geometry.connect_path( + f, + relating_element=wall_a, + related_element=wall_b, + relating_connection="ATSTART", + related_connection="ATSTART", + ) + elif connection == "MIX": + sign_offset = 0 if rotation < 0 else 1 + matrix_a = np.eye(4) + matrix_a[:, 3][0:3] = (0 + x_offset, 1 + offset - sign_offset, 0) + matrix_b = np.eye(4) + matrix_b = ifcopenshell.util.placement.rotation(rotation, "Z") @ matrix_b + matrix_b[:, 3][0:3] = (1 + x_offset, 1 + offset - sign_offset, 0) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_a, matrix=matrix_a) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_b, matrix=matrix_b) + + ifcopenshell.api.geometry.connect_path( + f, + relating_element=wall_a, + related_element=wall_b, + relating_connection="ATEND", + related_connection="ATSTART", + ) + + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_a, angle=a1) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_b, angle=a1) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_c, angle=a1) + + +def create_type(name, layers): + wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name=name) + layer_set = ifcopenshell.api.material.add_material_set(f, set_type="IfcMaterialLayerSet") + materials = cycle((material1, material2)) + for layer in layers: + material = next(materials) + item = ifcopenshell.api.material.add_layer(f, layer_set=layer_set, material=material, name="structure") + item.Priority = layer[0] + item.LayerThickness = layer[1] + ifcopenshell.api.material.assign_material(f, products=[wall_type], material=layer_set) + return wall_type + + +def test_atpath(offset, angle=None): + offset *= 1.5 + wall_type_a = create_type("A", [(1, 0.05), (2, 0.1), (3, 0.05)]) + wall_a = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="A123") + ifcopenshell.api.spatial.assign_container(f, products=[wall_a], relating_structure=site) + ifcopenshell.api.type.assign_type(f, related_objects=[wall_a], relating_type=wall_type_a) + axis_a = builder.polyline(((0.0, 0.0), (30.0, 0.0))) + rep_a = builder.get_representation(axis, [axis_a]) + ifcopenshell.api.geometry.assign_representation(f, product=wall_a, representation=rep_a) + matrix_a = np.eye(4) + matrix_a[:, 3][0:3] = (0, 0 + offset, 0) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall_a, matrix=matrix_a) + + def create_branch(name, p1, p2, p3, x, y, rotation): + wall_type = create_type(name, [(p1, 0.05), (p2, 0.1), (p3, 0.05)]) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"{name}{p1}{p2}{p3}") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=site) + ifcopenshell.api.type.assign_type(f, related_objects=[wall], relating_type=wall_type) + axis_a = builder.polyline(((0.0, 0.0), (1.0, 0.0))) + rep_a = builder.get_representation(axis, [axis_a]) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep_a) + matrix_a = np.eye(4) + matrix_a = ifcopenshell.util.placement.rotation(rotation, "Z") @ matrix_a + matrix_a[:, 3][0:3] = (x, y + offset, 0) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall, matrix=matrix_a) + ifcopenshell.api.geometry.connect_path( + f, + relating_element=wall, + related_element=wall_a, + relating_connection="ATEND", + related_connection="ATPATH", + ) + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall, angle=angle) + + create_branch("B", 1, 1, 1, 1, 1, -75) + create_branch("C", 1, 2, 3, 2, 1, -75) + create_branch("D", 1, 4, 2, 3, 1, -75) + create_branch("E", 4, 4, 4, 4, 1, -75) + create_branch("F", 4, 2, 4, 5, 1, -75) + + create_branch("B", 1, 1, 1, 0.5, -1, 75) + create_branch("C", 1, 2, 3, 1.5, -1, 75) + create_branch("D", 1, 4, 2, 2.5, -1, 75) + create_branch("E", 4, 4, 4, 3.5, -1, 75) + create_branch("F", 4, 2, 4, 4.5, -1, 75) + + ifcopenshell.api.geometry.regenerate_wall_representation(f, wall=wall_a, angle=angle) + + +test_wall(0, 1, 1, 1, 1, radians(10)) +test_wall(1, 2, 1, 1, 2, radians(10)) +test_wall(2, 2, 1, 1, 1, radians(10)) +test_wall(3, 1, 2, 1, 1, radians(10)) +test_wall(4, 1, 2, 1, 2, radians(10)) +test_wall(5, 3, 1, 2, 4, radians(10)) +test_atpath(7, radians(10)) + + +f.write("/home/dion/wall.ifc") diff --git a/src/bonsai/test/bim/bootstrap.py b/src/bonsai/test/bim/bootstrap.py index bea49df413..6d997bdd7f 100644 --- a/src/bonsai/test/bim/bootstrap.py +++ b/src/bonsai/test/bim/bootstrap.py @@ -23,6 +23,7 @@ import bpy import pytest import webbrowser import bonsai.bim.handler +import bonsai.tool as tool import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.representation @@ -66,7 +67,8 @@ class NewIfc4X3: bpy.data.batch_remove(bpy.data.objects) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) bonsai.bim.handler.load_post(None) - bpy.context.scene.BIMProjectProperties.export_schema = "IFC4X3_ADD2" + props = tool.Project.get_project_props() + props.export_schema = "IFC4X3_ADD2" bpy.ops.bim.create_project() @@ -172,14 +174,14 @@ def the_object_name_exists(name): def an_ifc_file_exists(): - ifc = IfcStore.get_file() + ifc = tool.Ifc.get() if not ifc: assert False, "No IFC file is available" return ifc def an_ifc_file_does_not_exist(): - ifc = IfcStore.get_file() + ifc = tool.Ifc.get() if ifc: assert False, "An IFC is available" @@ -190,12 +192,12 @@ def the_object_name_does_not_exist(name): def the_object_name_is_an_ifc_class(name, ifc_class): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert element.is_a(ifc_class), f'Object "{name}" is an {element.is_a()}' def the_object_name_is_not_an_ifc_element(name): - id = the_object_name_exists(name).BIMObjectProperties.ifc_definition_id + id = tool.Blender.get_ifc_definition_id(the_object_name_exists(name)) assert id == 0, f"The ID is {id}" @@ -227,7 +229,7 @@ def the_object_name_is_placed_in_the_collection_collection(name, collection): def the_object_name_has_a_type_representation_of_context(name, type, context): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) context, subcontext, target_view = context.split("/") assert ifcopenshell.util.representation.get_representation( element, context, subcontext or None, target_view or None @@ -236,7 +238,7 @@ def the_object_name_has_a_type_representation_of_context(name, type, context): def the_object_name_is_contained_in_container_name(name, container_name): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) container = ifcopenshell.util.element.get_container(element) if not container: assert False, f'Object "{name}" is not in any container' @@ -255,8 +257,8 @@ def i_delete_the_selected_objects(): def the_object_name1_and_name2_are_different_elements(name1, name2): ifc = an_ifc_file_exists() - element1 = ifc.by_id(the_object_name_exists(name1).BIMObjectProperties.ifc_definition_id) - element2 = ifc.by_id(the_object_name_exists(name2).BIMObjectProperties.ifc_definition_id) + element1 = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name1))) + element2 = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name2))) assert element1 != element2, f"Objects {name1} and {name2} have same elements {element1} and {element2}" @@ -281,8 +283,8 @@ def the_object_name1_has_no_boolean_difference_by_name2(name1, name2): def the_object_name_is_voided_by_void(name, void): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) for rel in element.HasOpenings: if rel.RelatedOpeningElement.Name == void: return True @@ -290,45 +292,45 @@ def the_object_name_is_voided_by_void(name, void): def the_object_name_is_not_voided_by_void(name, void): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) for rel in element.HasOpenings: if rel.RelatedOpeningElement.Name == void: assert False, "A void was found" def the_object_name_is_not_voided(name): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) if any(element.HasOpenings): assert False, "An opening was found" def the_object_name_is_not_a_void(name): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) if any(element.VoidsElements): assert False, "A void was found" def the_void_name_is_filled_by_filling(name, filling): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) if any(rel.RelatedBuildingElement.Name == filling for rel in element.HasFillings): return True assert False, "No filling found" def the_void_name_is_not_filled_by_filling(name, filling): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) if any(rel.RelatedBuildingElement.Name == filling for rel in element.HasFillings): assert False, "A filling was found" def the_object_name_is_not_a_filling(name): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) if any(element.FillsVoids): assert False, "A filling was found" diff --git a/src/bonsai/test/bim/conftest.py b/src/bonsai/test/bim/conftest.py new file mode 100644 index 0000000000..2d69fe415a --- /dev/null +++ b/src/bonsai/test/bim/conftest.py @@ -0,0 +1,19 @@ +import pytest + +# pytest by default doesn't print steps and where it failed. Let's fix that. + + +@pytest.hookimpl +def pytest_bdd_before_scenario(request, feature, scenario): + print(f"\033[94m# {feature.name}\033[0m") + print(f"\033[94m## {scenario.name}\033[0m") + + +@pytest.hookimpl(tryfirst=True) +def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args): + print(f"\033[92m>>> {step.name}\033[0m") + + +@pytest.hookimpl(tryfirst=True) +def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args): + print(f"\033[1;91m>>> {step.name} <-- FAILED\033[0m") diff --git a/src/bonsai/test/bim/feature/boolean.feature b/src/bonsai/test/bim/feature/boolean.feature new file mode 100644 index 0000000000..9b06580172 --- /dev/null +++ b/src/bonsai/test/bim/feature/boolean.feature @@ -0,0 +1,49 @@ +@boolean +Feature: Boolean + Manage boolean hierarchies and boolean results + +Scenario: Ensure added booleans are marked as manual + Given an empty IFC project + And I open the "Add" menu + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Representation" property to "Custom Extruded Solid" + And I click "OK" + And the object "IfcFurniture/Unnamed" exists + And I toggle edit mode + And the object "Item/IfcExtrudedAreaSolid/77" exists + And I open the "Add Item" menu + When I click "Half Space Solid" + And the object "Item/IfcHalfSpaceSolid/90" exists + And I deselect all objects + And I toggle edit mode + And I select the object "IfcFurniture/Unnamed" + And I look at the "Property Sets" panel + Then I see "BBIM_Boolean" + And I see "[91]" + +Scenario: Ensure removed booleans are unmarked as manual + Given an empty IFC project + And I open the "Add" menu + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Representation" property to "Custom Extruded Solid" + And I click "OK" + And the object "IfcFurniture/Unnamed" exists + And I toggle edit mode + And the object "Item/IfcExtrudedAreaSolid/77" exists + And I open the "Add Item" menu + And I click "Half Space Solid" + And I deselect all objects + And I toggle edit mode + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + And I select the object "Item/IfcHalfSpaceSolid/90" + When I delete the selected objects + And I toggle edit mode + And I select the object "IfcFurniture/Unnamed" + And I look at the "Property Sets" panel + Then I don't see "BBIM_Boolean" + And I don't see "[91]" diff --git a/src/bonsai/test/bim/feature/covering.feature b/src/bonsai/test/bim/feature/covering.feature index 0800e0edb5..4eb97649da 100644 --- a/src/bonsai/test/bim/feature/covering.feature +++ b/src/bonsai/test/bim/feature/covering.feature @@ -9,26 +9,27 @@ Scenario: Execute generate flooring coverings from walls And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" # 1st wall - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And I press "bim.change_layer_length(length=3.6)" # 2nd wall And the cursor is at "3.6,0.1,3" And I set "scene.BIMModelProperties.length" to "2.0" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" # 3rd wall And the cursor is at "3.5,2.1,3" And I set "scene.BIMModelProperties.length" to "3.5" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" # 4th wall And the cursor is at "0,2.0,0" And I set "scene.BIMModelProperties.length" to "1.9" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" # add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type. And the object "IfcCoveringType/COV30" is selected - And I press "bim.enable_editing_attributes(obj='IfcCoveringType/COV30')" - And I set "active_object.BIMAttributeProperties.attributes[6].enum_value" to "FLOORING" - And I press "bim.edit_attributes(obj='IfcCoveringType/COV30')" + And I look at the "Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "FLOORING" + And I click "Save Attributes" # Run the operator. When the object "IfcWall/Wall" is selected And additionally the object "IfcWall/Wall.001" is selected diff --git a/src/bonsai/test/bim/feature/geometry.feature b/src/bonsai/test/bim/feature/geometry.feature index 50e6e5fdbb..7d3459fdc7 100644 --- a/src/bonsai/test/bim/feature/geometry.feature +++ b/src/bonsai/test/bim/feature/geometry.feature @@ -33,8 +33,8 @@ Scenario: Add representation - add a new representation to a typed instance And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" And I press "bim.assign_class" - And I press "bim.add_constr_type_instance" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" + And I press "bim.add_occurrence" Then the object "IfcWall/Wall" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Wall.001" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" When the object "IfcWall/Wall" is selected @@ -127,8 +127,8 @@ Scenario: Remove representation - remove an instanced representation from an act And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" - And I press "bim.add_constr_type_instance" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" + And I press "bim.add_occurrence" And the object "IfcWallType/Cube" is selected When the variable "representation_body" is "{ifc}.by_type('IfcWallType')[0].RepresentationMaps[0].MappedRepresentation.id()" And I press "bim.remove_representation(representation_id={representation_body})" @@ -146,8 +146,8 @@ Scenario: Remove representation - remove an instanced representation from an act And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" - And I press "bim.add_constr_type_instance" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When the variable "representation_body" is "{ifc}.by_type('IfcWall')[0].Representation.Representations[0].id()" And I press "bim.remove_representation(representation_id={representation_body})" @@ -189,8 +189,7 @@ Scenario: Update representation - updating a layered extrusion And I press "bim.edit_material_set_item(material_set_item={layer})" And I press "bim.edit_assigned_material(material_set={layer_set})" And the variable "type" is "{ifc}.by_type('IfcWallType')[0].id()" - And I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" - When I press "bim.update_representation(obj='IfcWall/Cube')" + When I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "SweptSolid" representation of "Model/Body/MODEL_VIEW" Scenario: Update representation - updating a profiled extrusion @@ -339,7 +338,7 @@ Scenario: Override duplicate move - copying a type instance with a representatio And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When I duplicate the selected objects Then the object "IfcWall/Wall.001" exists @@ -425,14 +424,14 @@ Scenario: Override duplicate move - copying objects with connection And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" is an "IfcWall" And the object "IfcWall/Wall" dimensions are "1,0.1,3" And the object "IfcWall/Wall" bottom left corner is at "0,0,0" When I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcSlab/Slab" is an "IfcSlab" When the object "IfcSlab/Slab" is selected And the object "IfcSlab/Slab" is moved to "0,0,4" @@ -454,14 +453,14 @@ Scenario: Override duplicate move - copying walls with mitre joint And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "0.5,0,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall.001" is selected And additionally the object "IfcWall/Wall" is selected - When I press "bim.hotkey(hotkey='S_Y')" - Then the object "IfcWall/Wall" dimensions are "0.5,0.1,3" - And the object "IfcWall/Wall" bottom left corner is at "0.5,0,0" + When I press "bim.hotkey(hotkey='S_T')" + Then the object "IfcWall/Wall" dimensions are "0.6,0.1,3" + And the object "IfcWall/Wall" bottom left corner is at "0,0,0" And the object "IfcWall/Wall.001" dimensions are "1.1,0.1,3" And the object "IfcWall/Wall.001" bottom left corner is at "0.5,0.1,0" And the object "IfcWall/Wall.001" top right corner is at "0.6,-1,3" @@ -550,10 +549,11 @@ Scenario: Refresh linked aggregate When I deselect all objects And the object "IfcWall/Wall_01.001" is selected When the object layer length is set to "3" - Then the object "IfcWall/Wall_01.001" dimensions are "3,0.1,3" + # Extra 0.1 due to mitre + Then the object "IfcWall/Wall_01.001" dimensions are "3.1,0.1,3" When I refresh linked aggregate the selected object Then the object "IfcWall/Wall_01" exists - And the object "IfcWall/Wall_01" dimensions are "3,0.1,3" + And the object "IfcWall/Wall_01" dimensions are "3.1,0.1,3" Scenario: Refresh linked aggregate - after deleting an object Given I load the IFC test file "/test/files/linked-aggregates.ifc" diff --git a/src/bonsai/test/bim/feature/light.feature b/src/bonsai/test/bim/feature/light.feature index d98a230b59..04559e4729 100644 --- a/src/bonsai/test/bim/feature/light.feature +++ b/src/bonsai/test/bim/feature/light.feature @@ -44,11 +44,26 @@ Scenario: Display the sun path And I set the "Sun Path Size" property to "100.0" Then nothing happens -Scenario: Display shadows +Scenario: See no shadows + Given an empty IFC project + When I look at the "Solar Access / Shadow" panel + Then I don't see "Sun Intensity" + And I don't see "Shadow Intensity" + +Scenario: Display shaded shadows Given an empty IFC project And I look at the "Solar Access / Shadow" panel - When I click "Display Shadows" + When I set the "Shadow Mode" property to "Shaded" And I set the "Shadow Intensity" property to "1.0" + And I don't see "Sun Intensity" + Then nothing happens + +Scenario: Display rendered shadows + Given an empty IFC project + And I look at the "Solar Access / Shadow" panel + When I set the "Shadow Mode" property to "Rendered" + And I set the "Sun Intensity" property to "1.0" + And I don't see "Shadow Intensity" Then nothing happens Scenario: View from sun diff --git a/src/bonsai/test/bim/feature/misc.feature b/src/bonsai/test/bim/feature/misc.feature index d418ee3ea5..153b4ff48c 100644 --- a/src/bonsai/test/bim/feature/misc.feature +++ b/src/bonsai/test/bim/feature/misc.feature @@ -31,7 +31,7 @@ Scenario: Resize to storey When I press "bim.resize_to_storey(total_storeys=1)" Then nothing happens -Scenario: Split along edge +Scenario: Split along edge - boolean mode Given an empty IFC project And I add a cube And the object "Cube" is selected @@ -41,13 +41,24 @@ Scenario: Split along edge And I add a plane of size "4" at "0,0,0" And the object "IfcWall/Cube" is selected And additionally the object "Plane" is selected - When I press "bim.split_along_edge" + And I look at the "Miscellaneous" panel + When I click "Split Along Edge" Then the object "IfcWall/Cube" is an "IfcWall" And the object "IfcWall/Cube.001" is an "IfcWall" -Scenario: Enabling and disabling IFC Sverchok +Scenario: Split along edge - bisect mode Given an empty IFC project - And I press "preferences.addon_enable(module="sverchok")" - And I press "preferences.addon_enable(module="ifcsverchok")" - And I press "preferences.addon_disable(module="sverchok")" - And I press "preferences.addon_disable(module="ifcsverchok")" + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And I add a plane of size "4" at "0,0,0" + And the object "IfcWall/Cube" is selected + And additionally the object "Plane" is selected + And I look at the "Miscellaneous" panel + When I click "Bisect At Faces" + Then the object "IfcWall/Cube" is an "IfcWall" + And the object "IfcWall/Cube.001" is an "IfcWall" + + diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index 7467d6af86..729bd52bd5 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -11,7 +11,7 @@ Scenario: Add type instance - add from a mesh And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" exists Scenario: Add type instance - add from an empty @@ -24,7 +24,7 @@ Scenario: Add type instance - add from an empty And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "empty" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{empty}" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" exists Scenario: Add type instance - add a mesh where existing instances have changed context @@ -37,14 +37,14 @@ Scenario: Add type instance - add a mesh where existing instances have changed c And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{cube}" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Wall" is selected And the variable "context" is "[c for c in {ifc}.by_type('IfcGeometricRepresentationSubContext') if c.ContextType == 'Plan' and c.ContextIdentifier == 'Body' and c.TargetView == 'PLAN_VIEW'][0].id()" And I set "active_object.BIMGeometryProperties.contexts" to "{context}" And I press "bim.add_representation" And the object "IfcWall/Wall" data is a "Annotation2D" representation of "Plan/Body/PLAN_VIEW" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" data is a "Annotation2D" representation of "Plan/Body/PLAN_VIEW" And the object "IfcWall/Wall.001" data is a "Annotation2D" representation of "Plan/Body/PLAN_VIEW" @@ -72,7 +72,7 @@ Scenario: Add a wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - When I press "bim.add_constr_type_instance" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" is an "IfcWall" And the object "IfcWall/Wall" dimensions are "1,0.1,3" And the object "IfcWall/Wall" bottom left corner is at "0,0,0" @@ -83,7 +83,7 @@ Scenario: Extend a wall to the cursor And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And the cursor is at "2,0,0" When I press "bim.hotkey(hotkey='S_E')" @@ -96,9 +96,9 @@ Scenario: Add a wall perpendicular to an existing wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "0.5,0,0" - When I press "bim.hotkey(hotkey='S_A')" + When I press "bim.add_occurrence" Then the object "IfcWall/Wall" dimensions are "1,0.1,3" And the object "IfcWall/Wall" bottom left corner is at "0,0,0" And the object "IfcWall/Wall.001" dimensions are "1,0.1,3" @@ -111,9 +111,9 @@ Scenario: Extend one wall to another And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "0.5,0,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall.001" is moved to "0.5,-1,0" And the object "IfcWall/Wall.001" is selected And additionally the object "IfcWall/Wall" is selected @@ -124,59 +124,23 @@ Scenario: Extend one wall to another And the object "IfcWall/Wall.001" bottom left corner is at "0.5,0,0" And the object "IfcWall/Wall.001" top right corner is at "0.6,-2,3" -Scenario: Join two walls with a butt joint - first wall has priority - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" - And the cursor is at "0.5,0,0" - And I press "bim.hotkey(hotkey='S_A')" - And the object "IfcWall/Wall.001" is selected - And additionally the object "IfcWall/Wall" is selected - When I press "bim.hotkey(hotkey='S_T')" - Then the object "IfcWall/Wall" dimensions are "0.4,0.1,3" - And the object "IfcWall/Wall" bottom left corner is at "0.6,0,0" - And the object "IfcWall/Wall.001" dimensions are "1.1,0.1,3" - And the object "IfcWall/Wall.001" bottom left corner is at "0.5,0.1,0" - And the object "IfcWall/Wall.001" top right corner is at "0.6,-1,3" - -Scenario: Join two walls with a butt joint - second wall has priority - Given an empty IFC project - And I load the demo construction library - And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" - And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" - And the cursor is at "0.5,0,0" - And I press "bim.hotkey(hotkey='S_A')" - And the object "IfcWall/Wall" is selected - And additionally the object "IfcWall/Wall.001" is selected - When I press "bim.hotkey(hotkey='S_T')" - Then the object "IfcWall/Wall" dimensions are "0.5,0.1,3" - And the object "IfcWall/Wall" bottom left corner is at "0.5,0,0" - And the object "IfcWall/Wall.001" dimensions are "1,0.1,3" - And the object "IfcWall/Wall.001" bottom left corner is at "0.5,0,0" - And the object "IfcWall/Wall.001" top right corner is at "0.6,-1,3" - Scenario: Join two walls with a mitre joint Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" - And the cursor is at "0.5,0,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" + And the cursor is at "0.7,0,0" + And I press "bim.add_occurrence" And the object "IfcWall/Wall.001" is selected And additionally the object "IfcWall/Wall" is selected - When I press "bim.hotkey(hotkey='S_Y')" - Then the object "IfcWall/Wall" dimensions are "0.5,0.1,3" - And the object "IfcWall/Wall" bottom left corner is at "0.5,0,0" + When I press "bim.hotkey(hotkey='S_T')" + Then the object "IfcWall/Wall" dimensions are "0.8,0.1,3" + And the object "IfcWall/Wall" bottom left corner is at "0.0,0,0" And the object "IfcWall/Wall.001" dimensions are "1.1,0.1,3" - And the object "IfcWall/Wall.001" bottom left corner is at "0.5,0.1,0" - And the object "IfcWall/Wall.001" top right corner is at "0.6,-1,3" + And the object "IfcWall/Wall.001" bottom left corner is at "0.7,0.1,0" + And the object "IfcWall/Wall.001" top right corner is at "0.8,-1,3" Scenario: Change the height of a wall Given an empty IFC project @@ -184,7 +148,7 @@ Scenario: Change the height of a wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And I set "scene.BIMModelProperties.extrusion_depth" to "2.0" When I press "bim.change_extrusion_depth(depth=2.0)" @@ -196,7 +160,7 @@ Scenario: Change the length of a wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And I set "scene.BIMModelProperties.length" to "2.0" When I press "bim.change_layer_length(length=2.0)" @@ -208,12 +172,12 @@ Scenario: Flip a wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When I press "bim.hotkey(hotkey='S_F')" Then the object "IfcWall/Wall" dimensions are "1,0.1,3" - And the object "IfcWall/Wall" bottom left corner is at "1,0,0" - And the object "IfcWall/Wall" top right corner is at "0,-0.1,3" + And the object "IfcWall/Wall" bottom left corner is at "0,0,0" + And the object "IfcWall/Wall" top right corner is at "1,0.1,3" Scenario: Split a wall Given an empty IFC project @@ -221,7 +185,7 @@ Scenario: Split a wall And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And the cursor is at "0.5,0,0" When I press "bim.hotkey(hotkey='S_K')" @@ -240,7 +204,7 @@ Scenario: Rotate a wall by 90 degrees And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When I press "bim.hotkey(hotkey='S_R')" Then the object "IfcWall/Wall" dimensions are "1,0.1,3" @@ -253,7 +217,7 @@ Scenario: Regenerate a wall - after doing nothing interesting And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When I press "bim.hotkey(hotkey='S_G')" Then the object "IfcWall/Wall" is an "IfcWall" @@ -266,11 +230,11 @@ Scenario: Add a slab And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - When I press "bim.hotkey(hotkey='S_A')" + When I press "bim.add_occurrence" Then the object "IfcSlab/Slab" is an "IfcSlab" And the object "IfcSlab/Slab" dimensions are "1,1,0.2" - And the object "IfcSlab/Slab" bottom left corner is at "0,0,-0.2" - And the object "IfcSlab/Slab" top right corner is at "1,1,0" + And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" + And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" Scenario: Enable editing a slab profile Given an empty IFC project @@ -278,12 +242,12 @@ Scenario: Enable editing a slab profile And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcSlab/Slab" is selected When I press "bim.hotkey(hotkey='S_E')" Then the object "IfcSlab/Slab" dimensions are "1,1,0" - And the object "IfcSlab/Slab" bottom left corner is at "0,0,-0.2" - And the object "IfcSlab/Slab" top right corner is at "1,1,-0.2" + And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" + And the object "IfcSlab/Slab" top right corner is at "1,1,0" Scenario: Disable editing a slab profile Given an empty IFC project @@ -291,13 +255,13 @@ Scenario: Disable editing a slab profile And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcSlab/Slab" is selected And I press "bim.hotkey(hotkey='S_E')" When I press "bim.disable_editing_extrusion_profile" Then the object "IfcSlab/Slab" dimensions are "1,1,0.2" - And the object "IfcSlab/Slab" bottom left corner is at "0,0,-0.2" - And the object "IfcSlab/Slab" top right corner is at "1,1,0" + And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" + And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" Scenario: Edit a slab profile Given an empty IFC project @@ -305,13 +269,13 @@ Scenario: Edit a slab profile And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcSlab/Slab" is selected And I press "bim.hotkey(hotkey='S_E')" When I press "bim.edit_extrusion_profile" Then the object "IfcSlab/Slab" dimensions are "1,1,0.2" - And the object "IfcSlab/Slab" bottom left corner is at "0,0,-0.2" - And the object "IfcSlab/Slab" top right corner is at "1,1,0" + And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" + And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" Scenario: Add a beam Given an empty IFC project @@ -319,7 +283,7 @@ Scenario: Add a beam And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - When I press "bim.hotkey(hotkey='S_A')" + When I press "bim.add_occurrence" Then the object "IfcBeam/Beam" is an "IfcBeam" And the object "IfcBeam/Beam" dimensions are "0.1,0.2,3" And the object "IfcBeam/Beam" bottom left corner is at "0,-0.05,-0.1" @@ -331,7 +295,7 @@ Scenario: Extend a beam to the cursor And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam" is selected And the cursor is at "2,0,0" When I press "bim.hotkey(hotkey='S_E')" @@ -344,9 +308,9 @@ Scenario: Extend one beam to another And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "1,1,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam.001" is selected And I press "bim.hotkey(hotkey='S_R')" And the object "IfcBeam/Beam.001" is selected @@ -365,9 +329,9 @@ Scenario: Join two beams with a butt joint - first beam has priority And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "1,1,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam.001" is selected And I press "bim.hotkey(hotkey='S_R')" And the object "IfcBeam/Beam.001" is selected @@ -386,9 +350,9 @@ Scenario: Join two beams with a butt joint - second beam has priority And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "1,1,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam.001" is selected And I press "bim.hotkey(hotkey='S_R')" And the object "IfcBeam/Beam" is selected @@ -407,9 +371,9 @@ Scenario: Join two beams with a mitre joint And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the cursor is at "1,1,0" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam.001" is selected And I press "bim.hotkey(hotkey='S_R')" And the object "IfcBeam/Beam" is selected @@ -428,7 +392,7 @@ Scenario: Change the length of a beam And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam" is selected And I set "scene.BIMModelProperties.extrusion_depth" to "2.0" When I press "bim.change_profile_depth(depth=2.0)" @@ -440,7 +404,7 @@ Scenario: Rotate a beam by 90 degrees And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam" is selected When I press "bim.hotkey(hotkey='S_R')" Then the object "IfcBeam/Beam" dimensions are "0.1,0.2,3" @@ -453,7 +417,7 @@ Scenario: Regenerate a beam - after doing nothing interesting And I set "scene.BIMModelProperties.ifc_class" to "IfcBeamType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcBeamType') if e.Name == 'B1'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcBeam/Beam" is selected When I press "bim.hotkey(hotkey='S_G')" Then the object "IfcBeam/Beam" dimensions are "0.1,0.2,3" @@ -464,13 +428,13 @@ Scenario: Undo test - create a wall and couple windows and undo the last window Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And I set "scene.BIMModelProperties.ifc_class" to "IfcWindowType" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And I prepare to undo And the object "IfcWall/Wall" is selected And I set "scene.BIMModelProperties.ifc_class" to "IfcWindowType" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And I undo Then nothing happens @@ -478,9 +442,9 @@ Scenario: Undo test - create a wall with window opening, flip it and undo Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And I set "scene.BIMModelProperties.ifc_class" to "IfcWindowType" - And I press "bim.hotkey(hotkey='S_A')" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected And I prepare to undo And I press "bim.hotkey(hotkey='S_F')" @@ -489,22 +453,24 @@ Scenario: Undo test - create a wall with window opening, flip it and undo Scenario: Create window type based on window modifier, add an occurrence of it and edit it Given an empty IFC project - And I set "scene.BIMModelProperties.type_class" to "IfcWindowType" - And I set "scene.BIMModelProperties.type_predefined_type" to "WINDOW" - And I set "scene.BIMModelProperties.type_template" to "WINDOW" - And I press "bim.add_type()" - And I press "bim.hotkey(hotkey='S_A')" + And I trigger "Add Element" + And I set the "Class" property to "IfcWindowType" + And I set the "Predefined Type" property to "WINDOW" + And I set the "Representation" property to "Window" + When I click "OK" + And I press "bim.add_occurrence" And I press "bim.enable_editing_window()" And I press "bim.finish_editing_window()" Then nothing happens Scenario: Create door type based on door modifier, add an occurrence of it and edit it Given an empty IFC project - And I set "scene.BIMModelProperties.type_class" to "IfcDoorType" - And I set "scene.BIMModelProperties.type_predefined_type" to "DOOR" - And I set "scene.BIMModelProperties.type_template" to "DOOR" - And I press "bim.add_type()" - And I press "bim.hotkey(hotkey='S_A')" + And I trigger "Add Element" + And I set the "Class" property to "IfcDoorType" + And I set the "Predefined Type" property to "DOOR" + And I set the "Representation" property to "Door" + When I click "OK" + And I press "bim.add_occurrence" And I press "bim.enable_editing_door()" And I press "bim.finish_editing_door()" Then nothing happens @@ -525,11 +491,11 @@ Scenario: Create a MEP transition And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/RectSegment" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[1]" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/CircleSegment" And the object "IfcDuctSegment/RectSegment" is moved to "0,0,0" @@ -555,11 +521,11 @@ Scenario: Create a MEP bend between intersecting with different locations And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" And I set "scene.BIMModelProperties.extrusion_depth" to "5.0" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg1" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg2" And the object "IfcDuctSegment/Seg2" is rotated by "0,0,90" deg @@ -585,11 +551,11 @@ Scenario: Create a MEP bend between intersecting segments at the same location And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" And I set "scene.BIMModelProperties.extrusion_depth" to "5.0" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg1" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg2" And the object "IfcDuctSegment/Seg2" is rotated by "0,0,90" deg diff --git a/src/bonsai/test/bim/feature/root.feature b/src/bonsai/test/bim/feature/root.feature index d9606b62a0..9a5ad5d698 100644 --- a/src/bonsai/test/bim/feature/root.feature +++ b/src/bonsai/test/bim/feature/root.feature @@ -1,6 +1,70 @@ @root Feature: Root +Scenario: Add element - a type with no geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Name" property to "Foo" + And I set the "Description" property to "Bar" + And I set the "Definition" property to "IfcElementType" + And I set the "Class" property to "IfcFurnitureType" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "No Geometry" + When I click "OK" + Then the object "IfcFurnitureType/Foo" exists + And the object "IfcFurnitureType/Foo" has no data + +Scenario: Add element - an element with no geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Extruded Solid" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcExtrudedAreaSolid/77" exists + +Scenario: Add element - an element with extrusion geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Extruded Solid" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcExtrudedAreaSolid/77" exists + +Scenario: Add element - an element with custom tessellation geometry + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Custom Tessellation" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcPolygonalFaceSet/76" exists + +Scenario: Add element - an element with tessellation geometry from an object + Given an empty IFC project + And I add a cube + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Predefined Type" property to "SOFA" + And I set the "Representation" property to "Tessellation From Object" + And I set the "Object" property to "Cube" + When I click "OK" + And I select the object "IfcFurniture/Unnamed" + And I toggle edit mode + Then the object "Item/IfcPolygonalFaceSet/76" exists + And the object "Item/IfcPolygonalFaceSet/76" dimensions are "2,2,2" + Scenario: Reassign class Given an empty IFC project And I add a cube @@ -35,16 +99,6 @@ Scenario: Unlink object And the material "Style" is an IFC style And the material "Style.001" is not an IFC style -Scenario: Copy class - Given an empty IFC project - And I add a cube - And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" - When I press "bim.copy_class(obj='IfcWall/Cube')" - Then the object "IfcWall/Cube" is an "IfcWall" - Scenario: Assign a class to a cube Given an empty IFC project And I add a cube @@ -87,7 +141,7 @@ Scenario: Assign a spatial class to a cube already in a collection And I set "scene.BIMRootProperties.ifc_class" to "IfcSpace" And I press "bim.assign_class" Then the object "IfcSpace/Cube" is an "IfcSpace" - And the object "IfcSpace/Cube" is in the collection "IfcSpace/Cube" + And the object "IfcSpace/Cube" is in the collection "IfcSpace" And the object "IfcSpace/Cube" has a "Tessellation" representation of "Model/Body/MODEL_VIEW" Scenario: Assign a class to a cube in a collection @@ -113,7 +167,7 @@ Scenario: Copy a wall Scenario: Copy a storey - when locked Given an empty IFC project And the object "IfcBuildingStorey/My Storey" is selected - When I duplicate the selected objects + Then I expect an error "Error: 'IfcBuildingStorey/My Storey' is locked. Unlock it via the Spatial panel in the Project Overview tab." when "i_duplicate_the_selected_objects()" Then the object "IfcBuildingStorey/My Storey.001" does not exist Scenario: Copy a storey - when unlocked diff --git a/src/bonsai/test/bim/feature/spatial.feature b/src/bonsai/test/bim/feature/spatial.feature index c7b932c308..64dbbd2f12 100644 --- a/src/bonsai/test/bim/feature/spatial.feature +++ b/src/bonsai/test/bim/feature/spatial.feature @@ -103,8 +103,7 @@ Scenario: Select similar container Scenario: Execute generate space from cursor position Given an empty IFC project - When I press "bim.generate_space" - Then nothing happens + Then I press "bim.generate_space" and expect error "Error: Couldn't find any polygons to form the space shape. Perhaps, RL value need to be adjusted." Scenario: Execute generate spaces from walls Given an empty IFC project @@ -112,7 +111,7 @@ Scenario: Execute generate spaces from walls And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcWall/Wall" is selected When I press "bim.generate_spaces_from_walls" Then nothing happens @@ -133,6 +132,11 @@ Scenario: Spatial decomposition - see panel Scenario: Isolate spatial container Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Representation" property to "No Geometry" + And I click "OK" And I look at the "Spatial Decomposition" panel When I select the "My Site" item in the "BIM_UL_containers_manager" list And I click "Isolate" @@ -140,6 +144,11 @@ Scenario: Isolate spatial container Scenario: Show spatial container Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Representation" property to "No Geometry" + And I click "OK" And I look at the "Spatial Decomposition" panel When I select the "My Site" item in the "BIM_UL_containers_manager" list And I click "HIDE_OFF" @@ -147,6 +156,11 @@ Scenario: Show spatial container Scenario: Hide spatial container Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcFurniture" + And I set the "Representation" property to "No Geometry" + And I click "OK" And I look at the "Spatial Decomposition" panel When I select the "My Site" item in the "BIM_UL_containers_manager" list And I click "HIDE_ON" diff --git a/src/bonsai/test/bim/feature/style.feature b/src/bonsai/test/bim/feature/style.feature index 7e4c70b35f..68912ace0a 100644 --- a/src/bonsai/test/bim/feature/style.feature +++ b/src/bonsai/test/bim/feature/style.feature @@ -49,14 +49,15 @@ Scenario: Remove style Scenario: Edit style Given an empty IFC project - And I press "bim.load_styles(style_type='IfcSurfaceStyle')" - And I press "bim.enable_adding_presentation_style" - And I set "scene.BIMStylesProperties.style_name" to "Style" - And I press "bim.add_presentation_style" + And I look at the "Styles" panel + And I click "IMPORT" + And I click "ADD" + And I set the "Name" property to "Style" + And I click "Save New Style" And the variable "style" is "{ifc}.by_type('IfcSurfaceStyle')[0].id()" - And I press "bim.enable_editing_style(style={style})" - And I set "scene.BIMStylesProperties.attributes[0].string_value" to "NewStyle" - When I press "bim.edit_style" + And I click "GREASEPENCIL" + And I set the "Name" property to "NewStyle" + When I click "Save Attributes" Then the material "Style" does not exist And the material "NewStyle" exists diff --git a/src/bonsai/test/bim/feature/system.feature b/src/bonsai/test/bim/feature/system.feature index f671b1009b..6b900a0c04 100644 --- a/src/bonsai/test/bim/feature/system.feature +++ b/src/bonsai/test/bim/feature/system.feature @@ -139,13 +139,13 @@ Scenario: Connect MEP elements And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" And I set "scene.BIMModelProperties.extrusion_depth" to "5.0" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg1" # actuator And I set "scene.BIMModelProperties.ifc_class" to "IfcActuatorType" And I set "scene.BIMModelProperties.relating_type_id" to "{actuator_type_id}" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcActuator/Actuator" is moved to "10,0,0" # connect actuator @@ -159,6 +159,45 @@ Scenario: Connect MEP elements And the object "IfcActuator/Actuator" is at "5.5,0.0,1.0" And the variable "connected_elements" is "set(tool.System.get_connected_elements({ifc}.by_type('IfcActuator')[0]))" +Scenario: Add bend - and regenerate with no changes + Given an empty IFC project + And I create default MEP types + And the variable "segment_types" is "[str(e.id()) for e in {ifc}.by_type('IfcDuctSegmentType')]" + And the variable "actuator_type_id" is "{ifc}.by_type('IfcActuatorType')[0].id()" + + # segment1 + And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" + And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" + And I set "scene.BIMModelProperties.extrusion_depth" to "5.0" + And I press "bim.add_occurrence" + And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg1" + + # segment2 + And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" + And I press "bim.add_occurrence" + And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg2" + And the object "IfcDuctSegment/Seg2" is rotated by "0,0,90" deg + + # bend between segments 1 and 2 + When the object "IfcDuctSegment/Seg1" is selected + And additionally the object "IfcDuctSegment/Seg2" is selected + And I press "bim.mep_add_bend" + + Then the object "IfcDuctSegment/Seg1" is at "0.5,0,1" + And the object "IfcDuctSegment/Seg2" is at "0,0.5,1" + And the object "IfcDuctFitting/DuctFitting" is at "0,0.5,1" + And the object "IfcDuctFitting/DuctFitting" dimensions are "0.7,0.2,0.7" + And the object "IfcDuctSegment/Seg1" dimensions are "0.4,0.2,4.5" + And the object "IfcDuctSegment/Seg2" dimensions are "0.4,0.2,4.5" + + When I press "bim.regenerate_distribution_element" + Then the object "IfcDuctSegment/Seg1" is at "0.5,0,1" + And the object "IfcDuctSegment/Seg2" is at "0,0.5,1" + And the object "IfcDuctFitting/DuctFitting" is at "0,0.5,1" + And the object "IfcDuctFitting/DuctFitting" dimensions are "0.7,0.2,0.7" + And the object "IfcDuctSegment/Seg1" dimensions are "0.4,0.2,4.5" + And the object "IfcDuctSegment/Seg2" dimensions are "0.4,0.2,4.5" + Scenario: Connect MEP elements and regenerate Given an empty IFC project And I create default MEP types @@ -169,12 +208,12 @@ Scenario: Connect MEP elements and regenerate And I set "scene.BIMModelProperties.ifc_class" to "IfcDuctSegmentType" And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" And I set "scene.BIMModelProperties.extrusion_depth" to "5.0" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg1" # segment2 And I set "scene.BIMModelProperties.relating_type_id" to "{segment_types}[0]" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And I rename the object "IfcDuctSegment/DuctSegment" to "IfcDuctSegment/Seg2" And the object "IfcDuctSegment/Seg2" is rotated by "0,0,90" deg @@ -186,7 +225,7 @@ Scenario: Connect MEP elements and regenerate # actuator And I set "scene.BIMModelProperties.ifc_class" to "IfcActuatorType" And I set "scene.BIMModelProperties.relating_type_id" to "{actuator_type_id}" - And I press "bim.add_constr_type_instance" + And I press "bim.add_occurrence" And the object "IfcActuator/Actuator" is moved to "10,0,0" # connect actuator diff --git a/src/bonsai/test/bim/feature/type.feature b/src/bonsai/test/bim/feature/type.feature index 6b68fe7566..b85b9162c5 100644 --- a/src/bonsai/test/bim/feature/type.feature +++ b/src/bonsai/test/bim/feature/type.feature @@ -10,16 +10,6 @@ Scenario: Add type - adding via manual class assignment When I press "bim.assign_class" Then nothing happens -Scenario: Add type - add from empty template - Given an empty IFC project - And I press "bim.launch_type_manager" - And I set "scene.BIMModelProperties.type_class" to "IfcWallType" - And I set "scene.BIMModelProperties.type_predefined_type" to "SOLIDWALL" - And I set "scene.BIMModelProperties.type_template" to "EMPTY" - When I press "bim.add_type" - Then the object "IfcWallType/TYPEX" is an "IfcWallType" - And the object "IfcWallType/TYPEX" has no data - Scenario: Enable editing type Given an empty IFC project And I add a cube @@ -73,7 +63,7 @@ Scenario: Assign type - assign to a type with representation maps And I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "MappedRepresentation" representation of "Model/Body/MODEL_VIEW" -Scenario: Assign type - assign to a type with a material layer set, which automatically recreates the shape +Scenario: Assign type - assign to a type with a material layer set, which automatically recreates a mesh and resets dimensions Given an empty IFC project And I add a cube And the object "Cube" is selected @@ -92,7 +82,28 @@ Scenario: Assign type - assign to a type with a material layer set, which automa And I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "SweptSolid" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Cube" has a "100" thick layered material containing the material "Default" - And the object "IfcWall/Cube" dimensions are "2,.1,3" + And the object "IfcWall/Cube" dimensions are "1,.1,1" + +Scenario: Assign type - assign to a type with a material layer set, which automatically recreates a solid (preserving parameters) + Given an empty IFC project + And I trigger "Add Element" + And I set the "Definition" property to "IfcElement" + And I set the "Class" property to "IfcWall" + And I set the "Representation" property to "Custom Extruded Solid" + And I click "OK" + And I add an empty + And the object "Empty" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I press "bim.assign_class" + And I press "bim.add_material()" + And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet" + And I press "bim.assign_material" + When the variable "type" is "{ifc}.by_type('IfcWallType')[0].id()" + And I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Unnamed')" + Then the object "IfcWall/Unnamed" has a "SweptSolid" representation of "Model/Body/MODEL_VIEW" + And the object "IfcWall/Unnamed" has a "100" thick layered material containing the material "Default" + And the object "IfcWall/Unnamed" dimensions are ".5,.1,.5" Scenario: Assign type - assign to a different type with a material layer set Given an empty IFC project @@ -127,10 +138,10 @@ Scenario: Assign type - assign to a different type with a material layer set When I press "bim.assign_type(relating_type={type}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "SweptSolid" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Cube" has a "100" thick layered material containing the material "Default" - And the object "IfcWall/Cube" dimensions are "2,.1,3" + And the object "IfcWall/Cube" dimensions are "1,.1,1" When I press "bim.assign_type(relating_type={type2}, related_object='IfcWall/Cube')" Then the object "IfcWall/Cube" has a "200" thick layered material containing the material "Default" - And the object "IfcWall/Cube" dimensions are "2,.2,3" + And the object "IfcWall/Cube" dimensions are "1,.2,1" Scenario: Assign type - assign to a type with a material profile set Given an empty IFC project @@ -202,11 +213,11 @@ Scenario: Select similar type Scenario: Purge unused types Given an empty IFC project And I press "bim.launch_type_manager" - And I set "scene.BIMModelProperties.type_class" to "IfcWallType" - And I set "scene.BIMModelProperties.type_predefined_type" to "SOLIDWALL" - And I set "scene.BIMModelProperties.type_template" to "EMPTY" - When I press "bim.add_type" - Then the object "IfcWallType/TYPEX" is an "IfcWallType" - And the object "IfcWallType/TYPEX" has no data - When I press "bim.purge_unused_types" - Then the object "IfcWallType/TYPEX" does not exist + And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" + And I set "scene.BIMRootProperties.ifc_predefined_type" to "SOLIDWALL" + And I set "scene.BIMRootProperties.representation_template" to "EMPTY" + When I press "bim.add_element" + Then the object "IfcWallType/Unnamed" is an "IfcWallType" + And the object "IfcWallType/Unnamed" has no data + When I press "bim.purge_unused_objects(object_type='TYPE')" + Then the object "IfcWallType/Unnamed" does not exist diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 95945ced81..a094f94d3d 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -18,6 +18,7 @@ import os import bpy +import pytest import traceback import webbrowser import numpy as np @@ -39,7 +40,7 @@ scenarios("feature") variables = { "cwd": Path.cwd().as_posix(), - "ifc": "IfcStore.get_file()", + "ifc": "tool.Ifc.get()", "pset_ifc": "IfcStore.pset_template_file", "classification_ifc": "IfcStore.classification_file", } @@ -65,12 +66,14 @@ class PanelSpy: def __getattr__(self, attr): self.spied_attr = attr + if annotation := self.panel.__annotations__.get(attr, None): + return annotation.keywords.get("default", None) # An operator property if attr == "layout": return self return self def __call__(self, *args, **kwargs): - if self.spied_attr in ("row", "column", "box", "separator"): + if self.spied_attr in ("row", "column", "box", "separator", "menu", "operator_menu_enum", "split"): return self elif self.spied_attr == "template_list": listtype_name, list_id, dataptr, propname, active_dataptr, active_propname = args @@ -123,7 +126,10 @@ class PanelSpy: prefix, op_name = operator.split(".") operator = getattr(getattr(bpy.ops, prefix), op_name) bl_idname = operator.idname() - bl_label = getattr(bpy.types, bl_idname).bl_label + try: + bl_label = getattr(bpy.types, bl_idname).bl_label + except: # Doesn't work on built-ins, I don't know what to do + bl_label = bl_idname text = kwargs.get("text", bl_label) icon = kwargs.get("icon", None) if text: @@ -152,10 +158,29 @@ class TemplateListSpy: self.spied_data = spied_data -panel_name_cache = {} +ui_name_cache = {} panel_spy: PanelSpy = None +def create_ui_name_cache(): + global ui_name_cache + if ui_name_cache: + return + for bl_idname in dir(bpy.types): + try: + panel_type = getattr(bpy.types, bl_idname) + if panel_type.bl_rna.base.name == "Panel": + ui_name_cache[panel_type.bl_label] = panel_type.bl_idname + elif panel_type.bl_rna.base.name == "Operator": + ui_name_cache[panel_type.bl_label] = bl_idname + elif panel_type.bl_rna.base.name == "Menu": + if panel_type.bl_label == "Add" and bl_idname != "VIEW3D_MT_add": + continue # Non-unique, but "VIEW3D_MT_add" is the one we care about + ui_name_cache[panel_type.bl_label] = bl_idname + except: + pass + + def replace_variables(value): for key, new_value in variables.items(): value = value.replace("{" + key + "}", str(new_value)) @@ -190,7 +215,8 @@ def an_empty_blender_session(): # default project settings bpy.context.scene.unit_settings.system = "METRIC" bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" - bpy.context.scene.BIMProjectProperties.template_file = "0" + props = tool.Project.get_project_props() + props.template_file = "0" tool.Blender.get_addon_preferences().should_play_chaching_sound = False @@ -203,7 +229,8 @@ def an_empty_ifc_project(): @given("an empty IFC2X3 project") def an_empty_ifc_2x3_project(): an_empty_blender_session() - bpy.context.scene.BIMProjectProperties.export_schema = "IFC2X3" + props = tool.Project.get_project_props() + props.export_schema = "IFC2X3" bpy.ops.bim.create_project() @@ -216,22 +243,40 @@ def the_brickschema_is_stubbed(): @given(parsers.parse('I look at the "{panel}" panel')) @when(parsers.parse('I look at the "{panel}" panel')) -@when(parsers.parse('I look at the "{panel}" panel')) +@then(parsers.parse('I look at the "{panel}" panel')) def i_look_at_the_panel_panel(panel): - global panel_name_cache + global ui_name_cache global panel_spy - if not panel_name_cache: - for bl_idname in dir(bpy.types): - try: - panel_type = getattr(bpy.types, bl_idname) - if panel_type.bl_rna.base.name != "Panel": - continue - panel_name_cache[panel_type.bl_label] = panel_type.bl_idname - except: - pass - if panel not in panel_name_cache: - assert False, f"Panel {panel} not found in {panel_name_cache}" - panel_spy = PanelSpy(getattr(bpy.types, panel_name_cache[panel])) + create_ui_name_cache() + if panel not in ui_name_cache: + assert False, f"Panel {panel} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[panel])) + panel_spy.refresh_spy() + + +@given(parsers.parse('I open the "{name}" menu')) +@when(parsers.parse('I open the "{name}" menu')) +@then(parsers.parse('I open the "{name}" menu')) +def i_open_the_name_menu(name): + global ui_name_cache + global panel_spy + create_ui_name_cache() + if name not in ui_name_cache: + assert False, f"Menu {name} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[name])) + panel_spy.refresh_spy() + + +@given(parsers.parse('I trigger "{operator}"')) +@when(parsers.parse('I trigger "{operator}"')) +@then(parsers.parse('I trigger "{operator}"')) +def i_trigger_operator(operator): + global ui_name_cache + global panel_spy + create_ui_name_cache() + if operator not in ui_name_cache: + assert False, f"Operator {operator} not found in {ui_name_cache}" + panel_spy = PanelSpy(getattr(bpy.types, ui_name_cache[operator])) panel_spy.refresh_spy() @@ -299,27 +344,36 @@ def i_see_the_prop_property_is_value(prop, value): def i_set_the_prop_property_to_value(prop, value): value = value.strip() panel_spy.refresh_spy() - for spied_prop in panel_spy.spied_props: - if prop in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): - if spied_prop["prop_type"] == "BOOLEAN": - if value == "TRUE": - setattr(spied_prop["props"], spied_prop["name"], True) - elif value == "FALSE": - setattr(spied_prop["props"], spied_prop["name"], False) - elif spied_prop["prop_type"] == "FLOAT": - setattr(spied_prop["props"], spied_prop["name"], float(value)) - elif spied_prop["prop_type"] == "INT": - setattr(spied_prop["props"], spied_prop["name"], int(value)) - elif spied_prop["prop_type"] == "ENUM": - enum_identifier = [i for i in spied_prop["enum_items"] if i is not None and i[1] == value] - if not enum_identifier: - assert False, f"Could not find value {value} in enum {spied_prop['enum_items']}" - setattr(spied_prop["props"], spied_prop["name"], enum_identifier[0][0]) - else: - setattr(spied_prop["props"], spied_prop["name"], value) - panel_spy.is_spy_dirty = True - return - assert False, f"Property {prop} not found in {panel_spy.spied_props}" + is_nth = False + if prop[0].isnumeric() and (prop.endswith("st") or prop.endswith("nd") or prop.endswith("th")): + is_nth = True + for nth, spied_prop in enumerate(panel_spy.spied_props): + if is_nth and nth != int(prop[:-2]) - 1: + continue + if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + continue + if spied_prop["prop_type"] == "BOOLEAN": + if value == "TRUE": + setattr(spied_prop["props"], spied_prop["name"], True) + elif value == "FALSE": + setattr(spied_prop["props"], spied_prop["name"], False) + elif spied_prop["prop_type"] == "FLOAT": + setattr(spied_prop["props"], spied_prop["name"], float(value)) + elif spied_prop["prop_type"] == "INT": + setattr(spied_prop["props"], spied_prop["name"], int(value)) + elif spied_prop["prop_type"] == "ENUM": + enum_identifier = [i for i in spied_prop["enum_items"] if i is not None and i[1] == value] + if not enum_identifier: + assert False, f"Could not find value {value} in enum {spied_prop['enum_items']}" + setattr(spied_prop["props"], spied_prop["name"], enum_identifier[0][0]) + elif spied_prop["prop_type"] == "POINTER": + setattr(spied_prop["props"], spied_prop["name"], bpy.data.objects.get(value)) + else: + setattr(spied_prop["props"], spied_prop["name"], value) + panel_spy.is_spy_dirty = True + return + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_props)]) + assert False, f"Property {prop} not found in:\n{debug}" @then(parsers.parse('The "{name}" list has {total} items')) @@ -363,20 +417,25 @@ def i_create_default_mep_types(): model_props = tool.Model.get_model_props() # add couple segments types - model_props.type_class = "IfcDuctSegmentType" - model_props.type_name = "RECT1" - model_props.type_template = "FLOW_SEGMENT_RECTANGULAR" - bpy.ops.bim.add_type() + i_trigger_operator("Add Element") + i_set_the_prop_property_to_value("Name", "RECT1") + i_set_the_prop_property_to_value("Class", "IfcDuctSegmentType") + i_set_the_prop_property_to_value("Representation", "Rectangular Distribution Segment") + i_click_button("OK") - model_props.type_template = "FLOW_SEGMENT_CIRCULAR" - model_props.type_name = "CIRCLE1" - bpy.ops.bim.add_type() + i_trigger_operator("Add Element") + i_set_the_prop_property_to_value("Name", "CIRCLE1") + i_set_the_prop_property_to_value("Class", "IfcDuctSegmentType") + i_set_the_prop_property_to_value("Representation", "Circular Distribution Segment") + i_click_button("OK") # add an actuator type - model_props.type_class = "IfcActuatorType" - model_props.type_template = "MESH" # cube representation - model_props.type_name = "ACTUATOR" - bpy.ops.bim.add_type() + i_trigger_operator("Add Element") + i_set_the_prop_property_to_value("Name", "ACTUATOR") + i_set_the_prop_property_to_value("Class", "IfcActuatorType") + i_set_the_prop_property_to_value("Representation", "Custom Tessellation") + i_click_button("OK") + with bpy.context.temp_override(active_object=bpy.data.objects["IfcActuatorType/ACTUATOR"]): bpy.ops.bim.add_port() # port at cube's left side @@ -441,6 +500,20 @@ def i_add_a_plane_of_size_size_at_location(size, location): bpy.ops.mesh.primitive_plane_add(size=float(size), location=[float(co) for co in location.split(",")]) +@then(parsers.parse('I expect an error "{error_msg}" when "{function}"')) +def i_expect_an_error_msg_when_function(error_msg, function): + try: + exec(function) + except Exception as e: + actual_error_msg = str(e).strip() + if str(e).strip() != error_msg: + traceback.print_exc() + msg = f"Got different exception running {function} - '{actual_error_msg}' instead of '{error_msg}'" + assert False, msg + return + assert False, f"Function {function} ran without exception '{error_msg}'" + + @then(parsers.parse('I press "{operator}" and expect error "{error_msg}"')) def i_press_operator_and_expect_error(operator, error_msg): operator = replace_variables(operator) @@ -449,13 +522,14 @@ def i_press_operator_and_expect_error(operator, error_msg): exec(f"bpy.ops.{operator}") else: exec(f"bpy.ops.{operator}()") - assert False, f"Operator bpy.ops.{operator} ran without exception '{error_msg}'" except Exception as e: actual_error_msg = str(e).strip() if str(e).strip() != error_msg: traceback.print_exc() msg = f"Got different exception running bpy.ops.{operator} - '{actual_error_msg}' instead of '{error_msg}'" assert False, msg + return + assert False, f"Operator bpy.ops.{operator} ran without exception '{error_msg}'" @given(parsers.parse('I press "{operator}"')) @@ -474,6 +548,7 @@ def i_press_operator(operator): @given(parsers.parse('I click "{button}"')) @when(parsers.parse('I click "{button}"')) +@then(parsers.parse('I click "{button}"')) def i_click_button(button): panel_spy.refresh_spy() for spied_operator in panel_spy.spied_operators: @@ -487,7 +562,12 @@ def i_click_button(button): val = getattr(spied_prop["props"], spied_prop["name"]) setattr(spied_prop["props"], spied_prop["name"], not bool(val)) return - assert False, f"Could not find {button} in {panel_spy.spied_operators}" + if button == "OK" and panel_spy.panel.bl_rna.base.name == "Operator": + # Clicked confirm on an operator's draw dialog + return i_press_operator(panel_spy.panel.bl_idname) + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)]) + debug += f"\nHere is the text we see: {panel_spy.spied_labels}" + assert False, f"Could not find {button}:\n{debug}" @given(parsers.parse('I click "{button}" and expect error "{error_msg}"')) @@ -531,6 +611,7 @@ def i_refresh_the_selected_objects(): bonsai.bim.handler.active_object_callback() +@given("I deselect all objects") @when("I deselect all objects") def i_deselect_all_objects(): bpy.context.view_layer.objects.active = None @@ -539,6 +620,8 @@ def i_deselect_all_objects(): @given(parsers.parse('the object "{name}" is selected')) @when(parsers.parse('the object "{name}" is selected')) +@given(parsers.parse('I select the object "{name}"')) +@when(parsers.parse('I select the object "{name}"')) def the_object_name_is_selected(name): i_deselect_all_objects() additionally_the_object_name_is_selected(name) @@ -594,7 +677,9 @@ def then_the_object_name_is_placed_in_the_collection_collection(name: str, colle def additionally_the_object_name_is_selected(name): obj = bpy.context.scene.objects.get(name) if not obj: - assert False, f'The object "{name}" could not be selected' + total = len(bpy.context.scene.objects) + debug = "\n".join([o.name for o in bpy.context.scene.objects]) + assert False, f'The object "{name}" could not be selected. Available objects ({total} total):\n{debug}' bpy.context.view_layer.objects.active = obj obj.select_set(True) @@ -655,6 +740,8 @@ def nothing_happens(): pass +@given(parsers.parse('the object "{name}" exists')) +@when(parsers.parse('the object "{name}" exists')) @then(parsers.parse('the object "{name}" exists')) def the_object_name_exists(name: str) -> bpy.types.Object: # Some objects from linked collections may share the same name. This disambiguates them. @@ -664,7 +751,8 @@ def the_object_name_exists(name: str) -> bpy.types.Object: else: obj = bpy.data.objects.get(name) if not obj: - assert False, f'The object "{name}" does not exist' + debug = "\n".join([o.name for o in bpy.data.objects]) + assert False, f'The object "{name}" does not exist:\n{debug}' return obj @@ -716,8 +804,8 @@ def the_collection_exclude_status_is(name: str, exclude: str) -> None: @then(parsers.parse('the object "{name1}" and "{name2}" are different elements')) def the_object_name1_and_name2_are_different_elements(name1, name2): ifc = an_ifc_file_exists() - element1 = ifc.by_id(the_object_name_exists(name1).BIMObjectProperties.ifc_definition_id) - element2 = ifc.by_id(the_object_name_exists(name2).BIMObjectProperties.ifc_definition_id) + element1 = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name1))) + element2 = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name2))) assert element1 != element2, f"Objects {name1} and {name2} have same elements {element1} and {element2}" @@ -730,7 +818,7 @@ def the_object_name_has_a_body_of_value(name, value): @then(parsers.parse('the object "{name}" has a "{type}" representation of "{context}"')) def the_object_name_has_a_representation_type_of_context(name, type, context): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) context, subcontext, target_view = context.split("/") rep = ifcopenshell.util.representation.get_representation(element, context, subcontext or None, target_view or None) assert rep @@ -742,7 +830,7 @@ def the_object_name_has_a_representation_type_of_context(name, type, context): def the_object_name_data_is_a_type_representation_of_context(name, type, context): ifc = an_ifc_file_exists() context, subcontext, target_view = context.split("/") - rep = ifc.by_id(the_object_name_exists(name).data.BIMMeshProperties.ifc_definition_id) + rep = ifc.by_id(tool.Geometry.get_mesh_props(the_object_name_exists(name).data).ifc_definition_id) assert rep assert rep.RepresentationType == type, f"The object {name} is not a {type} representation" assert rep.ContextOfItems.ContextType == context @@ -784,14 +872,14 @@ def the_ifc_material_name_does_not_exist(name): @then("an IFC file does not exist") def an_ifc_file_does_not_exist(): - ifc = IfcStore.get_file() + ifc = tool.Ifc.get() if ifc: assert False, "An IFC is available" @then("an IFC file exists") def an_ifc_file_exists(): - ifc = IfcStore.get_file() + ifc = tool.Ifc.get() if not ifc: assert False, "No IFC file is available" return ifc @@ -805,8 +893,8 @@ def the_object_name_should_display_as_mode(name, mode): @then(parsers.parse('the object "{name}" is voided by "{void}"')) def the_object_name_is_voided_by_void(name, void): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert any((rel for rel in element.HasOpenings if rel.RelatedOpeningElement.Name == void)), "No void found" @@ -821,16 +909,16 @@ def the_object_name_is_not_voided_by_void(name, void): @then(parsers.parse('the object "{name}" is not voided')) def the_object_name_is_not_voided(name): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert not element.HasOpenings, "A void was found" @then(parsers.parse('the object "{name}" is a void')) def the_object_name_is_a_void(name): - ifc = IfcStore.get_file() + ifc = tool.Ifc.get() obj = the_object_name_exists(name) - element = ifc.by_id(obj.BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(obj)) assert any((element.VoidsElements)), "No void was found" @@ -870,14 +958,14 @@ def the_object_name_is_not_visible(name): @then(parsers.parse('the object "{name}" is an "{ifc_class}"')) def the_object_name_is_an_ifc_class(name, ifc_class): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert element.is_a(ifc_class), f'Object "{name}" is an {element.is_a()}' @then(parsers.parse('the object "{name}" is not an IFC element')) def the_object_name_is_not_an_ifc_element(name): obj = the_object_name_exists(name) - ifc_definition_id = obj.BIMObjectProperties.ifc_definition_id + ifc_definition_id = tool.Blender.get_ifc_definition_id(obj) assert ifc_definition_id == 0, f"The object {obj} has an ID of {ifc_definition_id}" @@ -888,35 +976,35 @@ def the_object_name_has_no_data(name): @then(parsers.parse('the object "{name}" has data which is an IFC representation')) def the_object_name_has_ifc_representation_data(name): - id = the_object_name_exists(name).data.BIMMeshProperties.ifc_definition_id + id = tool.Geometry.get_mesh_props(the_object_name_exists(name).data).ifc_definition_id assert id != 0, f"The ID is {id}" @then(parsers.parse('the material "{name}" is an IFC material')) def the_material_name_is_an_ifc_material(name): obj = the_material_name_exists(name) - ifc_definition_id = obj.BIMObjectProperties.ifc_definition_id + ifc_definition_id = tool.Blender.get_ifc_definition_id(obj) assert ifc_definition_id != 0, f"The material {obj} has no ID: {ifc_definition_id}" @then(parsers.parse('the material "{name}" is not an IFC material')) def the_material_name_is_not_an_ifc_material(name): obj = the_material_name_exists(name) - ifc_definition_id = obj.BIMObjectProperties.ifc_definition_id + ifc_definition_id = tool.Blender.get_ifc_definition_id(obj) assert ifc_definition_id == 0, f"The material {obj} has an ID of {ifc_definition_id}" @then(parsers.parse('the material "{name}" is an IFC style')) def the_material_name_is_an_ifc_style(name): obj = the_material_name_exists(name) - ifc_definition_id = obj.BIMStyleProperties.ifc_definition_id + ifc_definition_id = tool.Blender.get_ifc_definition_id(obj) assert ifc_definition_id != 0, f"The material {obj} has a style ID of {ifc_definition_id}" @then(parsers.parse('the material "{name}" is not an IFC style')) def the_material_name_is_not_an_ifc_style(name): obj = the_material_name_exists(name) - ifc_definition_id = obj.BIMStyleProperties.ifc_definition_id + ifc_definition_id = tool.Blender.get_ifc_definition_id(obj) assert ifc_definition_id == 0, f"The material {obj} has a style ID of {ifc_definition_id}" @@ -934,8 +1022,8 @@ def the_object_name_has_number_vertices(name, number): @then(parsers.parse('the void "{name}" is filled by "{filling}"')) def the_void_name_is_filled_by_filling(name, filling): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert any((rel.RelatedBuildingElement.Name == filling for rel in element.HasFillings)), "No filling found" @@ -951,8 +1039,8 @@ def the_void_name_is_not_filled_by_filling(name, filling): @when(parsers.parse('the object "{name}" is not a filling')) @then(parsers.parse('the object "{name}" is not a filling')) def the_object_name_is_not_a_filling(name): - ifc = IfcStore.get_file() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + ifc = tool.Ifc.get() + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) assert not any(element.FillsVoids), "A filling was found" @@ -1008,8 +1096,9 @@ def prop_is_roughly_value(prop, value): def the_object_name_has_a_cartesian_point_offset_of_offset(name: str, offset: str) -> None: offset = replace_variables(offset) obj = the_object_name_exists(name) - assert obj.BIMObjectProperties.blender_offset_type == "CARTESIAN_POINT" - obj_offset = np.array(tuple(map(float, obj.BIMObjectProperties.cartesian_point_offset.split(",")))) + props = tool.Blender.get_object_bim_props(obj) + assert props.blender_offset_type == "CARTESIAN_POINT" + obj_offset = np.array(tuple(map(float, props.cartesian_point_offset.split(",")))) offset = np.array(tuple(map(float, offset.split(",")))) assert np.allclose(obj_offset, offset) @@ -1111,7 +1200,7 @@ def the_object_name_is_at_location(name, location): obj_location = the_object_name_exists(name).location assert ( obj_location - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object is at {obj_location}" + ).length < 0.1, f"Object is at {obj_location} instead of {location}" @then(parsers.parse('the object "{name}" has a vertex at "{location}"')) @@ -1143,7 +1232,7 @@ def the_object_name_dimensions_are_dimensions(name, dimensions): actual_dimensions = list(the_object_name_exists(name).dimensions) expected_dimensions = [float(co) for co in dimensions.split(",")] for i, number in enumerate(actual_dimensions): - assert is_x(number, expected_dimensions[i]), f"Expected {expected_dimensions[i]} but got {number}" + assert is_x(number, expected_dimensions[i]), f"Expected {expected_dimensions} but got {actual_dimensions}" @then(parsers.parse('the object "{name}" top right corner is at "{location}"')) @@ -1152,7 +1241,7 @@ def the_object_name_top_right_corner_is_at_location(name, location): obj_corner = obj.matrix_world @ Vector(obj.bound_box[6]) assert ( obj_corner - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object has top right corner {obj_corner}" + ).length < 0.1, f"Object has top right corner {obj_corner} instead of {location}" @then(parsers.parse('the object "{name}" bottom left corner is at "{location}"')) @@ -1161,13 +1250,13 @@ def the_object_name_bottom_left_corner_is_at_location(name, location): obj_corner = obj.matrix_world @ Vector(obj.bound_box[0]) assert ( obj_corner - Vector([float(co) for co in location.split(",")]) - ).length < 0.1, f"Object has bottom left corner {obj_corner}" + ).length < 0.1, f"Object has bottom left corner {obj_corner} instead of {location}" @then(parsers.parse('the object "{name}" is contained in "{container_name}"')) def the_object_name_is_contained_in_container_name(name, container_name): ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) container = ifcopenshell.util.element.get_container(element) if not container: assert False, f'Object "{name}" is not in any container' @@ -1177,7 +1266,7 @@ def the_object_name_is_contained_in_container_name(name, container_name): @then(parsers.parse('the object "{name}" is contained in object "{container_name}"')) def the_object_name_is_contained_in_object_container_name(name: str, container_name: str) -> None: ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) container = ifcopenshell.util.element.get_container(element) if not container: assert False, f'Object "{name}" is not in any container' @@ -1188,7 +1277,7 @@ def the_object_name_is_contained_in_object_container_name(name: str, container_n @then(parsers.parse('the object "{name}" is aggregated by object "{aggregate_name}"')) def the_object_name_is_aggregated_by_object_aggregate_name(name: str, aggregate_name: str) -> None: ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) aggregate = ifcopenshell.util.element.get_aggregate(element) if not aggregate: assert False, f'Object "{name}" is not aggregated by any element' @@ -1199,7 +1288,7 @@ def the_object_name_is_aggregated_by_object_aggregate_name(name: str, aggregate_ @then(parsers.parse('the object "{name}" has no aggregate')) def the_object_name_has_no_aggregate(name: str) -> None: ifc = an_ifc_file_exists() - element = ifc.by_id(the_object_name_exists(name).BIMObjectProperties.ifc_definition_id) + element = ifc.by_id(tool.Blender.get_ifc_definition_id(the_object_name_exists(name))) aggregate = ifcopenshell.util.element.get_aggregate(element) if aggregate: assert False, f'Object "{name}" is aggregated by element "{aggregate}"' @@ -1220,7 +1309,7 @@ def the_object_name_has_no_modifiers(name): @given(parsers.parse('I load the IFC test file "{filepath}"')) def i_load_the_ifc_test_file(filepath): filepath = f"{variables['cwd']}{filepath}" - bpy.ops.bim.load_project(filepath=filepath, use_relative_path=True) + bpy.ops.bim.load_project(filepath=filepath) @given("I load the demo construction library") @@ -1245,7 +1334,7 @@ def i_display_the_construction_type_browser(): @when("I add the construction type") def i_add_the_active_construction_type(): props = tool.Model.get_model_props() - bpy.ops.bim.add_constr_type_instance(relating_type_id=int(props.relating_type_id)) + bpy.ops.bim.add_occurrence(relating_type_id=int(props.relating_type_id)) @then(parsers.parse("construction type is {relating_type_name}")) @@ -1255,6 +1344,20 @@ def construction_type(relating_type_name): assert relating_type == relating_type_name, f"Construction Type is a {relating_type}, not a {relating_type_name}" +@given("I toggle edit mode") +@when("I toggle edit mode") +@then("I toggle edit mode") +def i_toggle_edit_mode(): + props = tool.Geometry.get_geometry_props() + print(f"Toggling from {bpy.context.mode} / {props.mode} ...") + print("Selected items:", bpy.context.active_object, bpy.context.selected_objects) + if bpy.context.mode == "OBJECT": + bpy.ops.bim.override_mode_set_edit() + else: + bpy.ops.bim.override_mode_set_object() + print(f"... mode is now {bpy.context.mode} / {props.mode}") + + @when("I move the cursor to the bottom left corner") def move_cursor_bottom_left(): bpy.context.window.cursor_warp(10, 10) @@ -1273,6 +1376,9 @@ def prepare_undo(): def hit_undo(): bpy.ops.ed.undo_push(message="UNDO STEP") bpy.ops.ed.undo() + # override = tool.Blender.get_viewport_context() + # with bpy.context.temp_override(**override): + # bpy.ops.ed.undo() @then(parsers.parse('the object "{obj_name1}" has a connection with "{obj_name2}"')) @@ -1327,12 +1433,9 @@ def the_obj1_and_obj2_belong_the_same_linked_aggregate_group(obj_name1, obj_name def the_obj_layer_lenght_is_set_to(value): value = float(value) try: - eval(f"bpy.context.scene.BIMModelProperties.length") + eval("bpy.context.scene.BIMModelProperties.length") except: assert False, f"Property BIMModelProperties.length does not exist when trying to set to value {value}" - - print(50 * "@", bpy.context.selected_objects) - props = tool.Model.get_model_props() props.length = value bpy.ops.bim.change_layer_length(length=value) @@ -1348,11 +1451,19 @@ def run_test_code(): pass +@given(parsers.parse("I fail")) +@when(parsers.parse("I fail")) +@then(parsers.parse("I fail")) +def i_fail(): + assert False + + @given(parsers.parse("I save sample test files")) @when(parsers.parse("I save sample test files")) @then(parsers.parse("I save sample test files")) def saving_sample_test_files(and_open_in_blender=None): filepath = f"{variables['cwd']}/test/files/temp/sample_test_file" + print(f"Saved to {filepath}") bpy.ops.bim.save_project(filepath=f"{filepath}.ifc", should_save_as=True) bpy.ops.wm.save_as_mainfile(filepath=f"{filepath}.blend") diff --git a/src/bonsai/test/tool/test_brick.py b/src/bonsai/test/tool/test_brick.py index c2d10d699f..0220cb2962 100644 --- a/src/bonsai/test/tool/test_brick.py +++ b/src/bonsai/test/tool/test_brick.py @@ -59,16 +59,18 @@ class TestAddBrickBreadcrumb(NewFile): def test_run(self): subject.set_active_brick_class("brick_class") subject.add_brick_breadcrumb() - assert bpy.context.scene.BIMBrickProperties.brick_breadcrumbs[0].name == "brick_class" + props = tool.Brick.get_brick_props() + assert props.brick_breadcrumbs[0].name == "brick_class" subject.add_brick_breadcrumb() - assert bpy.context.scene.BIMBrickProperties.brick_breadcrumbs[1].name == "brick_class" + assert props.brick_breadcrumbs[1].name == "brick_class" def test_run_split_screen(self): subject.set_active_brick_class("brick_class", split_screen=True) subject.add_brick_breadcrumb(split_screen=True) - assert bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs[0].name == "brick_class" + props = tool.Brick.get_brick_props() + assert props.split_screen_brick_breadcrumbs[0].name == "brick_class" subject.add_brick_breadcrumb(split_screen=True) - assert bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs[1].name == "brick_class" + assert props.split_screen_brick_breadcrumbs[1].name == "brick_class" class TestAddBrickFromElement(NewFile): @@ -122,11 +124,10 @@ class TestAddBrickifcProject(NewFile): result = subject.add_brickifc_project("http://example.org/digitaltwin#") assert result == f"http://example.org/digitaltwin#{project.GlobalId}" brick = URIRef(result) + props = tool.Blender.get_bim_props() assert list(BrickStore.graph.triples((brick, A, REF.ifcProject))) assert list(BrickStore.graph.triples((brick, REF.ifcProjectID, Literal(project.GlobalId)))) - assert list( - BrickStore.graph.triples((brick, REF.ifcFileLocation, Literal(bpy.context.scene.BIMProperties.ifc_file))) - ) + assert list(BrickStore.graph.triples((brick, REF.ifcFileLocation, Literal(props.ifc_file)))) assert list( BrickStore.graph.triples( (brick, URIRef("http://www.w3.org/2000/01/rdf-schema#label"), Literal("My Project")) @@ -189,25 +190,28 @@ class TestRemoveRelation(NewFile): class TestClearBrickBrowser(NewFile): def test_run(self): - bpy.context.scene.BIMBrickProperties.bricks.add() + props = tool.Brick.get_brick_props() + props.bricks.add() subject.clear_brick_browser() - assert len(bpy.context.scene.BIMBrickProperties.bricks) == 0 + assert len(props.bricks) == 0 def test_run_split_screen(self): - bpy.context.scene.BIMBrickProperties.split_screen_bricks.add() + props = tool.Brick.get_brick_props() + props.split_screen_bricks.add() subject.clear_brick_browser(split_screen=True) - assert len(bpy.context.scene.BIMBrickProperties.split_screen_bricks) == 0 + assert len(props.split_screen_bricks) == 0 class TestClearProject(NewFile): def test_run(self): BrickStore.graph = "graph" - bpy.context.scene.BIMBrickProperties.active_brick_class == "brick_class" - bpy.context.scene.BIMBrickProperties.split_screen_active_brick_class == "brick_class2" + props = tool.Brick.get_brick_props() + props.active_brick_class = "brick_class" + props.split_screen_active_brick_class = "brick_class2" subject.clear_project() assert BrickStore.graph is None - assert bpy.context.scene.BIMBrickProperties.active_brick_class == "" - assert bpy.context.scene.BIMBrickProperties.split_screen_active_brick_class == "" + assert props.active_brick_class == "" + assert props.split_screen_active_brick_class == "" class TestExportBrickAttributes(NewFile): @@ -375,13 +379,14 @@ class TestImportBrickClasses(NewFile): def test_run(self): TestLoadBrickFile().test_run() subject.import_brick_classes("Class") - assert len(bpy.context.scene.BIMBrickProperties.bricks) == 2 - brick = bpy.context.scene.BIMBrickProperties.bricks[0] + props = tool.Brick.get_brick_props() + assert len(props.bricks) == 2 + brick = props.bricks[0] assert brick.name == "Building" assert brick.uri == "https://brickschema.org/schema/Brick#Building" assert brick.total_items == 1 assert not brick.label - brick = bpy.context.scene.BIMBrickProperties.bricks[1] + brick = props.bricks[1] assert brick.name == "Location" assert brick.uri == "https://brickschema.org/schema/Brick#Location" assert brick.total_items == 1 @@ -390,13 +395,14 @@ class TestImportBrickClasses(NewFile): def test_run_split_sccreen(self): TestLoadBrickFile().test_run() subject.import_brick_classes("Class", split_screen=True) - assert len(bpy.context.scene.BIMBrickProperties.split_screen_bricks) == 2 - brick = bpy.context.scene.BIMBrickProperties.split_screen_bricks[0] + props = tool.Brick.get_brick_props() + assert len(props.split_screen_bricks) == 2 + brick = props.split_screen_bricks[0] assert brick.name == "Building" assert brick.uri == "https://brickschema.org/schema/Brick#Building" assert brick.total_items == 1 assert not brick.label - brick = bpy.context.scene.BIMBrickProperties.split_screen_bricks[1] + brick = props.split_screen_bricks[1] assert brick.name == "Location" assert brick.uri == "https://brickschema.org/schema/Brick#Location" assert brick.total_items == 1 @@ -407,8 +413,9 @@ class TestImportBrickItems(NewFile): def test_run(self): TestLoadBrickFile().test_run() subject.import_brick_items("Building") - assert len(bpy.context.scene.BIMBrickProperties.bricks) == 1 - brick = bpy.context.scene.BIMBrickProperties.bricks[0] + props = tool.Brick.get_brick_props() + assert len(props.bricks) == 1 + brick = props.bricks[0] assert brick.name == "bldg" assert brick.label == "My Building" assert brick.uri == "https://example.org/digitaltwin#bldg" @@ -417,8 +424,9 @@ class TestImportBrickItems(NewFile): def test_run_split_screen(self): TestLoadBrickFile().test_run() subject.import_brick_items("Building", split_screen=True) - assert len(bpy.context.scene.BIMBrickProperties.split_screen_bricks) == 1 - brick = bpy.context.scene.BIMBrickProperties.split_screen_bricks[0] + props = tool.Brick.get_brick_props() + assert len(props.split_screen_bricks) == 1 + brick = props.split_screen_bricks[0] assert brick.name == "bldg" assert brick.label == "My Building" assert brick.uri == "https://example.org/digitaltwin#bldg" @@ -457,18 +465,20 @@ class TestNewBrickFile(NewFile): class TestPopBrickBreadcrumb(NewFile): def test_run(self): - bpy.context.scene.BIMBrickProperties.brick_breadcrumbs.add().name = "foo" - bpy.context.scene.BIMBrickProperties.brick_breadcrumbs.add().name = "bar" + props = tool.Brick.get_brick_props() + props.brick_breadcrumbs.add().name = "foo" + props.brick_breadcrumbs.add().name = "bar" assert subject.pop_brick_breadcrumb() == "bar" - assert len(bpy.context.scene.BIMBrickProperties.brick_breadcrumbs) == 1 - assert bpy.context.scene.BIMBrickProperties.brick_breadcrumbs[0].name == "foo" + assert len(props.brick_breadcrumbs) == 1 + assert props.brick_breadcrumbs[0].name == "foo" def test_run_split_screen(self): - bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs.add().name = "foo" - bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs.add().name = "bar" + props = tool.Brick.get_brick_props() + props.split_screen_brick_breadcrumbs.add().name = "foo" + props.split_screen_brick_breadcrumbs.add().name = "bar" assert subject.pop_brick_breadcrumb(split_screen=True) == "bar" - assert len(bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs) == 1 - assert bpy.context.scene.BIMBrickProperties.split_screen_brick_breadcrumbs[0].name == "foo" + assert len(props.split_screen_brick_breadcrumbs) == 1 + assert props.split_screen_brick_breadcrumbs[0].name == "foo" class TestRemoveBrick(NewFile): @@ -513,19 +523,22 @@ class TestRunViewBrickClass(NewFile): class TestSelectBrowserItem(NewFile): def test_run(self): subject.set_active_brick_class("brick_class") - assert bpy.context.scene.BIMBrickProperties.active_brick_class == "brick_class" + props = tool.Brick.get_brick_props() + assert props.active_brick_class == "brick_class" def test_run(self): subject.set_active_brick_class("brick_class", split_screen=True) - assert bpy.context.scene.BIMBrickProperties.split_screen_active_brick_class == "brick_class" + props = tool.Brick.get_brick_props() + assert props.split_screen_active_brick_class == "brick_class" class TestSetActiveBrickClass(NewFile): def test_run(self): - bpy.context.scene.BIMBrickProperties.bricks.add().name = "foo" - bpy.context.scene.BIMBrickProperties.bricks.add().name = "bar" + props = tool.Brick.get_brick_props() + props.bricks.add().name = "foo" + props.bricks.add().name = "bar" subject.select_browser_item("namespace#bar") - assert bpy.context.scene.BIMBrickProperties.active_brick_index == 1 + assert props.active_brick_index == 1 class TestSerializeBrick(NewFile): diff --git a/src/bonsai/test/tool/test_classification.py b/src/bonsai/test/tool/test_classification.py index 5d094bf0bc..5a90457cc7 100644 --- a/src/bonsai/test/tool/test_classification.py +++ b/src/bonsai/test/tool/test_classification.py @@ -84,19 +84,18 @@ class TestAddClassificationReferenceFromBSDD(NewFile): assert pset.name == "Pset_SpaceCommon" assert len(pset.properties) == 1 pset_prop = pset.properties[0] - assert pset_prop.name == "HandicapAccessible" + assert pset_prop.name == "Handicap Accessible" pset_prop.bool_value = True bpy.ops.bim.add_classification_reference_from_bsdd(obj="IfcSpace/Cube", obj_type="Object") pset = ifcopenshell.util.element.get_pset(element, "Pset_SpaceCommon") - assert pset and pset["HandicapAccessible"] == True + assert pset and pset["Handicap Accessible"] == True refs = ifcopenshell.util.classification.get_references(element) assert len(refs) == 1 assert list(refs)[0].Location.startswith(uri) def test_add_clasification_reference_with_object_type(self): bpy.ops.bim.create_project() - ifc_file = tool.Ifc.get() context = bpy.context bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] @@ -104,27 +103,29 @@ class TestAddClassificationReferenceFromBSDD(NewFile): element = tool.Ifc.get_entity(obj) assert element - bpy.ops.bim.load_bsdd_domains() - uri = "https://identifier.buildingsmart.org/uri/ifcairport/ifcairport/1.0" - bpy.ops.bim.set_active_bsdd_domain(name="IFC Airport", uri=uri) props = context.scene.BIMBSDDProperties + props.load_preview_domains = True + bpy.ops.bim.load_bsdd_domains() + uri = "https://identifier.buildingsmart.org/uri/ifcairport/ifcairport/0.9" + bpy.ops.bim.set_active_bsdd_domain(name="IFC Airport", uri=uri) bpy.context.scene.BIMClassificationProperties.classification_source = "BSDD" props.should_filter_ifc_class = False # Important due to class mismatch. - props.keyword = "Check-In Conveyor" + props.keyword = "check-in conveyor" bpy.ops.bim.search_bsdd_classifications() - # https://identifier.buildingsmart.org/uri/ifcairport/ifcairport/1.0/class/ifcairport0000000004 + # https://identifier.buildingsmart.org/uri/bs-airport/airport/0.9/class/AD-BHS-006 props.active_classification_index = next( - i for i, c in enumerate(props.classifications) if c.name == "Check-In Conveyor" + i for i, c in enumerate(props.classifications) if c.name == "Check-in conveyor" ) bpy.ops.bim.get_bsdd_classification_properties() psets = props.classification_psets assert len(psets) == 1 pset = psets[0] - assert pset.name == "undefined_set" - assert len(pset.properties) == 1 + assert pset.name == "ISet_AirportDomain" + assert len(pset.properties) == 20 pset_prop = pset.properties[0] - assert pset_prop.name == "ObjectType" + assert pset_prop.name == "Conveying speed" bpy.ops.bim.add_classification_reference_from_bsdd(obj="IfcSpace/Cube", obj_type="Object") - assert element.ObjectType == "CHECKINCONVEYOR" + # Check this? + # assert element.ObjectType == "CHECKINCONVEYOR" assert not ifcopenshell.util.element.get_psets(element) diff --git a/src/bonsai/test/tool/test_collector.py b/src/bonsai/test/tool/test_collector.py index aff221632f..8c0cd557c6 100644 --- a/src/bonsai/test/tool/test_collector.py +++ b/src/bonsai/test/tool/test_collector.py @@ -19,6 +19,7 @@ import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.aggregate import ifcopenshell.util.element import bonsai.core.tool import bonsai.tool as tool @@ -167,7 +168,8 @@ class TestAssign(NewIfc): tool.Ifc.link(building_element, building_obj) building_collection = bpy.data.collections.new("Foobar") bpy.context.scene.collection.children.link(building_collection) - building_obj.BIMObjectProperties.collection = building_collection + props = tool.Blender.get_object_bim_props(building_obj) + props.collection = building_collection building_collection.objects.link(building_obj) ifcopenshell.api.aggregate.assign_object( tool.Ifc.get(), diff --git a/src/bonsai/test/tool/test_debug.py b/src/bonsai/test/tool/test_debug.py index d7d6a95c2c..888a717f4d 100644 --- a/src/bonsai/test/tool/test_debug.py +++ b/src/bonsai/test/tool/test_debug.py @@ -55,7 +55,8 @@ class TestLoadExpress(NewFile): class TestPurgeHdf5Cache(NewFile): def test_run(self): - cache_dir = Path(bpy.context.scene.BIMProperties.cache_dir) + props = tool.Blender.get_bim_props() + cache_dir = Path(props.cache_dir) test_file = cache_dir / "test.h5" test_file.parent.mkdir(parents=True, exist_ok=True) test_file.touch() diff --git a/src/bonsai/test/tool/test_document.py b/src/bonsai/test/tool/test_document.py index 30ce8b5721..d60b9fbe31 100644 --- a/src/bonsai/test/tool/test_document.py +++ b/src/bonsai/test/tool/test_document.py @@ -36,13 +36,13 @@ class TestAddBreadcrumb(NewFile): tool.Ifc().set(ifc) document = ifc.createIfcDocumentInformation() subject.add_breadcrumb(document) - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() assert props.breadcrumbs[0].name == str(document.id()) class TestClearBreadcrumbs(NewFile): def test_run(self): - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() props.breadcrumbs.add() subject.clear_breadcrumbs() assert len(props.breadcrumbs) == 0 @@ -50,7 +50,7 @@ class TestClearBreadcrumbs(NewFile): class TestClearDocumentTree(NewFile): def test_run(self): - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() new = props.documents.add() subject.clear_document_tree() assert len(props.documents) == 0 @@ -58,23 +58,26 @@ class TestClearDocumentTree(NewFile): class TestDisableEditingDocument(NewFile): def test_run(self): - bpy.context.scene.BIMDocumentProperties.active_document_id = 1 + props = tool.Document.get_document_props() + props.active_document_id = 1 subject.disable_editing_document() - assert bpy.context.scene.BIMDocumentProperties.active_document_id == 0 + assert props.active_document_id == 0 class TestDisableEditingUI(NewFile): def test_run(self): - bpy.context.scene.BIMDocumentProperties.is_editing = True + props = tool.Document.get_document_props() + props.is_editing = True subject.disable_editing_ui() - assert bpy.context.scene.BIMDocumentProperties.is_editing == False + assert props.is_editing == False class TestEnableEditingUI(NewFile): def test_run(self): - bpy.context.scene.BIMDocumentProperties.is_editing = False + props = tool.Document.get_document_props() + props.is_editing = False subject.enable_editing_ui() - assert bpy.context.scene.BIMDocumentProperties.is_editing == True + assert props.is_editing == True class TestExportDocumentAttributes(NewFile): @@ -129,22 +132,22 @@ class TestImportDocumentAttributes(NewFile): document.Confidentiality = "CONFIDENTIAL" document.Status = "DRAFT" subject().import_document_attributes(document) - props = bpy.context.scene.BIMDocumentProperties - assert props.document_attributes.get("Identification").string_value == "Identification" - assert props.document_attributes.get("Name").string_value == "Name" - assert props.document_attributes.get("Description").string_value == "Description" - assert props.document_attributes.get("Location").string_value == "Location" - assert props.document_attributes.get("Purpose").string_value == "Purpose" - assert props.document_attributes.get("IntendedUse").string_value == "IntendedUse" - assert props.document_attributes.get("Scope").string_value == "Scope" - assert props.document_attributes.get("Revision").string_value == "Revision" - assert props.document_attributes.get("CreationTime").string_value == "CreationTime" - assert props.document_attributes.get("LastRevisionTime").string_value == "LastRevisionTime" - assert props.document_attributes.get("ElectronicFormat").string_value == "ElectronicFormat" - assert props.document_attributes.get("ValidFrom").string_value == "ValidFrom" - assert props.document_attributes.get("ValidUntil").string_value == "ValidUntil" - assert props.document_attributes.get("Confidentiality").enum_value == "CONFIDENTIAL" - assert props.document_attributes.get("Status").enum_value == "DRAFT" + props = tool.Document.get_document_props() + assert props.document_attributes["Identification"].string_value == "Identification" + assert props.document_attributes["Name"].string_value == "Name" + assert props.document_attributes["Description"].string_value == "Description" + assert props.document_attributes["Location"].string_value == "Location" + assert props.document_attributes["Purpose"].string_value == "Purpose" + assert props.document_attributes["IntendedUse"].string_value == "IntendedUse" + assert props.document_attributes["Scope"].string_value == "Scope" + assert props.document_attributes["Revision"].string_value == "Revision" + assert props.document_attributes["CreationTime"].string_value == "CreationTime" + assert props.document_attributes["LastRevisionTime"].string_value == "LastRevisionTime" + assert props.document_attributes["ElectronicFormat"].string_value == "ElectronicFormat" + assert props.document_attributes["ValidFrom"].string_value == "ValidFrom" + assert props.document_attributes["ValidUntil"].string_value == "ValidUntil" + assert props.document_attributes["Confidentiality"].enum_value == "CONFIDENTIAL" + assert props.document_attributes["Status"].enum_value == "DRAFT" def test_importing_reference(self): ifc = ifcopenshell.file() @@ -155,11 +158,11 @@ class TestImportDocumentAttributes(NewFile): document.Name = "Name" document.Description = "Description" subject().import_document_attributes(document) - props = bpy.context.scene.BIMDocumentProperties - assert props.document_attributes.get("Location").string_value == "Location" - assert props.document_attributes.get("Identification").string_value == "Identification" - assert props.document_attributes.get("Name").string_value == "Name" - assert props.document_attributes.get("Description").string_value == "Description" + props = tool.Document.get_document_props() + assert props.document_attributes["Location"].string_value == "Location" + assert props.document_attributes["Identification"].string_value == "Identification" + assert props.document_attributes["Name"].string_value == "Name" + assert props.document_attributes["Description"].string_value == "Description" class TestImportProjectDocuments(NewFile): @@ -169,7 +172,7 @@ class TestImportProjectDocuments(NewFile): ifc.createIfcProject() document = ifcopenshell.api.run("document.add_information", ifc) subject.import_project_documents() - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() assert len(props.documents) == 1 assert props.documents[0].ifc_definition_id == document.id() assert props.documents[0].name == "Unnamed" @@ -185,7 +188,7 @@ class TestImportReferences(NewFile): document = ifcopenshell.api.run("document.add_information", ifc) reference = ifcopenshell.api.run("document.add_reference", ifc, information=document) subject.import_references(document) - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() assert len(props.documents) == 1 assert props.documents[0].ifc_definition_id == reference.id() assert props.documents[0].name == "Unnamed" @@ -201,7 +204,7 @@ class TestImportSubdocuments(NewFile): document = ifcopenshell.api.run("document.add_information", ifc) subdocument = ifcopenshell.api.run("document.add_information", ifc, parent=document) subject.import_subdocuments(document) - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() assert len(props.documents) == 1 assert props.documents[0].ifc_definition_id == subdocument.id() assert props.documents[0].name == "Unnamed" @@ -220,7 +223,7 @@ class TestIsDocumentInformation(NewFile): class TestRemoveLatestBreadcrumb(NewFile): def test_run(self): - props = bpy.context.scene.BIMDocumentProperties + props = tool.Document.get_document_props() props.breadcrumbs.add() props.breadcrumbs.add() subject.remove_latest_breadcrumb() @@ -232,4 +235,5 @@ class TestSetActiveDocument(NewFile): ifc = ifcopenshell.file() document = ifc.createIfcDocumentInformation() subject.set_active_document(document) - assert bpy.context.scene.BIMDocumentProperties.active_document_id == document.id() + props = tool.Document.get_document_props() + assert props.active_document_id == document.id() diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 5e3c239e03..6c4c338b3d 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -27,7 +27,6 @@ import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile from bonsai.tool.drawing import Drawing as subject -from bonsai.bim.ifc import IfcStore from bonsai.bim.module.drawing.data import DecoratorData from mathutils import Vector @@ -113,30 +112,34 @@ class TestDeleteDrawingElements(NewFile): class TestDisableEditingDrawings(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_drawings = True + props = tool.Drawing.get_document_props() + props.is_editing_drawings = True subject.disable_editing_drawings() - assert bpy.context.scene.DocProperties.is_editing_drawings == False + assert props.is_editing_drawings == False class TestDisableEditingSchedules(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_schedules = True + props = tool.Drawing.get_document_props() + props.is_editing_schedules = True subject.disable_editing_schedules() - assert bpy.context.scene.DocProperties.is_editing_schedules == False + assert props.is_editing_schedules == False class TestDisableEditingReferences(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_references = True + props = tool.Drawing.get_document_props() + props.is_editing_references = True subject.disable_editing_references() - assert bpy.context.scene.DocProperties.is_editing_references == False + assert props.is_editing_references == False class TestDisableEditingSheets(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_sheets = True + props = tool.Drawing.get_document_props() + props.is_editing_sheets = True subject.disable_editing_sheets() - assert bpy.context.scene.DocProperties.is_editing_sheets == False + assert props.is_editing_sheets == False class TestDisableEditingText(NewFile): @@ -166,30 +169,34 @@ class TestEnableEditing(NewFile): class TestEnableEditingDrawings(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_drawings = False + props = tool.Drawing.get_document_props() + props.is_editing_drawings = False subject.enable_editing_drawings() - assert bpy.context.scene.DocProperties.is_editing_drawings == True + assert props.is_editing_drawings == True class TestEnableEditingSchedules(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_schedules = False + props = tool.Drawing.get_document_props() + props.is_editing_schedules = False subject.enable_editing_schedules() - assert bpy.context.scene.DocProperties.is_editing_schedules == True + assert props.is_editing_schedules == True class TestEnableEditingReferences(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_references = False + props = tool.Drawing.get_document_props() + props.is_editing_references = False subject.enable_editing_references() - assert bpy.context.scene.DocProperties.is_editing_references == True + assert props.is_editing_references == True class TestEnableEditingSheets(NewFile): def test_run(self): - bpy.context.scene.DocProperties.is_editing_sheets = False + props = tool.Drawing.get_document_props() + props.is_editing_sheets = False subject.enable_editing_sheets() - assert bpy.context.scene.DocProperties.is_editing_sheets == True + assert props.is_editing_sheets == True class TestEnableEditingText(NewFile): @@ -312,7 +319,8 @@ class TestGetDrawingCollection(NewFile): collection = bpy.data.collections.new("Collection") bpy.context.scene.collection.children.link(collection) collection.objects.link(obj) - obj.BIMObjectProperties.collection = collection + props = tool.Blender.get_object_bim_props(obj) + props.collection = collection collection.BIMCollectionProperties.obj = obj element = ifc.createIfcAnnotation() @@ -492,7 +500,7 @@ class TestImportDrawings(NewFile): pset = ifcopenshell.api.run("pset.add_pset", ifc, product=drawing, name="EPset_Drawing") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"TargetView": "PLAN_VIEW"}) subject.import_drawings() - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() for d in props.drawings: d.is_expanded = True subject.import_drawings() @@ -508,7 +516,7 @@ class TestImportSchedules(NewFile): ifc.createIfcDocumentInformation(Identification="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(Identification="X", Name="FOOBAR", Scope="SCHEDULE") subject.import_documents("SCHEDULE") - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.schedules[0].ifc_definition_id == document.id() assert props.schedules[0].identification == "X" assert props.schedules[0].name == "FOOBAR" @@ -519,7 +527,7 @@ class TestImportSchedules(NewFile): ifc.createIfcDocumentInformation(DocumentId="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(DocumentId="X", Name="FOOBAR", Scope="SCHEDULE") subject.import_documents("SCHEDULE") - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.schedules[0].ifc_definition_id == document.id() assert props.schedules[0].identification == "X" assert props.schedules[0].name == "FOOBAR" @@ -532,7 +540,7 @@ class TestImportReferences(NewFile): ifc.createIfcDocumentInformation(Identification="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(Identification="X", Name="FOOBAR", Scope="REFERENCE") subject.import_documents("REFERENCE") - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.references[0].ifc_definition_id == document.id() assert props.references[0].identification == "X" assert props.references[0].name == "FOOBAR" @@ -543,7 +551,7 @@ class TestImportReferences(NewFile): ifc.createIfcDocumentInformation(DocumentId="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(DocumentId="X", Name="FOOBAR", Scope="REFERENCE") subject.import_documents("REFERENCE") - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.references[0].ifc_definition_id == document.id() assert props.references[0].identification == "X" assert props.references[0].name == "FOOBAR" @@ -556,7 +564,7 @@ class TestImportSheets(NewFile): ifc.createIfcDocumentInformation(Identification="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(Identification="X", Name="FOOBAR", Scope="SHEET") subject.import_sheets() - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.sheets[0].ifc_definition_id == document.id() assert props.sheets[0].identification == "X" assert props.sheets[0].name == "FOOBAR" @@ -567,7 +575,7 @@ class TestImportSheets(NewFile): ifc.createIfcDocumentInformation(DocumentId="Y", Name="FOOBAZ") document = ifc.createIfcDocumentInformation(DocumentId="X", Name="FOOBAR", Scope="SHEET") subject.import_sheets() - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() assert props.sheets[0].ifc_definition_id == document.id() assert props.sheets[0].identification == "X" assert props.sheets[0].name == "FOOBAR" @@ -657,9 +665,10 @@ class TestSetName(NewFile): class TestShowDecorations(NewFile): def test_run(self): - bpy.context.scene.DocProperties.should_draw_decorations = False + props = tool.Drawing.get_document_props() + props.should_draw_decorations = False subject.show_decorations() - assert bpy.context.scene.DocProperties.should_draw_decorations is True + assert props.should_draw_decorations is True class TestDrawingMaintainingSheetPosition(NewFile): @@ -680,7 +689,7 @@ class TestDrawingMaintainingSheetPosition(NewFile): return drawing_data def test_run(self): - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() bpy.ops.bim.create_project() ifc = tool.Ifc.get() sheet_path = Path.cwd() / "layouts" / "A00 - UNTITLED.svg" @@ -845,10 +854,11 @@ class TestDrawingStyles(NewFile): ifc = tool.Ifc.get() drawing = ifc.by_type("IfcAnnotation")[0] bpy.ops.bim.expand_target_view(target_view="PLAN_VIEW") - props = bpy.context.scene.DocProperties + props = tool.Drawing.get_document_props() props.active_drawing_index = 2 bpy.ops.bim.activate_drawing(drawing=drawing.id()) - self.drawing_styles = bpy.context.scene.DocProperties.drawing_styles + props = tool.Drawing.get_document_props() + self.drawing_styles = props.drawing_styles def test_drawing_styles_not_loaded_if_underlay_is_inactive(self): self.setup_project_with_drawing() @@ -867,7 +877,8 @@ class TestDrawingStyles(NewFile): class TestAddReferenceImage(NewFile): def test_run(self): - bpy.context.scene.BIMProjectProperties.template_file = "0" + props = tool.Project.get_project_props() + props.template_file = "0" bpy.ops.bim.create_project() ifc_path = Path("test/files/temp/test.ifc").absolute() bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True) @@ -880,11 +891,12 @@ class TestAddReferenceImage(NewFile): assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0))) material = obj.active_material + assert material assert material.name == "image" - assert material.BIMStyleProperties.ifc_definition_id != 0 + assert tool.Blender.get_ifc_definition_id(material) != 0 - ifc_file = tool.Ifc.get() - style = ifc_file.by_id(material.BIMStyleProperties.ifc_definition_id) + style = tool.Ifc.get_entity(material) + assert style styled_items = set(tool.Style.get_styled_items(style)) representation_items = set(tool.Geometry.get_active_representation(obj).Items) assert styled_items == representation_items diff --git a/src/bonsai/test/tool/test_geometry.py b/src/bonsai/test/tool/test_geometry.py index f1ca935f30..85a0a4c79d 100644 --- a/src/bonsai/test/tool/test_geometry.py +++ b/src/bonsai/test/tool/test_geometry.py @@ -112,7 +112,8 @@ class TestGetObjectMaterialsWithoutStyles(NewFile): material1 = bpy.data.materials.new("Material") material2 = bpy.data.materials.new("Material") material3 = bpy.data.materials.new("Material") - material3.BIMStyleProperties.ifc_definition_id = 1 + props = tool.Style.get_material_style_props(material3) + props.ifc_definition_id = 1 obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh")) obj.data.materials.append(material1) obj.data.materials.append(material2) @@ -170,23 +171,26 @@ class TestGetTextLiteral(NewFile): class TestGetCartesianPointCoordinateOffset(NewFile): def test_run(self): obj = bpy.data.objects.new("Object", None) - obj.BIMObjectProperties.blender_offset_type = "CARTESIAN_POINT" - props = bpy.context.scene.BIMGeoreferenceProperties + oprops = tool.Blender.get_object_bim_props(obj) + oprops.blender_offset_type = "CARTESIAN_POINT" + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True - obj.BIMObjectProperties.cartesian_point_offset = "1,2,3" + oprops.cartesian_point_offset = "1,2,3" assert np.allclose(subject.get_cartesian_point_offset(obj), np.array((1.0, 2.0, 3.0))) def test_get_null_if_not_a_cartesian_point_offset_type(self): obj = bpy.data.objects.new("Object", None) - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True - obj.BIMObjectProperties.cartesian_point_offset = "1,2,3" + oprops = tool.Blender.get_object_bim_props(obj) + oprops.cartesian_point_offset = "1,2,3" assert subject.get_cartesian_point_offset(obj) is None def test_get_null_if_no_blender_offset(self): obj = bpy.data.objects.new("Object", None) - obj.BIMObjectProperties.blender_offset_type = "CARTESIAN_POINT" - props = bpy.context.scene.BIMGeoreferenceProperties + oprops = tool.Blender.get_object_bim_props(obj) + oprops.blender_offset_type = "CARTESIAN_POINT" + props = tool.Georeference.get_georeference_props() props.has_blender_offset = False assert subject.get_cartesian_point_offset(obj) is None @@ -237,14 +241,15 @@ class TestImportRepresentationParameters(NewFile): item = ifc.createIfcExtrudedAreaSolid(SweptArea=swept_area, Depth=2) representation = ifc.createIfcShapeRepresentation(Items=[item]) data = bpy.data.meshes.new("Mesh") - data.BIMMeshProperties.ifc_definition_id = representation.id() + mprops = tool.Geometry.get_mesh_props(data) + mprops.ifc_definition_id = representation.id() subject.import_representation_parameters(data) - assert data.BIMMeshProperties.ifc_parameters[0].name == "IfcExtrudedAreaSolid/Depth" - assert data.BIMMeshProperties.ifc_parameters[0].step_id == item.id() - assert data.BIMMeshProperties.ifc_parameters[0].index == 3 - assert data.BIMMeshProperties.ifc_parameters[1].name == "IfcCircleProfileDef/Radius" - assert data.BIMMeshProperties.ifc_parameters[1].step_id == swept_area.id() - assert data.BIMMeshProperties.ifc_parameters[1].index == 3 + assert mprops.ifc_parameters[0].name == "IfcExtrudedAreaSolid/Depth" + assert mprops.ifc_parameters[0].step_id == item.id() + assert mprops.ifc_parameters[0].index == 3 + assert mprops.ifc_parameters[1].name == "IfcCircleProfileDef/Radius" + assert mprops.ifc_parameters[1].step_id == swept_area.id() + assert mprops.ifc_parameters[1].index == 3 class TestIsBodyRepresentation(NewFile): @@ -293,7 +298,7 @@ class TestLink(NewFile): element = ifc.createIfcShapeRepresentation() obj = bpy.data.meshes.new("Mesh") subject.link(element, obj) - assert obj.BIMMeshProperties.ifc_definition_id == element.id() + assert tool.Geometry.get_mesh_props(obj).ifc_definition_id == element.id() class TestRecordObjectMaterials(NewFile): @@ -303,18 +308,19 @@ class TestRecordObjectMaterials(NewFile): tool.Ifc.set(ifc) style = ifc.createIfcSurfaceStyle() material = bpy.data.materials.new("Material") - material.BIMStyleProperties.ifc_definition_id = style.id() + tool.Ifc.link(style, material) obj.data.materials.append(material) subject.record_object_materials(obj) - assert obj.data.BIMMeshProperties.material_checksum == str([style.id()]) + assert tool.Geometry.get_mesh_props(obj.data).material_checksum == str([style.id()]) class TestRecordObjectPosition(NewFile): def test_run(self): obj = bpy.data.objects.new("Object", None) + props = tool.Blender.get_object_bim_props(obj) subject.record_object_position(obj) - assert obj.BIMObjectProperties.location_checksum == repr(np.array(obj.matrix_world.translation).tobytes()) - assert obj.BIMObjectProperties.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3()).tobytes()) + assert props.location_checksum == repr(np.array(obj.matrix_world.translation).tobytes()) + assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3()).tobytes()) class TestRemoveConnection(NewFile): diff --git a/src/bonsai/test/tool/test_georeference.py b/src/bonsai/test/tool/test_georeference.py index 4b22a84781..96e498482c 100644 --- a/src/bonsai/test/tool/test_georeference.py +++ b/src/bonsai/test/tool/test_georeference.py @@ -39,7 +39,7 @@ class TestImportProjectedCRS(NewFile): ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") ifcopenshell.api.run("context.add_context", ifc, context_type="Model") subject.import_projected_crs() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert len(props.projected_crs) == 0 def test_importing_projected_crs(self): @@ -58,7 +58,7 @@ class TestImportProjectedCRS(NewFile): unit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT") projected_crs.MapUnit = unit subject.import_projected_crs() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert props.projected_crs.get("Name").string_value == "Name" assert props.projected_crs.get("Description").string_value == "Description" assert props.projected_crs.get("GeodeticDatum").string_value == "GeodeticDatum" @@ -71,7 +71,7 @@ class TestImportProjectedCRS(NewFile): ifc = ifcopenshell.file(schema="IFC2X3") tool.Ifc.set(ifc) subject.import_projected_crs() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert len(props.projected_crs) == 0 @@ -82,7 +82,7 @@ class TestImportCoordinateOperation(NewFile): ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") ifcopenshell.api.run("context.add_context", ifc, context_type="Model") subject.import_coordinate_operation() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert len(props.coordinate_operation) == 0 def test_importing_coordinate_operation(self): @@ -99,7 +99,7 @@ class TestImportCoordinateOperation(NewFile): map_conversion.XAxisOrdinate = 5 map_conversion.Scale = 6 subject.import_coordinate_operation() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert props.coordinate_operation.get("Eastings").string_value == "1.0" assert props.coordinate_operation.get("Northings").string_value == "2.0" assert props.coordinate_operation.get("OrthogonalHeight").string_value == "3.0" @@ -112,7 +112,7 @@ class TestImportCoordinateOperation(NewFile): ifc = ifcopenshell.file(schema="IFC2X3") tool.Ifc.set(ifc) subject.import_coordinate_operation() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert len(props.coordinate_operation) == 0 @@ -123,7 +123,7 @@ class TestImportTrueNorth(NewFile): ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") ifcopenshell.api.run("context.add_context", ifc, context_type="Model") subject.import_true_north() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert props.true_north_abscissa == "0" assert props.true_north_ordinate == "1" assert props.true_north_angle == "0" @@ -135,7 +135,7 @@ class TestImportTrueNorth(NewFile): context = ifcopenshell.api.run("context.add_context", ifc, context_type="Model") context.TrueNorth = ifc.createIfcDirection((1.0, 2.0, 0.0)) subject.import_true_north() - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() assert props.true_north_abscissa == "1.0" assert props.true_north_ordinate == "2.0" assert props.true_north_angle == "-26.5650512" @@ -176,35 +176,39 @@ class TestGetTrueNorthAttributes(NewFile): class TestEnableEditing(NewFile): def test_run(self): - bpy.context.scene.BIMGeoreferenceProperties.is_editing = False + props = tool.Georeference.get_georeference_props() + props.is_editing = False subject.enable_editing() - assert bpy.context.scene.BIMGeoreferenceProperties.is_editing is True + assert props.is_editing is True class TestDisableEditing(NewFile): def test_run(self): - bpy.context.scene.BIMGeoreferenceProperties.is_editing = True + props = tool.Georeference.get_georeference_props() + props.is_editing = True subject.disable_editing() - assert bpy.context.scene.BIMGeoreferenceProperties.is_editing is False + assert props.is_editing is False class TestSetCoordinates(NewFile): def test_run(self): + props = tool.Georeference.get_georeference_props() subject.set_coordinates("local", [1.0, 2.0, 3.0]) - assert bpy.context.scene.BIMGeoreferenceProperties.local_coordinates == "1.0,2.0,3.0" + assert props.local_coordinates == "1.0,2.0,3.0" subject.set_coordinates("blender", [4.0, 5.0, 6.0]) - assert bpy.context.scene.BIMGeoreferenceProperties.blender_coordinates == "4.0,5.0,6.0" + assert props.blender_coordinates == "4.0,5.0,6.0" subject.set_coordinates("map", [7.0, 8.0, 9.0]) - assert bpy.context.scene.BIMGeoreferenceProperties.map_coordinates == "7.0,8.0,9.0" + assert props.map_coordinates == "7.0,8.0,9.0" class TestGetCoordinates(NewFile): def test_run(self): - bpy.context.scene.BIMGeoreferenceProperties.local_coordinates = "1.0,2.0,3.0" + props = tool.Georeference.get_georeference_props() + props.local_coordinates = "1.0,2.0,3.0" assert subject.get_coordinates("local") == [1.0, 2.0, 3.0] - bpy.context.scene.BIMGeoreferenceProperties.blender_coordinates = "4.0,5.0,6.0" + props.blender_coordinates = "4.0,5.0,6.0" assert subject.get_coordinates("blender") == [4.0, 5.0, 6.0] - bpy.context.scene.BIMGeoreferenceProperties.map_coordinates = "7.0,8.0,9.0" + props.map_coordinates = "7.0,8.0,9.0" assert subject.get_coordinates("map") == [7.0, 8.0, 9.0] @@ -231,7 +235,7 @@ class TestXyz2Enh(NewFile): ifc = ifcopenshell.file() ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") tool.Ifc.set(ifc) - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True props.blender_offset_x = "1.0" assert subject.xyz2enh([0.0, 0.0, 0.0]) == (1.0, 0.0, 0.0) @@ -247,7 +251,7 @@ class TestXyz2Enh(NewFile): assert subject.xyz2enh([0.0, 0.0, 0.0]) == (1.0, 0.0, 0.0) def test_applying_both_blender_offset_and_map_conversion(self): - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True props.blender_offset_x = "1.0" ifc = ifcopenshell.file() @@ -271,7 +275,7 @@ class TestEnh2Xyz(NewFile): ifc = ifcopenshell.file() ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") tool.Ifc.set(ifc) - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True props.blender_offset_x = "1.0" assert subject.enh2xyz([0.0, 0.0, 0.0]) == (-1.0, 0.0, 0.0) @@ -287,7 +291,7 @@ class TestEnh2Xyz(NewFile): assert subject.enh2xyz([0.0, 0.0, 0.0]) == (-1.0, 0.0, 0.0) def test_applying_both_blender_offset_and_map_conversion(self): - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True props.blender_offset_x = "1.0" ifc = ifcopenshell.file() diff --git a/src/bonsai/test/tool/test_ifc.py b/src/bonsai/test/tool/test_ifc.py index 429dd76212..c12af2a23f 100644 --- a/src/bonsai/test/tool/test_ifc.py +++ b/src/bonsai/test/tool/test_ifc.py @@ -40,12 +40,14 @@ class TestSet(test.bim.bootstrap.NewFile): class TestGet(test.bim.bootstrap.NewFile): def test_getting_an_ifc_dataset_from_a_ifc_spf_filepath(self): assert subject.get() is None - bpy.context.scene.BIMProperties.ifc_file = "test/files/basic.ifc" + props = tool.Blender.get_bim_props() + props.ifc_file = "test/files/basic.ifc" result = subject.get() assert isinstance(result, ifcopenshell.file) def test_getting_the_active_ifc_dataset_regardless_of_ifc_path(self): - bpy.context.scene.BIMProperties.ifc_file = "test/files/basic.ifc" + props = tool.Blender.get_bim_props() + props.ifc_file = "test/files/basic.ifc" ifc = ifcopenshell.file() subject.set(ifc) assert subject.get() == ifc @@ -127,14 +129,16 @@ class TestGetEntity(test.bim.bootstrap.NewFile): def test_attempting_without_a_file(self): obj = bpy.data.objects.new("Object", None) - obj.BIMObjectProperties.ifc_definition_id = 1 + props = tool.Blender.get_object_bim_props(obj) + props.ifc_definition_id = 1 assert subject.get_entity(obj) is None def test_attempting_to_get_an_invalidly_linked_object(self): ifc = ifcopenshell.file() subject.set(ifc) obj = bpy.data.objects.new("Object", None) - obj.BIMObjectProperties.ifc_definition_id = 1 + props = tool.Blender.get_object_bim_props(obj) + props.ifc_definition_id = 1 assert subject.get_entity(obj) is None @@ -189,7 +193,7 @@ class TestLink(test.bim.bootstrap.NewFile): element = ifc.create_entity("IfcShapeRepresentation") obj = bpy.data.meshes.new("Material") subject.link(element, obj) - assert obj.BIMMeshProperties.ifc_definition_id == element.id() + assert tool.Geometry.get_mesh_props(obj).ifc_definition_id == element.id() class TestUnlink(test.bim.bootstrap.NewFile): diff --git a/src/bonsai/test/tool/test_library.py b/src/bonsai/test/tool/test_library.py index 7ce2308d69..12209824ea 100644 --- a/src/bonsai/test/tool/test_library.py +++ b/src/bonsai/test/tool/test_library.py @@ -31,7 +31,7 @@ class TestImplementsTool(NewFile): class TestClearEditingMode(NewFile): def test_run(self): - props = bpy.context.scene.BIMLibraryProperties + props = tool.Library.get_library_props() props.editing_mode = "LIBRARY" subject.clear_editing_mode() assert props.editing_mode == "NONE" @@ -78,7 +78,7 @@ class TestImportLibraryAttributes(NewFile): tool.Ifc.set(ifc := ifcopenshell.file()) library = ifc.createIfcLibraryInformation("Name", "Version", None, "VersionDate", "Location", "Description") subject.import_library_attributes(library) - props = bpy.context.scene.BIMLibraryProperties + props = tool.Library.get_library_props() assert props.library_attributes.get("Name").string_value == "Name" assert props.library_attributes.get("Version").string_value == "Version" assert props.library_attributes.get("VersionDate").string_value == "VersionDate" @@ -91,7 +91,7 @@ class TestImportReferenceAttributes(NewFile): tool.Ifc.set(ifc := ifcopenshell.file()) reference = ifc.createIfcLibraryReference("Location", "Identification", "Name", "Description", "Language") subject.import_reference_attributes(reference) - props = bpy.context.scene.BIMLibraryProperties + props = tool.Library.get_library_props() assert props.reference_attributes.get("Location").string_value == "Location" assert props.reference_attributes.get("Identification").string_value == "Identification" assert props.reference_attributes.get("Name").string_value == "Name" @@ -105,7 +105,7 @@ class TestImportReferences(NewFile): library = ifc.createIfcLibraryInformation() reference = ifc.createIfcLibraryReference(Name="Reference", ReferencedLibrary=library) subject.import_references(library) - props = bpy.context.scene.BIMLibraryProperties + props = tool.Library.get_library_props() assert props.references[0].ifc_definition_id == reference.id() assert props.references[0].name == "Reference" @@ -116,7 +116,8 @@ class TestSetActiveLibrary(NewFile): tool.Ifc.set(ifc) library = ifc.createIfcLibraryInformation() subject.set_active_library(library) - assert bpy.context.scene.BIMLibraryProperties.active_library_id == library.id() + props = tool.Library.get_library_props() + assert props.active_library_id == library.id() class TestSetActiveReference(NewFile): @@ -125,10 +126,12 @@ class TestSetActiveReference(NewFile): tool.Ifc.set(ifc) reference = ifc.createIfcLibraryReference() subject.set_active_reference(reference) - assert bpy.context.scene.BIMLibraryProperties.active_reference_id == reference.id() + props = tool.Library.get_library_props() + assert props.active_reference_id == reference.id() class TestSetEditingMode(NewFile): def test_run(self): subject.set_editing_mode("LIBRARY") - assert bpy.context.scene.BIMLibraryProperties.editing_mode == "LIBRARY" + props = tool.Library.get_library_props() + assert props.editing_mode == "LIBRARY" diff --git a/src/bonsai/test/tool/test_loader.py b/src/bonsai/test/tool/test_loader.py index c8235ed52b..abb3473245 100644 --- a/src/bonsai/test/tool/test_loader.py +++ b/src/bonsai/test/tool/test_loader.py @@ -520,7 +520,8 @@ class TestLoadingIndexedMap(NewFile): class TestSetupActiveBsddClassification(NewFile): def run_test(self, schema: ifcopenshell.util.schema.IFC_SCHEMA) -> None: schema_ = "IFC4X3_ADD2" if schema == "IFC4X3" else schema - bpy.context.scene.BIMProjectProperties.export_schema = schema_ + props = tool.Project.get_project_props() + props.export_schema = schema_ bpy.ops.bim.create_project() ifc_file = tool.Ifc.get() name = "CCI Construction" @@ -548,3 +549,51 @@ class TestSetupActiveBsddClassification(NewFile): def test_set_load_and_set_active_bsdd_ifc4x3(self): self.run_test("IFC4X3") + + +class TestCreatePointCloudMesh(NewFile): + def test_cartesian_point_list_3d(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + coords = ((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)) + item = ifc_file.createIfcCartesianPointList3D(coords) + rep = ifc_file.createIfcShapeRepresentation(Items=[item]) + mesh = subject.create_point_cloud_mesh(rep) + assert len(mesh.vertices) == 2 + verts = np.array([v.co for v in mesh.vertices]) + assert np.allclose(verts, np.array([np.array(c) for c in coords])) + + def test_cartesian_point_list_2d(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + coords = ((1.0, 2.0), (4.0, 5.0)) + coords3d = ((1.0, 2.0, 0.0), (4.0, 5.0, 0.0)) + item = ifc_file.createIfcCartesianPointList2D(coords) + rep = ifc_file.createIfcShapeRepresentation(Items=[item]) + mesh = subject.create_point_cloud_mesh(rep) + assert len(mesh.vertices) == 2 + verts = np.array([v.co for v in mesh.vertices]) + assert np.allclose(verts, np.array([np.array(c) for c in coords3d])) + + def test_point_3d(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + coords = ((1.0, 2.0, 0.0),) + item = ifc_file.createIfcCartesianPoint(Coordinates=coords[0]) + rep = ifc_file.createIfcShapeRepresentation(Items=[item]) + mesh = subject.create_point_cloud_mesh(rep) + assert len(mesh.vertices) == 1 + verts = np.array([v.co for v in mesh.vertices]) + assert np.allclose(verts, np.array([np.array(c) for c in coords])) + + def test_point_2d(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + coords = ((1.0, 2.0),) + coords3d = ((1.0, 2.0, 0.0),) + item = ifc_file.createIfcCartesianPoint(Coordinates=coords[0]) + rep = ifc_file.createIfcShapeRepresentation(Items=[item]) + mesh = subject.create_point_cloud_mesh(rep) + assert len(mesh.vertices) == 1 + verts = np.array([v.co for v in mesh.vertices]) + assert np.allclose(verts, np.array([np.array(c) for c in coords3d])) diff --git a/src/bonsai/test/tool/test_material.py b/src/bonsai/test/tool/test_material.py index 281c8918ac..c980135b5a 100644 --- a/src/bonsai/test/tool/test_material.py +++ b/src/bonsai/test/tool/test_material.py @@ -35,25 +35,28 @@ class TestImplementsTool(NewFile): class TestDisableEditingMaterials(NewFile): def test_run(self): - bpy.context.scene.BIMMaterialProperties.is_editing = True + props = tool.Material.get_material_props() + props.is_editing = True subject.disable_editing_materials() - assert bpy.context.scene.BIMMaterialProperties.is_editing is False + assert props.is_editing is False class TestEnableEditingMaterials(NewFile): def test_run(self): - bpy.context.scene.BIMMaterialProperties.is_editing = False + props = tool.Material.get_material_props() + props.is_editing = False subject.enable_editing_materials() - assert bpy.context.scene.BIMMaterialProperties.is_editing is True + assert props.is_editing is True class TestGetActiveMaterialType(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc.set(ifc) - bpy.context.scene.BIMMaterialProperties.material_type = "IfcMaterial" + props = tool.Material.get_material_props() + props.material_type = "IfcMaterial" assert subject.get_active_material_type() == "IfcMaterial" - bpy.context.scene.BIMMaterialProperties.material_type = "IfcMaterialLayerSet" + props.material_type = "IfcMaterialLayerSet" assert subject.get_active_material_type() == "IfcMaterialLayerSet" @@ -73,7 +76,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterial(Name="Name", Category="Category") subject.import_material_definitions("IfcMaterial") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() assert props.materials[0].ifc_definition_id == 0 assert props.materials[0].name == "Category" assert props.materials[0].is_category is True @@ -85,7 +88,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterial(Name="Name", Category="Category") subject.import_material_definitions("IfcMaterial") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() props.materials[0].is_expanded = True subject.import_material_definitions("IfcMaterial") assert len(props.materials) == 2 @@ -98,7 +101,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterialLayerSet(LayerSetName="Name") subject.import_material_definitions("IfcMaterialLayerSet") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" assert props.materials[0].total_elements == 0 @@ -108,7 +111,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterialProfileSet(Name="Name") subject.import_material_definitions("IfcMaterialProfileSet") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" assert props.materials[0].total_elements == 0 @@ -118,7 +121,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterialConstituentSet(Name="Name") subject.import_material_definitions("IfcMaterialConstituentSet") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" assert props.materials[0].total_elements == 0 @@ -128,7 +131,7 @@ class TestImportMaterialDefinitions(NewFile): tool.Ifc.set(ifc) material = ifc.createIfcMaterialList() subject.import_material_definitions("IfcMaterialList") - props = bpy.context.scene.BIMMaterialProperties + props = tool.Material.get_material_props() assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Unnamed" assert props.materials[0].total_elements == 0 @@ -136,9 +139,10 @@ class TestImportMaterialDefinitions(NewFile): class TestIsEditingMaterials(NewFile): def test_run(self): - bpy.context.scene.BIMMaterialProperties.is_editing = False + props = tool.Material.get_material_props() + props.is_editing = False assert subject.is_editing_materials() is False - bpy.context.scene.BIMMaterialProperties.is_editing = True + props.is_editing = True assert subject.is_editing_materials() is True diff --git a/src/bonsai/test/tool/test_misc.py b/src/bonsai/test/tool/test_misc.py index a274c89386..78a3c34cc5 100644 --- a/src/bonsai/test/tool/test_misc.py +++ b/src/bonsai/test/tool/test_misc.py @@ -24,7 +24,6 @@ import test.bim.bootstrap import bonsai.core.tool import bonsai.tool as tool from bonsai.tool.misc import Misc as subject -from bonsai.bim.ifc import IfcStore class TestImplementsTool(test.bim.bootstrap.NewFile): diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 13a847f94a..f41a4d5988 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -378,27 +378,31 @@ class TestGenerateStair2DProfile(NewFile): class TestUsingArrays(NewFile): def setup_array(self, add_second_layer=False, sync_children=False): - bpy.context.scene.BIMProjectProperties.template_file = "0" + tool.Project.get_project_props().template_file = "0" bpy.ops.bim.create_project() bpy.ops.mesh.primitive_cube_add() obj = bpy.context.active_object - bpy.context.scene.BIMRootProperties.ifc_product = "IfcElement" + assert obj + rprops = tool.Root.get_root_props() + rprops.ifc_product = "IfcElement" bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="") bpy.ops.bim.add_array() bpy.ops.bim.enable_editing_array(item=0) - obj.BIMArrayProperties.count = 4 - obj.BIMArrayProperties.x = 4 - obj.BIMArrayProperties.sync_children = sync_children + props = tool.Model.get_array_props(obj) + props.count = 4 + props.x = 4 + props.sync_children = sync_children bpy.ops.bim.edit_array(item=0) if add_second_layer: bpy.ops.bim.add_array() bpy.ops.bim.enable_editing_array(item=1) - obj.BIMArrayProperties.count = 3 - obj.BIMArrayProperties.y = 4 - obj.BIMArrayProperties.sync_children = sync_children + props = tool.Model.get_array_props(obj) + props.count = 3 + props.y = 4 + props.sync_children = sync_children bpy.ops.bim.edit_array(item=1) def test_remove_array_last_to_first(self): @@ -458,7 +462,7 @@ class TestApplyIfcMaterialChanges(NewFile): def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]: ifc_file = tool.Ifc.get() return { - ifc_file.by_id(s.material.BIMStyleProperties.ifc_definition_id) for s in obj.material_slots if s.material + ifc_file.by_id(tool.Blender.get_ifc_definition_id(s.material)) for s in obj.material_slots if s.material } def get_mesh(self, obj: bpy.types.Object) -> bpy.types.Mesh: @@ -467,7 +471,8 @@ class TestApplyIfcMaterialChanges(NewFile): return mesh def setup_test(self, and_elements: bool = True) -> None: - bpy.context.scene.BIMProjectProperties.template_file = "0" + props = tool.Project.get_project_props() + props.template_file = "0" bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" bpy.ops.bim.create_project() ifc_file = tool.Ifc.get() @@ -479,20 +484,21 @@ class TestApplyIfcMaterialChanges(NewFile): red_material = ifcopenshell.api.material.add_material(ifc_file, "Red Material") bpy.ops.bim.load_styles(style_type="IfcSurfaceStyle") bpy.ops.bim.enable_adding_presentation_style() - bpy.data.scenes["Scene"].BIMStylesProperties.style_name = "Red" + sprops = tool.Style.get_style_props() + sprops.style_name = "Red" bpy.ops.bim.add_presentation_style() red_style = next((i for i in ifc_file.by_type("IfcSurfaceStyle") if i.Name == "Red")) ifcopenshell.api.style.assign_material_style(ifc_file, red_material, red_style, context) blue_material = ifcopenshell.api.material.add_material(ifc_file, "Blue Material") bpy.ops.bim.enable_adding_presentation_style() - bpy.data.scenes["Scene"].BIMStylesProperties.style_name = "Blue" + sprops.style_name = "Blue" bpy.ops.bim.add_presentation_style() blue_style = next((i for i in ifc_file.by_type("IfcSurfaceStyle") if i.Name == "Blue")) ifcopenshell.api.style.assign_material_style(ifc_file, blue_material, blue_style, context) bpy.ops.bim.enable_adding_presentation_style() - bpy.data.scenes["Scene"].BIMStylesProperties.style_name = "Green" + sprops.style_name = "Green" bpy.ops.bim.add_presentation_style() if and_elements: @@ -511,20 +517,20 @@ class TestApplyIfcMaterialChanges(NewFile): # Setup occurrences. relating_type_id = element_type.id() - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) simple = bpy.context.active_object simple.name = "Simple" # Occurrence with an opening. - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) with_opening = bpy.context.active_object with_opening.name = "With Opening" - props = bpy.context.scene.BIMRootProperties + props = tool.Root.get_root_props() props.representation_obj = with_opening bpy.ops.bim.add_element(ifc_product="IfcFeatureElement", ifc_class="IfcOpeningElement") # Occurrence with a material override. - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) with_material = bpy.context.active_object with_material.name = "With Material" tool.Blender.set_objects_selection(bpy.context, active_object=with_material, selected_objects=[with_material]) diff --git a/src/bonsai/test/tool/test_polyline.py b/src/bonsai/test/tool/test_polyline.py index b7d4303b0d..9dd1e8f41c 100644 --- a/src/bonsai/test/tool/test_polyline.py +++ b/src/bonsai/test/tool/test_polyline.py @@ -18,6 +18,9 @@ import bpy import ifcopenshell +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index 4af7494a85..a87b00f753 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -97,7 +97,8 @@ class TestSetContext(NewFile): tool.Ifc.set(ifc) context = ifc.createIfcGeometricRepresentationContext() subject.set_context(context) - assert bpy.context.scene.BIMRootProperties.contexts == str(context.id()) + rprops = tool.Root.get_root_props() + assert rprops.contexts == str(context.id()) class TestSetDefaultContext(NewFile): @@ -115,7 +116,8 @@ class TestSetDefaultContext(NewFile): target_view="MODEL_VIEW", ) subject.set_default_context() - assert bpy.context.scene.BIMRootProperties.contexts == str(body.id()) + rprops = tool.Root.get_root_props() + assert rprops.contexts == str(body.id()) class TestSetDefaultModelingDimensions(NewFile): @@ -244,25 +246,25 @@ class TestLoadProject(NewFile): class TestLoadLinkedModels(NewFile): def test_load_linked_models_no_document(self): - links = bpy.context.scene.BIMProjectProperties.links + props = tool.Project.get_project_props() ifc = ifcopenshell.file() tool.Ifc.set(ifc) subject.load_linked_models_from_ifc() - assert len(links) == 0 + assert len(props.links) == 0 def test_load_linked_models_document_no_references(self): ifc = ifcopenshell.file() - links = bpy.context.scene.BIMProjectProperties.links + props = tool.Project.get_project_props() ifcopenshell.api.root.create_entity(ifc, "IfcProject") document = ifcopenshell.api.document.add_information(ifc) document.Name = "BBIM_Linked_Models" tool.Ifc.set(ifc) subject.load_linked_models_from_ifc() - assert len(links) == 0 + assert len(props.links) == 0 def test_load_linked_models_document_with_references(self): ifc = ifcopenshell.file() - links = bpy.context.scene.BIMProjectProperties.links + props = tool.Project.get_project_props() ifcopenshell.api.root.create_entity(ifc, "IfcProject") document = ifcopenshell.api.document.add_information(ifc) document.Name = "BBIM_Linked_Models" @@ -271,8 +273,8 @@ class TestLoadLinkedModels(NewFile): reference.Location = linked_model_path tool.Ifc.set(ifc) subject.load_linked_models_from_ifc() - assert len(links) == 1 - assert links[0].name == linked_model_path + assert len(props.links) == 1 + assert props.links[0].name == linked_model_path class TestSaveLinkedModelsToIfc(NewFile): @@ -286,8 +288,8 @@ class TestSaveLinkedModelsToIfc(NewFile): def test_save_linked_models_to_ifc_paths_to_add(self): ifc = ifcopenshell.file() ifcopenshell.api.root.create_entity(ifc, "IfcProject") - links = bpy.context.scene.BIMProjectProperties.links - link = links.add() + props = tool.Project.get_project_props() + link = props.links.add() linked_model_path = "test.ifc" link.name = linked_model_path tool.Ifc.set(ifc) @@ -299,7 +301,7 @@ class TestSaveLinkedModelsToIfc(NewFile): def test_save_linked_models_to_ifc_already_created_references(self): ifc = ifcopenshell.file() - links = bpy.context.scene.BIMProjectProperties.links + links = tool.Project.get_project_props().links ifcopenshell.api.root.create_entity(ifc, "IfcProject") document = ifcopenshell.api.document.add_information(ifc) @@ -326,7 +328,7 @@ class TestSaveLinkedModelsToIfc(NewFile): def test_save_linked_models_to_ifc_references_to_remove(self): ifc = ifcopenshell.file() - links = bpy.context.scene.BIMProjectProperties.links + links = tool.Project.get_project_props().links ifcopenshell.api.root.create_entity(ifc, "IfcProject") document = ifcopenshell.api.document.add_information(ifc) diff --git a/src/bonsai/test/tool/test_qto.py b/src/bonsai/test/tool/test_qto.py index b9e3926f65..7ab26cd9d8 100644 --- a/src/bonsai/test/tool/test_qto.py +++ b/src/bonsai/test/tool/test_qto.py @@ -42,7 +42,8 @@ class TestGetRadiusOfSelectedVertices(test.bim.bootstrap.NewFile): class TestSetQtoResult(test.bim.bootstrap.NewFile): def test_run(self): subject.set_qto_result(123.4567) - assert bpy.context.scene.BIMQtoProperties.qto_result == "123.457" + props = tool.Qto.get_qto_props() + assert props.qto_result == "123.457" class TestGetRoundedValue(test.bim.bootstrap.NewFile): diff --git a/src/bonsai/test/tool/test_root.py b/src/bonsai/test/tool/test_root.py index 19cba02029..b807c1ddb8 100644 --- a/src/bonsai/test/tool/test_root.py +++ b/src/bonsai/test/tool/test_root.py @@ -120,8 +120,8 @@ class TestGetObjectRepresentation(NewFile): ifc = ifcopenshell.file() tool.Ifc.set(ifc) representation = ifc.createIfcShapeRepresentation() - obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh")) - obj.data.BIMMeshProperties.ifc_definition_id = representation.id() + obj = bpy.data.objects.new("Object", (mesh := bpy.data.meshes.new("Mesh"))) + tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id() assert subject.get_object_representation(obj) == representation @@ -175,7 +175,7 @@ class TestSetObjectName(NewFile): class TestReassignClass(NewFile): def test_reassigning_multiple_occurrences_of_the_same_type(self): - bpy.context.scene.BIMProjectProperties.template_file = "IFC4 Demo Template.ifc" + tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc" bpy.ops.bim.create_project() ifc_file = tool.Ifc.get() context = bpy.context @@ -184,15 +184,17 @@ class TestReassignClass(NewFile): n_slab_types = len(ifc_file.by_type("IfcSlabType")) # create 3 slabs - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) + bpy.ops.bim.add_occurrence(relating_type_id=relating_type_id) slabs = [tool.Ifc.get_object(e) for e in ifc_file.by_type("IfcSlab")] assert len(slabs) == 3 tool.Blender.set_objects_selection(context, slabs[0], (slabs[1],)) - context.scene.BIMRootProperties.ifc_product = "IfcElement" - context.scene.BIMRootProperties.ifc_class = "IfcWall" + + props = tool.Root.get_root_props() + props.ifc_product = "IfcElement" + props.ifc_class = "IfcWall" bpy.ops.bim.reassign_class() assert len(ifc_file.by_type("IfcWall")) == 3 diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 2985b458d1..9bcc84c6dc 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -129,7 +129,8 @@ class TestDisableEditing(NewFile): obj = bpy.data.objects.new("Object", None) subject.enable_editing(obj) subject.disable_editing(obj) - assert obj.BIMObjectSpatialProperties.is_editing is False + props = tool.Spatial.get_object_spatial_props(obj) + assert props.is_editing is False class TestDuplicateObjectAndData(NewFile): @@ -148,7 +149,8 @@ class TestEnableEditing(NewFile): def test_run(self): obj = bpy.data.objects.new("Object", None) subject.enable_editing(obj) - assert obj.BIMObjectSpatialProperties.is_editing is True + props = tool.Spatial.get_object_spatial_props(obj) + assert props.is_editing is True class TestGetContainer(NewFile): diff --git a/src/bonsai/test/tool/test_style.py b/src/bonsai/test/tool/test_style.py index cf46d8fab7..5f37d397b5 100644 --- a/src/bonsai/test/tool/test_style.py +++ b/src/bonsai/test/tool/test_style.py @@ -47,7 +47,7 @@ class TestCanSupportRenderingStyle(NewFile): class TestDisableEditing(NewFile): def test_run(self): - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() props.is_editing_style = 1 subject.disable_editing() assert props.is_editing_style == 0 @@ -55,14 +55,15 @@ class TestDisableEditing(NewFile): class TestDisableEditingStyles(NewFile): def test_run(self): - bpy.context.scene.BIMStylesProperties.is_editing = True + props = tool.Style.get_style_props() + props.is_editing = True subject.disable_editing_styles() - assert bpy.context.scene.BIMStylesProperties.is_editing is False + assert props.is_editing is False class TestEnableEditing(NewFile): def test_run(self): - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = ifcopenshell.file().create_entity("IfcSurfaceStyle") subject.enable_editing(style) assert props.is_editing_style is style.id() @@ -70,9 +71,10 @@ class TestEnableEditing(NewFile): class TestEnableEditingStyles(NewFile): def test_run(self): - bpy.context.scene.BIMStylesProperties.is_editing = False + props = props = tool.Style.get_style_props() + props.is_editing = False subject.enable_editing_styles() - assert bpy.context.scene.BIMStylesProperties.is_editing is True + assert props.is_editing is True class TestExportSurfaceAttributes(NewFile): @@ -85,9 +87,10 @@ class TestGetActiveStyleType(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc.set(ifc) - bpy.context.scene.BIMStylesProperties.style_type = "IfcSurfaceStyle" + props = tool.Style.get_style_props() + props.style_type = "IfcSurfaceStyle" assert subject.get_active_style_type() == "IfcSurfaceStyle" - bpy.context.scene.BIMStylesProperties.style_type = "IfcCurveStyle" + props.style_type = "IfcCurveStyle" assert subject.get_active_style_type() == "IfcCurveStyle" @@ -305,7 +308,8 @@ class TestGetSurfaceRenderingStyle(NewFile): style_item = tool.Ifc.get().createIfcSurfaceStyleRendering() style = tool.Ifc.get().createIfcSurfaceStyle(Styles=[style_item]) obj = bpy.data.materials.new("Material") - obj.BIMStyleProperties.ifc_definition_id = style.id() + props = tool.Style.get_material_style_props(obj) + props.ifc_definition_id = style.id() assert subject.get_surface_rendering_style(obj) == style_item @@ -344,7 +348,7 @@ class TestGetSurfaceShadingStyle(NewFile): style_item = tool.Ifc.get().createIfcSurfaceStyleShading() style = tool.Ifc.get().createIfcSurfaceStyle(Styles=[style_item]) obj = bpy.data.materials.new("Material") - obj.BIMStyleProperties.ifc_definition_id = style.id() + tool.Ifc.link(style, obj) assert subject.get_surface_shading_style(obj) == style_item def test_do_not_get_rendering_styles(self): @@ -352,7 +356,7 @@ class TestGetSurfaceShadingStyle(NewFile): style_item = tool.Ifc.get().createIfcSurfaceStyleRendering() style = tool.Ifc.get().createIfcSurfaceStyle(Styles=[style_item]) obj = bpy.data.materials.new("Material") - obj.BIMStyleProperties.ifc_definition_id = style.id() + tool.Ifc.link(style, obj) assert subject.get_surface_shading_style(obj) is None @@ -362,7 +366,7 @@ class TestGetSurfaceTextureStyle(NewFile): style_item = tool.Ifc.get().createIfcSurfaceStyleWithTextures() style = tool.Ifc.get().createIfcSurfaceStyle(Styles=[style_item]) obj = bpy.data.materials.new("Material") - obj.BIMStyleProperties.ifc_definition_id = style.id() + tool.Ifc.link(style, obj) assert subject.get_surface_texture_style(obj) == style_item @@ -378,7 +382,7 @@ class TestGetUVMaps(NewFile): class TestImportSurfaceAttributes(NewFile): def test_run(self): tool.Ifc.set(ifc := ifcopenshell.file()) - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() style = ifc.create_entity("IfcSurfaceStyle", "Name", "BOTH") subject.import_surface_attributes(style) assert props.attributes.get("Name").string_value == "Name" @@ -387,7 +391,7 @@ class TestImportSurfaceAttributes(NewFile): def test_importing_surface_attributes_twice(self): tool.Ifc.set(ifc := ifcopenshell.file()) style = ifc.create_entity("IfcSurfaceStyle", "Name", "BOTH") - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() subject.import_surface_attributes(style) assert len(props.attributes) == 2 assert props.attributes.get("Name").string_value == "Name" @@ -404,7 +408,7 @@ class TestImportPresentationStyles(NewFile): tool.Ifc.set(ifc) style = ifc.createIfcCurveStyle(Name="Name") subject.import_presentation_styles("IfcCurveStyle") - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() assert props.styles[0].ifc_definition_id == style.id() assert props.styles[0].name == "Name" assert props.styles[0].total_elements == 0 @@ -414,7 +418,7 @@ class TestImportPresentationStyles(NewFile): tool.Ifc.set(ifc) style = ifc.createIfcFillAreaStyle(Name="Name") subject.import_presentation_styles("IfcFillAreaStyle") - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() assert props.styles[0].ifc_definition_id == style.id() assert props.styles[0].name == "Name" assert props.styles[0].total_elements == 0 @@ -424,7 +428,7 @@ class TestImportPresentationStyles(NewFile): tool.Ifc.set(ifc) style = ifc.createIfcSurfaceStyle(Name="Name") subject.import_presentation_styles("IfcSurfaceStyle") - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() assert props.styles[0].ifc_definition_id == style.id() assert props.styles[0].name == "Name" assert props.styles[0].total_elements == 0 @@ -434,7 +438,7 @@ class TestImportPresentationStyles(NewFile): tool.Ifc.set(ifc) style = ifc.createIfcTextStyle(Name="Name") subject.import_presentation_styles("IfcTextStyle") - props = bpy.context.scene.BIMStylesProperties + props = tool.Style.get_style_props() assert props.styles[0].ifc_definition_id == style.id() assert props.styles[0].name == "Name" assert props.styles[0].total_elements == 0 @@ -442,9 +446,10 @@ class TestImportPresentationStyles(NewFile): class TestIsEditingStyles(NewFile): def test_run(self): - bpy.context.scene.BIMStylesProperties.is_editing = False + props = tool.Style.get_style_props() + props.is_editing = False assert subject.is_editing_styles() is False - bpy.context.scene.BIMStylesProperties.is_editing = True + props.is_editing = True assert subject.is_editing_styles() is True diff --git a/src/bonsai/test/tool/test_surveyor.py b/src/bonsai/test/tool/test_surveyor.py index 0a12bd3903..5d3684fdcf 100644 --- a/src/bonsai/test/tool/test_surveyor.py +++ b/src/bonsai/test/tool/test_surveyor.py @@ -20,6 +20,7 @@ import bpy import numpy as np import ifcopenshell import ifcopenshell.api +import ifcopenshell.util.geolocation import test.bim.bootstrap import bonsai.core.tool @@ -34,7 +35,7 @@ class TestImplementsTool(test.bim.bootstrap.NewFile): class TestGetGlobalMatrix(test.bim.bootstrap.NewFile): def test_getting_an_absolute_matrix_if_no_blender_offset(self): - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = False obj = bpy.data.objects.new("Object", None) assert (subject.get_absolute_matrix(obj) == np.array(obj.matrix_world)).all() @@ -45,7 +46,7 @@ class TestGetGlobalMatrix(test.bim.bootstrap.NewFile): unit = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT", prefix="MILLI") ifcopenshell.api.run("unit.assign_unit", ifc, units=[unit]) tool.Ifc.set(ifc) - props = bpy.context.scene.BIMGeoreferenceProperties + props = tool.Georeference.get_georeference_props() props.has_blender_offset = True props.blender_offset_x = "1000" props.blender_offset_y = "2000" @@ -53,6 +54,7 @@ class TestGetGlobalMatrix(test.bim.bootstrap.NewFile): props.blender_x_axis_abscissa = "0" props.blender_x_axis_ordinate = "1" obj = bpy.data.objects.new("Object", None) - obj.BIMObjectProperties.blender_offset_type = "OBJECT_PLACEMENT" + props = tool.Blender.get_object_bim_props(obj) + props.blender_offset_type = "OBJECT_PLACEMENT" matrix = ifcopenshell.util.geolocation.local2global(np.array(obj.matrix_world), 1.0, 2.0, 3.0, 0.0, 1.0) assert (subject.get_absolute_matrix(obj) == matrix).all() diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py index 7b0dc9e0d1..5a6ce1949f 100644 --- a/src/bonsai/test/tool/test_system.py +++ b/src/bonsai/test/tool/test_system.py @@ -112,22 +112,25 @@ class TestDeleteElementObjects(NewFile): class TestDisableEditingSystem(NewFile): def test_run(self): - bpy.context.scene.BIMSystemProperties.edited_system_id = 10 + props = tool.System.get_system_props() + props.edited_system_id = 10 subject.disable_editing_system() - assert bpy.context.scene.BIMSystemProperties.edited_system_id == 0 + assert props.edited_system_id == 0 class TestDisableSystemEditingUI(NewFile): def test_run(self): subject.enable_system_editing_ui() subject.disable_system_editing_ui() - assert bpy.context.scene.BIMSystemProperties.is_editing is False + props = tool.System.get_system_props() + assert props.is_editing is False class TestEnableSystemEditingUI(NewFile): def test_run(self): subject.enable_system_editing_ui() - assert bpy.context.scene.BIMSystemProperties.is_editing is True + props = tool.System.get_system_props() + assert props.is_editing is True class TestExportSystemAttributes(NewFile): @@ -171,7 +174,7 @@ class TestImportSystemAttributes(NewFile): system.Description = "Description" system.ObjectType = "ObjectType" subject().import_system_attributes(system) - props = bpy.context.scene.BIMSystemProperties + props = tool.System.get_system_props() assert props.system_attributes.get("GlobalId").string_value == "GlobalId" assert props.system_attributes.get("Name").string_value == "Name" assert props.system_attributes.get("Description").string_value == "Description" @@ -188,7 +191,7 @@ class TestImportSystemAttributes(NewFile): system.PredefinedType = "SHADING" system.LongName = "LongName" subject().import_system_attributes(system) - props = bpy.context.scene.BIMSystemProperties + props = tool.System.get_system_props() assert props.system_attributes.get("GlobalId").string_value == "GlobalId" assert props.system_attributes.get("Name").string_value == "Name" assert props.system_attributes.get("Description").string_value == "Description" @@ -207,7 +210,7 @@ class TestImportSystemAttributes(NewFile): system.PredefinedType = "ELECTRICAL" system.LongName = "LongName" subject().import_system_attributes(system) - props = bpy.context.scene.BIMSystemProperties + props = tool.System.get_system_props() assert props.system_attributes.get("GlobalId").string_value == "GlobalId" assert props.system_attributes.get("Name").string_value == "Name" assert props.system_attributes.get("Description").string_value == "Description" @@ -223,7 +226,7 @@ class TestImportSystems(NewFile): system = ifc.createIfcDistributionSystem() zone = ifc.createIfcZone() subject.import_systems() - props = bpy.context.scene.BIMSystemProperties + props = tool.System.get_system_props() assert len(props.systems) == 2 assert props.systems[0].ifc_definition_id == system.id() assert props.systems[0].name == "Unnamed" @@ -277,7 +280,8 @@ class TestSetActiveSystem(NewFile): tool.Ifc().set(ifc) system = ifcopenshell.api.run("system.add_system", ifc, ifc_class="IfcSystem") subject.set_active_edited_system(system) - assert bpy.context.scene.BIMSystemProperties.edited_system_id == system.id() + props = tool.System.get_system_props() + assert props.edited_system_id == system.id() class TestFlowElementAndControls(NewFile): diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py index 4a8512c360..09db8b72bd 100644 --- a/src/bonsai/test/tool/test_unit.py +++ b/src/bonsai/test/tool/test_unit.py @@ -18,6 +18,9 @@ import bpy import ifcopenshell +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit import bonsai.core.tool import bonsai.tool as tool from test.bim.bootstrap import NewFile @@ -31,23 +34,26 @@ class TestImplementsTool(NewFile): class TestClearActiveUnit(NewFile): def test_run(self): - bpy.context.scene.BIMUnitProperties.active_unit_id = 1 + props = tool.Unit.get_unit_props() + props.active_unit_id = 1 subject.clear_active_unit() - assert bpy.context.scene.BIMUnitProperties.active_unit_id == 0 + assert props.active_unit_id == 0 class TestDisableEditingUnits(NewFile): def test_run(self): - bpy.context.scene.BIMUnitProperties.is_editing = True + props = tool.Unit.get_unit_props() + props.is_editing = True subject.disable_editing_units() - assert bpy.context.scene.BIMUnitProperties.is_editing == False + assert props.is_editing == False class TestEnableEditingUnits(NewFile): def test_run(self): - bpy.context.scene.BIMUnitProperties.is_editing = False + props = tool.Unit.get_unit_props() + props.is_editing = False subject.enable_editing_units() - assert bpy.context.scene.BIMUnitProperties.is_editing == True + assert props.is_editing == True class TestExportUnitAttributes(NewFile): @@ -98,10 +104,11 @@ class TestExportUnitAttributes(NewFile): class TestGetSceneUnitName(NewFile): def test_getting_an_imperial_name(self): + props = tool.Blender.get_bim_props() bpy.context.scene.unit_settings.system = "IMPERIAL" bpy.context.scene.unit_settings.length_unit = "MILES" - bpy.context.scene.BIMProperties.area_unit = "square foot" - bpy.context.scene.BIMProperties.volume_unit = "cubic inch" + props.area_unit = "square foot" + props.volume_unit = "cubic inch" assert subject.get_scene_unit_name("LENGTHUNIT") == "mile" assert subject.get_scene_unit_name("AREAUNIT") == "square foot" assert subject.get_scene_unit_name("VOLUMEUNIT") == "cubic inch" @@ -142,13 +149,14 @@ class TestGetSceneUnitSIPrefix: assert subject.get_scene_unit_si_prefix("LENGTHUNIT") == "KILO" bpy.context.scene.unit_settings.length_unit = "ADAPTIVE" assert subject.get_scene_unit_si_prefix("LENGTHUNIT") is None - bpy.context.scene.BIMProperties.area_unit = "SQUARE_METRE" + props = tool.Blender.get_bim_props() + props.area_unit = "SQUARE_METRE" assert subject.get_scene_unit_si_prefix("AREAUNIT") is None - bpy.context.scene.BIMProperties.area_unit = "MILLI/SQUARE_METRE" + props.area_unit = "MILLI/SQUARE_METRE" assert subject.get_scene_unit_si_prefix("AREAUNIT") == "MILLI" - bpy.context.scene.BIMProperties.volume_unit = "CUBIC_METRE" + props.volume_unit = "CUBIC_METRE" assert subject.get_scene_unit_si_prefix("VOLUMEUNIT") is None - bpy.context.scene.BIMProperties.volume_unit = "MILLI/CUBIC_METRE" + props.volume_unit = "MILLI/CUBIC_METRE" assert subject.get_scene_unit_si_prefix("VOLUMEUNIT") == "MILLI" @@ -159,25 +167,25 @@ class TestImportUnitAttributes(NewFile): unit.UnitType = "ANGULARVELOCITYUNIT" unit.UserDefinedType = "UserDefinedType" subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("UnitType").enum_value == "ANGULARVELOCITYUNIT" - assert props.unit_attributes.get("UserDefinedType").string_value == "UserDefinedType" + props = tool.Unit.get_unit_props() + assert props.unit_attributes["UnitType"].enum_value == "ANGULARVELOCITYUNIT" + assert props.unit_attributes["UserDefinedType"].string_value == "UserDefinedType" def test_importing_monetary_units(self): tool.Ifc.set(ifc := ifcopenshell.file()) unit = ifc.createIfcMonetaryUnit() unit.Currency = "Currency" subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("Currency").string_value == "Currency" + props = tool.Unit.get_unit_props() + assert props.unit_attributes["Currency"].string_value == "Currency" def test_importing_monetary_units_ifc2x3(self): tool.Ifc.set(ifc := ifcopenshell.file(schema="IFC2X3")) unit = ifc.createIfcMonetaryUnit() unit.Currency = "USD" subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("Currency").enum_value == "USD" + props = tool.Unit.get_unit_props() + assert props.unit_attributes["Currency"].enum_value == "USD" def test_importing_context_dependent_units(self): ifc = ifcopenshell.file() @@ -187,10 +195,10 @@ class TestImportUnitAttributes(NewFile): unit.Name = "Name" unit.Dimensions = ifc.createIfcDimensionalExponents(1, 2, 3, 4, 5, 6, 7) subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("UnitType").enum_value == "ABSORBEDDOSEUNIT" - assert props.unit_attributes.get("Name").string_value == "Name" - assert props.unit_attributes.get("Dimensions").string_value == "[1, 2, 3, 4, 5, 6, 7]" + props = tool.Unit.get_unit_props() + assert props.unit_attributes["UnitType"].enum_value == "ABSORBEDDOSEUNIT" + assert props.unit_attributes["Name"].string_value == "Name" + assert props.unit_attributes["Dimensions"].string_value == "[1, 2, 3, 4, 5, 6, 7]" def test_importing_conversion_based_units(self): ifc = ifcopenshell.file() @@ -200,10 +208,10 @@ class TestImportUnitAttributes(NewFile): unit.Name = "Name" unit.Dimensions = ifc.createIfcDimensionalExponents(1, 2, 3, 4, 5, 6, 7) subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("UnitType").enum_value == "ABSORBEDDOSEUNIT" - assert props.unit_attributes.get("Name").string_value == "Name" - assert props.unit_attributes.get("Dimensions").string_value == "[1, 2, 3, 4, 5, 6, 7]" + props = tool.Unit.get_unit_props() + assert props.unit_attributes["UnitType"].enum_value == "ABSORBEDDOSEUNIT" + assert props.unit_attributes["Name"].string_value == "Name" + assert props.unit_attributes["Dimensions"].string_value == "[1, 2, 3, 4, 5, 6, 7]" def test_importing_conversion_based_with_offset_units(self): ifc = ifcopenshell.file() @@ -214,11 +222,11 @@ class TestImportUnitAttributes(NewFile): unit.Dimensions = ifc.createIfcDimensionalExponents(1, 2, 3, 4, 5, 6, 7) unit.ConversionOffset = 1 subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("UnitType").enum_value == "ABSORBEDDOSEUNIT" - assert props.unit_attributes.get("Name").string_value == "Name" - assert props.unit_attributes.get("Dimensions").string_value == "[1, 2, 3, 4, 5, 6, 7]" - assert props.unit_attributes.get("ConversionOffset").float_value == 1 + props = tool.Unit.get_unit_props() + assert props.unit_attributes["UnitType"].enum_value == "ABSORBEDDOSEUNIT" + assert props.unit_attributes["Name"].string_value == "Name" + assert props.unit_attributes["Dimensions"].string_value == "[1, 2, 3, 4, 5, 6, 7]" + assert props.unit_attributes["ConversionOffset"].float_value == 1 def test_importing_si_units(self): ifc = ifcopenshell.file() @@ -228,11 +236,11 @@ class TestImportUnitAttributes(NewFile): unit.Prefix = "EXA" unit.Name = "AMPERE" subject.import_unit_attributes(unit) - props = bpy.context.scene.BIMUnitProperties - assert props.unit_attributes.get("UnitType").enum_value == "ABSORBEDDOSEUNIT" - assert props.unit_attributes.get("Prefix").enum_value == "EXA" - assert props.unit_attributes.get("Name").enum_value == "AMPERE" - assert props.unit_attributes.get("Dimensions") is None + props = tool.Unit.get_unit_props() + assert props.unit_attributes["UnitType"].enum_value == "ABSORBEDDOSEUNIT" + assert props.unit_attributes["Prefix"].enum_value == "EXA" + assert props.unit_attributes["Name"].enum_value == "AMPERE" + assert "Dimensions" not in props.unit_attributes class TestImportUnits(NewFile): @@ -248,7 +256,7 @@ class TestImportUnits(NewFile): ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") ifcopenshell.api.unit.assign_unit(ifc, units=[unit2]) subject.import_units() - props = bpy.context.scene.BIMUnitProperties + props = tool.Unit.get_unit_props() assert len(props.units) == 6 assert props.units[0].ifc_definition_id == unit1.id() @@ -311,4 +319,5 @@ class TestSetActiveUnit(NewFile): ifc = ifcopenshell.file() unit = ifc.createIfcSIUnit() subject.set_active_unit(unit) - assert bpy.context.scene.BIMUnitProperties.active_unit_id == unit.id() + props = tool.Unit.get_unit_props() + assert props.active_unit_id == unit.id() diff --git a/src/ifc4d/ifc4d/wpattern.py b/src/ifc4d/ifc4d/wpattern.py index 72a150aea9..2e434223d5 100644 --- a/src/ifc4d/ifc4d/wpattern.py +++ b/src/ifc4d/ifc4d/wpattern.py @@ -1,7 +1,7 @@ """ This class parses the calendar work pattern and retruns a list The list returned has a key DayOfWeek which takes a value Sunday to Saturday -for each day as a key there is a list of working times with the format +for each day as a key there is a list of working times with the format {"Start": datetime.time, "Finish": datetime.time} """ diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index dfe870bbe2..2c3cdcd0f8 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -40,11 +40,11 @@ "CrossSectionArea": null, "GrossSurfaceArea": "gross_get_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_max_xyz", "NetSurfaceArea": "net_get_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": "net_get_outer_surface_area" } }, @@ -134,11 +134,11 @@ "CrossSectionArea": null, "GrossSurfaceArea": "gross_get_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_max_xyz", "NetSurfaceArea": "net_get_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": "net_get_outer_surface_area" } }, @@ -317,11 +317,11 @@ "CrossSectionArea": null, "GrossSurfaceArea": null, "GrossVolume": null, - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Height": "net_get_z", "Length": "net_get_max_xy", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": null, "Width": null } @@ -368,11 +368,11 @@ "CrossSectionArea": null, "GrossSurfaceArea": "gross_get_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_max_xyz", "NetSurfaceArea": "net_get_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": "net_get_outer_surface_area" } }, @@ -400,10 +400,10 @@ "CrossSectionArea": null, "GrossSurfaceArea": null, "GrossVolume": null, - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_z", "NetVolume": null, - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": null } }, @@ -420,10 +420,10 @@ "IfcPipeSegment": { "Qto_PipeSegmentBaseQuantities": { "GrossCrossSectionArea": null, - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_segment_length", "NetCrossSectionArea": null, - "NetWeight": null, + "NetWeight": "net_get_weight", "OuterSurfaceArea": null } }, @@ -431,10 +431,10 @@ "Qto_PlateBaseQuantities": { "GrossArea": "gross_get_max_side_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "NetArea": "net_get_max_side_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "Perimeter": null, "Width": "net_get_min_xyz" } @@ -510,11 +510,11 @@ "Depth": "net_get_z", "GrossArea": "gross_get_footprint_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Length": "net_get_x", "NetArea": "net_get_footprint_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "Perimeter": "net_get_footprint_perimeter", "Width": "net_get_y" } @@ -609,13 +609,13 @@ "GrossFootprintArea": null, "GrossSideArea": "gross_get_side_area", "GrossVolume": "gross_get_volume", - "GrossWeight": null, + "GrossWeight": "gross_get_weight", "Height": "net_get_z", "Length": "net_get_x", "NetFootprintArea": null, "NetSideArea": "net_get_side_area", "NetVolume": "net_get_volume", - "NetWeight": null, + "NetWeight": "net_get_weight", "Width": "net_get_y" } }, diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json index 3dcfd882ac..73bc99dc3b 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json @@ -220,7 +220,7 @@ "Area": "get_net_side_area", "Height": "get_height", "Perimeter": "get_rectangular_perimeter", - "Width": "get_length" + "Width": "get_x" } }, "IfcDuctFitting": { @@ -611,7 +611,7 @@ "GrossVolume": "get_gross_volume", "GrossWeight": "get_gross_weight", "Height": "get_height", - "Length": "get_length", + "Length": "get_x", "NetFootprintArea": "get_net_footprint_area", "NetSideArea": "get_net_side_area", "NetVolume": "get_net_volume", @@ -629,7 +629,7 @@ "Area": "get_net_side_area", "Height": "get_height", "Perimeter": "get_rectangular_perimeter", - "Width": "get_length" + "Width": "get_x" } } } diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json new file mode 100644 index 0000000000..cf0bf7ebde --- /dev/null +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -0,0 +1,810 @@ +{ + "name": "IFC4X3 Base Quantities - IfcOpenShell", + "description": "This ruleset quantifies every single possible standardised base quantity in IFC4X3 using only IfcOpenShell as a geometry processor.", + "calculators": { + "IfcOpenShell": { + "IfcActuator + IfcActuatorType": { + "Qto_ActuatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAirTerminal + IfcAirTerminalType": { + "Qto_AirTerminalBaseQuantities": { + "GrossWeight": null, + "Perimeter": null, + "TotalSurfaceArea": null + } + }, + "IfcAirTerminalBox + IfcAirTerminalBoxType": { + "Qto_AirTerminalBoxTypeBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAirToAirHeatRecovery + IfcAirToAirHeatRecoveryType": { + "Qto_AirToAirHeatRecoveryBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAlarm + IfcAlarmType": { + "Qto_AlarmBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAudioVisualAppliance + IfcAudioVisualApplianceType": { + "Qto_AudioVisualApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcBeam + IfcBeamType": { + "Qto_BeamBaseQuantities": { + "CrossSectionArea": null, + "GrossSurfaceArea": "gross_get_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "Length": "net_get_max_xyz", + "NetSurfaceArea": "net_get_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "OuterSurfaceArea": "net_get_outer_surface_area" + } + }, + "IfcBoiler + IfcBoilerType": { + "Qto_BoilerBaseQuantities": { + "GrossWeight": null, + "NetWeight": null, + "TotalSurfaceArea": null + } + }, + "IfcBuilding": { + "Qto_BuildingBaseQuantities": { + "EavesHeight": null, + "FootPrintArea": null, + "GrossFloorArea": null, + "GrossVolume": null, + "Height": null, + "NetFloorArea": null, + "NetVolume": null + } + }, + "IfcBuildingElementProxy + IfcBuildingElementProxyType": { + "Qto_BuildingElementProxyQuantities": { + "NetSurfaceArea": null, + "NetVolume": null + } + }, + "IfcBuildingStorey": { + "Qto_BuildingStoreyBaseQuantities": { + "GrossFloorArea": null, + "GrossHeight": null, + "GrossPerimeter": null, + "GrossVolume": null, + "NetFloorArea": null, + "NetHeight": null, + "NetVolume": null + } + }, + "IfcBurner + IfcBurnerType": { + "Qto_BurnerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableCarrierFitting + IfcCableCarrierFittingType": { + "Qto_CableCarrierFittingBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableCarrierSegment + IfcCableCarrierSegmentType": { + "Qto_CableCarrierSegmentBaseQuantities": { + "CrossSectionArea": null, + "GrossWeight": null, + "Length": "net_get_segment_length", + "OuterSurfaceArea": null + } + }, + "IfcCableCarrierSegment, PredefinedType=\"CONDUITSEGMENT\" + IfcCableCarrierSegmentType, PredefinedType=\"CONDUITSEGMENT\"": { + "Qto_ConduitSegmentBaseQuantities": { + "InnerDiameter": null, + "OuterDiameter": null + } + }, + "IfcCableFitting + IfcCableFittingType": { + "Qto_CableFittingBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableSegment + IfcCableSegmentType": { + "Qto_CableSegmentBaseQuantities": { + "CrossSectionArea": null, + "GrossWeight": null, + "Length": "net_get_segment_length", + "OuterSurfaceArea": null + } + }, + "IfcChiller + IfcChillerType": { + "Qto_ChillerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcChimney + IfcChimneyType": { + "Qto_ChimneyBaseQuantities": { + "Length": "net_get_max_xyz" + } + }, + "IfcCoil + IfcCoilType": { + "Qto_CoilBaseQuantities": { + "GrossWeight": null + } + }, + "IfcColumn + IfcColumnType": { + "Qto_ColumnBaseQuantities": { + "CrossSectionArea": null, + "GrossSurfaceArea": "gross_get_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "Length": "net_get_max_xyz", + "NetSurfaceArea": "net_get_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "OuterSurfaceArea": "net_get_outer_surface_area" + } + }, + "IfcCommunicationsAppliance + IfcCommunicationsApplianceType": { + "Qto_CommunicationsApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCompressor + IfcCompressorType": { + "Qto_CompressorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCondenser + IfcCondenserType": { + "Qto_CondenserBaseQuantities": { + "GrossWeight": null + } + }, + "IfcConstructionEquipmentResource + IfcConstructionEquipmentResourceType": { + "Qto_ConstructionEquipmentResourceBaseQuantities": { + "OperatingTime": null, + "UsageTime": null + } + }, + "IfcConstructionMaterialResource + IfcConstructionMaterialResourceType": { + "Qto_ConstructionMaterialResourceBaseQuantities": { + "GrossVolume": null, + "GrossWeight": null, + "NetVolume": null, + "NetWeight": null + } + }, + "IfcController + IfcControllerType": { + "Qto_ControllerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCooledBeam + IfcCooledBeamType": { + "Qto_CooledBeamBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCoolingTower + IfcCoolingTowerType": { + "Qto_CoolingTowerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCourse + IfcCourseType": { + "Qto_CourseBaseQuantities": { + "GrossVolume": null, + "Length": null, + "Thickness": null, + "Volume": null, + "Weight": null, + "Width": null + } + }, + "IfcCovering + IfcCoveringType": { + "Qto_CoveringBaseQuantities": { + "GrossArea": "gross_get_max_side_area", + "NetArea": "net_get_max_side_area", + "Width": "gross_get_min_xyz" + } + }, + "IfcCurtainWall + IfcCurtainWallType": { + "Qto_CurtainWallQuantities": { + "GrossSideArea": null, + "Height": null, + "Length": null, + "NetSideArea": null, + "Width": null + } + }, + "IfcDamper + IfcDamperType": { + "Qto_DamperBaseQuantities": { + "GrossWeight": null + } + }, + "IfcDistributionChamberElement + IfcDistributionChamberElementType": { + "Qto_DistributionChamberElementBaseQuantities": { + "Depth": null, + "GrossSurfaceArea": null, + "GrossVolume": null, + "NetSurfaceArea": null, + "NetVolume": null + } + }, + "IfcDoor + IfcDoorType": { + "Qto_DoorBaseQuantities": { + "Area": null, + "Height": null, + "Perimeter": null, + "Width": null + } + }, + "IfcDuctFitting + IfcDuctFittingType": { + "Qto_DuctFittingBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": null, + "NetCrossSectionArea": null, + "OuterSurfaceArea": null + } + }, + "IfcDuctSegment + IfcDuctSegmentType": { + "Qto_DuctSegmentBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": "net_get_segment_length", + "NetCrossSectionArea": null, + "OuterSurfaceArea": null + } + }, + "IfcDuctSilencer + IfcDuctSilencerType": { + "Qto_DuctSilencerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEarthworksCut": { + "Qto_EarthworksCutBaseQuantities": { + "Depth": null, + "Length": null, + "LooseVolume": null, + "UndisturbedVolume": null, + "Weight": null, + "Width": null + } + }, + "IfcEarthworksFill": { + "Qto_EarthworksFillBaseQuantities": { + "CompactedVolume": null, + "Depth": null, + "Length": null, + "LooseVolume": null, + "Width": null + } + }, + "IfcElectricAppliance + IfcElectricApplianceType": { + "Qto_ElectricApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricDistributionBoard + IfcElectricDistributionBoardType": { + "Qto_DistributionBoardBaseQuantities": { + "GrossWeight": null, + "NumberOfCircuits": null + } + }, + "IfcElectricFlowStorageDevice + IfcElectricFlowStorageDeviceType": { + "Qto_ElectricFlowStorageDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricGenerator + IfcElectricGeneratorType": { + "Qto_ElectricGeneratorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricMotor + IfcElectricMotorType": { + "Qto_ElectricMotorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricTimeControl + IfcElectricTimeControlType": { + "Qto_ElectricTimeControlBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEvaporativeCooler + IfcEvaporativeCoolerType": { + "Qto_EvaporativeCoolerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEvaporator + IfcEvaporatorType": { + "Qto_EvaporatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFacilityPart": { + "Qto_FacilityPartBaseQuantities": { + "Area": null, + "Height": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcFan + IfcFanType": { + "Qto_FanBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFilter + IfcFilterType": { + "Qto_FilterBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFireSuppressionTerminal + IfcFireSuppressionTerminalType": { + "Qto_FireSuppressionTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFlowInstrument + IfcFlowInstrumentType": { + "Qto_FlowInstrumentBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFlowMeter + IfcFlowMeterType": { + "Qto_FlowMeterBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFooting + IfcFootingType": { + "Qto_FootingBaseQuantities": { + "CrossSectionArea": null, + "GrossSurfaceArea": null, + "GrossVolume": null, + "GrossWeight": "gross_get_weight", + "Height": "net_get_z", + "Length": "net_get_max_xy", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "OuterSurfaceArea": null, + "Width": null + } + }, + "IfcGeotechnicalStratum": { + "Qto_ArealStratumBaseQuantities": { + "Area": null, + "Length": null, + "PlanLength": null + }, + "Qto_LinearStratumBaseQuantities": { + "Diameter": null, + "Length": null + }, + "Qto_VolumetricStratumBaseQuantities": { + "Area": null, + "Mass": null, + "PlanArea": null, + "Volume": null + } + }, + "IfcHeatExchanger + IfcHeatExchangerType": { + "Qto_HeatExchangerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcHumidifier + IfcHumidifierType": { + "Qto_HumidifierBaseQuantities": { + "GrossWeight": null + } + }, + "IfcImpactProtectionDevice + IfcImpactProtectionDeviceType": { + "Qto_ImpactProtectionDeviceBaseQuantities": { + "Weight": null + } + }, + "IfcInterceptor + IfcInterceptorType": { + "Qto_InterceptorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcJunctionBox + IfcJunctionBoxType": { + "Qto_JunctionBoxBaseQuantities": { + "GrossWeight": null, + "Height": null, + "Length": null, + "NumberOfGangs": null, + "Width": null + } + }, + "IfcKerb + IfcKerbType": { + "Qto_KerbBaseQuantities": { + "Depth": null, + "Height": null, + "Length": null, + "Volume": null, + "Weight": null, + "Width": null + } + }, + "IfcLaborResource + IfcLaborResourceType": { + "Qto_LaborResourceBaseQuantities": { + "OvertimeWork": null, + "StandardWork": null + } + }, + "IfcLamp + IfcLampType": { + "Qto_LampBaseQuantities": { + "GrossWeight": null + } + }, + "IfcLightFixture + IfcLightFixtureType": { + "Qto_LightFixtureBaseQuantities": { + "GrossWeight": null + } + }, + "IfcMarineFacility": { + "Qto_MarineFacilityBaseQuantities": { + "Area": null, + "Height": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcMember + IfcMemberType": { + "Qto_MemberBaseQuantities": { + "CrossSectionArea": null, + "GrossSurfaceArea": "gross_get_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "Length": "net_get_max_xyz", + "NetSurfaceArea": "net_get_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "OuterSurfaceArea": "net_get_outer_surface_area" + } + }, + "IfcMotorConnection + IfcMotorConnectionType": { + "Qto_MotorConnectionBaseQuantities": { + "GrossWeight": null + } + }, + "IfcOpeningElement": { + "Qto_OpeningElementBaseQuantities": { + "Area": "gross_get_max_side_area", + "Depth": "gross_get_z", + "Height": "gross_get_y", + "Volume": "gross_get_volume", + "Width": "gross_get_x" + } + }, + "IfcOutlet + IfcOutletType": { + "Qto_OutletBaseQuantities": { + "GrossWeight": null + } + }, + "IfcPavement + IfcPavementType": { + "Qto_PavementBaseQuantities": { + "Depth": null, + "GrossArea": null, + "GrossVolume": null, + "Length": null, + "NetArea": null, + "NetVolume": null, + "Width": null + } + }, + "IfcPile + IfcPileType": { + "Qto_PileBaseQuantities": { + "CrossSectionArea": null, + "GrossSurfaceArea": null, + "GrossVolume": null, + "GrossWeight": "gross_get_weight", + "Length": "net_get_z", + "NetVolume": null, + "NetWeight": "net_get_weight", + "OuterSurfaceArea": null + } + }, + "IfcPipeFitting + IfcPipeFittingType": { + "Qto_PipeFittingBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": null, + "NetCrossSectionArea": null, + "NetWeight": null, + "OuterSurfaceArea": null + } + }, + "IfcPipeSegment + IfcPipeSegmentType": { + "Qto_PipeSegmentBaseQuantities": { + "FootPrintArea": null, + "GrossCrossSectionArea": null, + "GrossWeight": "gross_get_weight", + "Length": "net_get_segment_length", + "NetCrossSectionArea": null, + "NetWeight": "net_get_weight", + "OuterSurfaceArea": null + } + }, + "IfcPlate + IfcPlateType": { + "Qto_PlateBaseQuantities": { + "GrossArea": "gross_get_max_side_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "NetArea": "net_get_max_side_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "Perimeter": null, + "Width": "net_get_min_xyz" + } + }, + "IfcProduct": { + "Qto_BodyGeometryValidation": { + "GrossSurfaceArea": null, + "GrossVolume": null, + "NetSurfaceArea": null, + "NetVolume": null, + "SurfaceGenusAfterFeatures": null, + "SurfaceGenusBeforeFeatures": null + } + }, + "IfcProjectionElement": { + "Qto_ProjectionElementBaseQuantities": { + "Area": null, + "Volume": null + } + }, + "IfcProtectiveDevice + IfcProtectiveDeviceType": { + "Qto_ProtectiveDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcProtectiveDeviceTrippingUnit + IfcProtectiveDeviceTrippingUnitType": { + "Qto_ProtectiveDeviceTrippingUnitBaseQuantities": { + "GrossWeight": null + } + }, + "IfcPump + IfcPumpType": { + "Qto_PumpBaseQuantities": { + "GrossWeight": null + } + }, + "IfcRail + IfcRailType": { + "Qto_RailBaseQuantities": { + "Length": null, + "Volume": null, + "Weight": null + } + }, + "IfcRailing + IfcRailingType": { + "Qto_RailingBaseQuantities": { + "Length": null + } + }, + "IfcRampFlight + IfcRampFlightType": { + "Qto_RampFlightBaseQuantities": { + "GrossArea": null, + "GrossVolume": null, + "Length": null, + "NetArea": null, + "NetVolume": null, + "Width": null + } + }, + "IfcReinforcedSoil": { + "Qto_ReinforcedSoilBaseQuantities": { + "Area": null, + "Depth": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcReinforcingElement + IfcReinforcingElementType": { + "Qto_ReinforcingElementBaseQuantities": { + "Count": null, + "Length": null, + "Weight": null + } + }, + "IfcRoof + IfcRoofType": { + "Qto_RoofBaseQuantities": { + "GrossArea": "gross_get_top_area", + "NetArea": "net_get_top_area", + "ProjectedArea": null + } + }, + "IfcSanitaryTerminal + IfcSanitaryTerminalType": { + "Qto_SanitaryTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcSensor + IfcSensorType": { + "Qto_SensorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcSign + IfcSignType": { + "Qto_SignBaseQuantities": { + "Height": null, + "Thickness": null, + "Weight": null, + "Width": null + } + }, + "IfcSign, PredefinedType=\"PICTORAL\" + IfcSignType, PredefinedType=\"PICTORAL\"": { + "Qto_PictorialSignQuantities": { + "Area": null, + "SignArea": null + } + }, + "IfcSignal + IfcSignalType": { + "Qto_SignalBaseQuantities": { + "Weight": null + } + }, + "IfcSite": { + "Qto_SiteBaseQuantities": { + "GrossArea": null, + "GrossPerimeter": null + } + }, + "IfcSlab + IfcSlabType": { + "Qto_SlabBaseQuantities": { + "Depth": "net_get_z", + "GrossArea": "gross_get_footprint_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "Length": "net_get_x", + "NetArea": "net_get_footprint_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "Perimeter": "net_get_footprint_perimeter", + "Width": "net_get_y" + } + }, + "IfcSolarDevice + IfcSolarDeviceType": { + "Qto_SolarDeviceBaseQuantities": { + "GrossArea": null, + "GrossWeight": null + } + }, + "IfcSpace + IfcSpaceType": { + "Qto_SpaceBaseQuantities": { + "FinishCeilingHeight": null, + "FinishFloorHeight": null, + "GrossCeilingArea": null, + "GrossFloorArea": null, + "GrossPerimeter": null, + "GrossVolume": null, + "GrossWallArea": null, + "Height": null, + "NetCeilingArea": null, + "NetFloorArea": null, + "NetPerimeter": null, + "NetVolume": null, + "NetWallArea": null + } + }, + "IfcSpaceHeater + IfcSpaceHeaterType": { + "Qto_SpaceHeaterBaseQuantities": { + "GrossWeight": null, + "Length": null, + "NetWeight": null + } + }, + "IfcSpatialZone + IfcSpatialZoneType": { + "Qto_SpatialZoneBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcStackTerminal + IfcStackTerminalType": { + "Qto_StackTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcStairFlight + IfcStairFlightType": { + "Qto_StairFlightBaseQuantities": { + "GrossVolume": null, + "Length": "net_get_max_xy", + "NetVolume": "net_get_volume" + } + }, + "IfcSurfaceFeature": { + "Qto_SurfaceFeatureBaseQuantities": { + "Area": null, + "Length": null + } + }, + "IfcSwitchingDevice + IfcSwitchingDeviceType": { + "Qto_SwitchingDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcTank + IfcTankType": { + "Qto_TankBaseQuantities": { + "GrossWeight": null, + "NetWeight": null, + "TotalSurfaceArea": null + } + }, + "IfcTrackElement, PredefinedType=\"SLEEPER\" + IfcTrackElementType, PredefinedType=\"SLEEPER\"": { + "Qto_SleeperBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcTransformer + IfcTransformerType": { + "Qto_TransformerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcTubeBundle + IfcTubeBundleType": { + "Qto_TubeBundleBaseQuantities": { + "GrossWeight": null, + "NetWeight": null + } + }, + "IfcUnitaryControlElement + IfcUnitaryControlElementType": { + "Qto_UnitaryControlElementBaseQuantities": { + "GrossWeight": null + } + }, + "IfcUnitaryEquipment + IfcUnitaryEquipmentType": { + "Qto_UnitaryEquipmentBaseQuantities": { + "GrossWeight": null + } + }, + "IfcValve + IfcValveType": { + "Qto_ValveBaseQuantities": { + "GrossWeight": null + } + }, + "IfcVehicle, PredefinedType=\"ROLLINGSTOCK\" + IfcVehicle, PredefinedType=\"VEHICLEAIR\" + IfcVehicle, PredefinedType=\"VEHICLEMARINE\" + IfcVehicle, PredefinedType=\"VEHICLE\" + IfcVehicle, PredefinedType=\"VEHICLETRACKED\" + IfcVehicleType, PredefinedType=\"ROLLINGSTOCK\" + IfcVehicleType, PredefinedType=\"VEHICLEAIR\" + IfcVehicleType, PredefinedType=\"VEHICLEMARINE\" + IfcVehicleType, PredefinedType=\"VEHICLE\" + IfcVehicleType, PredefinedType=\"VEHICLETRACKED\"": { + "Qto_VehicleBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcVibrationIsolator + IfcVibrationIsolatorType": { + "Qto_VibrationIsolatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcWall + IfcWallType": { + "Qto_WallBaseQuantities": { + "GrossFootPrintArea": null, + "GrossSideArea": "gross_get_side_area", + "GrossVolume": "gross_get_volume", + "GrossWeight": "gross_get_weight", + "Height": "net_get_z", + "Length": "net_get_x", + "NetFootPrintArea": null, + "NetSideArea": "net_get_side_area", + "NetVolume": "net_get_volume", + "NetWeight": "net_get_weight", + "Width": "net_get_y" + } + }, + "IfcWasteTerminal + IfcWasteTerminalType": { + "Qto_WasteTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcWindow + IfcWindowType": { + "Qto_WindowBaseQuantities": { + "Area": "net_get_max_side_area", + "Height": "net_get_z", + "Perimeter": null, + "Width": "net_get_x" + } + } + } + } +} diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json new file mode 100644 index 0000000000..a1351c07ff --- /dev/null +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -0,0 +1,810 @@ +{ + "name": "IFC4X3 Base Quantities - Blender", + "description": "This ruleset quantifies every single possible standardised base quantity in IFC4X3 using Blender.", + "calculators": { + "Blender": { + "IfcActuator + IfcActuatorType": { + "Qto_ActuatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAirTerminal + IfcAirTerminalType": { + "Qto_AirTerminalBaseQuantities": { + "GrossWeight": null, + "Perimeter": null, + "TotalSurfaceArea": null + } + }, + "IfcAirTerminalBox + IfcAirTerminalBoxType": { + "Qto_AirTerminalBoxTypeBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAirToAirHeatRecovery + IfcAirToAirHeatRecoveryType": { + "Qto_AirToAirHeatRecoveryBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAlarm + IfcAlarmType": { + "Qto_AlarmBaseQuantities": { + "GrossWeight": null + } + }, + "IfcAudioVisualAppliance + IfcAudioVisualApplianceType": { + "Qto_AudioVisualApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcBeam + IfcBeamType": { + "Qto_BeamBaseQuantities": { + "CrossSectionArea": "get_cross_section_area", + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetSurfaceArea": "get_net_surface_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcBoiler + IfcBoilerType": { + "Qto_BoilerBaseQuantities": { + "GrossWeight": null, + "NetWeight": null, + "TotalSurfaceArea": null + } + }, + "IfcBuilding": { + "Qto_BuildingBaseQuantities": { + "EavesHeight": null, + "FootPrintArea": null, + "GrossFloorArea": null, + "GrossVolume": null, + "Height": null, + "NetFloorArea": null, + "NetVolume": null + } + }, + "IfcBuildingElementProxy + IfcBuildingElementProxyType": { + "Qto_BuildingElementProxyQuantities": { + "NetSurfaceArea": "get_net_surface_area", + "NetVolume": "get_net_volume" + } + }, + "IfcBuildingStorey": { + "Qto_BuildingStoreyBaseQuantities": { + "GrossFloorArea": null, + "GrossHeight": null, + "GrossPerimeter": null, + "GrossVolume": null, + "NetFloorArea": null, + "NetHeight": null, + "NetVolume": null + } + }, + "IfcBurner + IfcBurnerType": { + "Qto_BurnerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableCarrierFitting + IfcCableCarrierFittingType": { + "Qto_CableCarrierFittingBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableCarrierSegment + IfcCableCarrierSegmentType": { + "Qto_CableCarrierSegmentBaseQuantities": { + "CrossSectionArea": null, + "GrossWeight": null, + "Length": null, + "OuterSurfaceArea": null + } + }, + "IfcCableCarrierSegment, PredefinedType=\"CONDUITSEGMENT\" + IfcCableCarrierSegmentType, PredefinedType=\"CONDUITSEGMENT\"": { + "Qto_ConduitSegmentBaseQuantities": { + "InnerDiameter": null, + "OuterDiameter": null + } + }, + "IfcCableFitting + IfcCableFittingType": { + "Qto_CableFittingBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCableSegment + IfcCableSegmentType": { + "Qto_CableSegmentBaseQuantities": { + "CrossSectionArea": null, + "GrossWeight": null, + "Length": "get_length", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcChiller + IfcChillerType": { + "Qto_ChillerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcChimney + IfcChimneyType": { + "Qto_ChimneyBaseQuantities": { + "Length": "get_height" + } + }, + "IfcCoil + IfcCoilType": { + "Qto_CoilBaseQuantities": { + "GrossWeight": null + } + }, + "IfcColumn + IfcColumnType": { + "Qto_ColumnBaseQuantities": { + "CrossSectionArea": "get_cross_section_area", + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetSurfaceArea": "get_net_surface_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcCommunicationsAppliance + IfcCommunicationsApplianceType": { + "Qto_CommunicationsApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCompressor + IfcCompressorType": { + "Qto_CompressorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCondenser + IfcCondenserType": { + "Qto_CondenserBaseQuantities": { + "GrossWeight": null + } + }, + "IfcConstructionEquipmentResource + IfcConstructionEquipmentResourceType": { + "Qto_ConstructionEquipmentResourceBaseQuantities": { + "OperatingTime": null, + "UsageTime": null + } + }, + "IfcConstructionMaterialResource + IfcConstructionMaterialResourceType": { + "Qto_ConstructionMaterialResourceBaseQuantities": { + "GrossVolume": "get_gross_volume", + "GrossWeight": null, + "NetVolume": "get_net_volume", + "NetWeight": null + } + }, + "IfcController + IfcControllerType": { + "Qto_ControllerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCooledBeam + IfcCooledBeamType": { + "Qto_CooledBeamBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCoolingTower + IfcCoolingTowerType": { + "Qto_CoolingTowerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcCourse + IfcCourseType": { + "Qto_CourseBaseQuantities": { + "GrossVolume": null, + "Length": null, + "Thickness": null, + "Volume": null, + "Weight": null, + "Width": null + } + }, + "IfcCovering + IfcCoveringType": { + "Qto_CoveringBaseQuantities": { + "GrossArea": "get_covering_gross_area", + "NetArea": "get_covering_net_area", + "Width": "get_covering_width" + } + }, + "IfcCurtainWall + IfcCurtainWallType": { + "Qto_CurtainWallQuantities": { + "GrossSideArea": null, + "Height": null, + "Length": null, + "NetSideArea": null, + "Width": null + } + }, + "IfcDamper + IfcDamperType": { + "Qto_DamperBaseQuantities": { + "GrossWeight": null + } + }, + "IfcDistributionChamberElement + IfcDistributionChamberElementType": { + "Qto_DistributionChamberElementBaseQuantities": { + "Depth": null, + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "NetSurfaceArea": "get_net_surface_area", + "NetVolume": "get_net_volume" + } + }, + "IfcDoor + IfcDoorType": { + "Qto_DoorBaseQuantities": { + "Area": "get_net_side_area", + "Height": "get_height", + "Perimeter": "get_rectangular_perimeter", + "Width": "get_length" + } + }, + "IfcDuctFitting + IfcDuctFittingType": { + "Qto_DuctFittingBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": "get_length", + "NetCrossSectionArea": null, + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcDuctSegment + IfcDuctSegmentType": { + "Qto_DuctSegmentBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": "get_length", + "NetCrossSectionArea": null, + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcDuctSilencer + IfcDuctSilencerType": { + "Qto_DuctSilencerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEarthworksCut": { + "Qto_EarthworksCutBaseQuantities": { + "Depth": null, + "Length": null, + "LooseVolume": null, + "UndisturbedVolume": null, + "Weight": null, + "Width": null + } + }, + "IfcEarthworksFill": { + "Qto_EarthworksFillBaseQuantities": { + "CompactedVolume": null, + "Depth": null, + "Length": null, + "LooseVolume": null, + "Width": null + } + }, + "IfcElectricAppliance + IfcElectricApplianceType": { + "Qto_ElectricApplianceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricDistributionBoard + IfcElectricDistributionBoardType": { + "Qto_DistributionBoardBaseQuantities": { + "GrossWeight": null, + "NumberOfCircuits": null + } + }, + "IfcElectricFlowStorageDevice + IfcElectricFlowStorageDeviceType": { + "Qto_ElectricFlowStorageDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricGenerator + IfcElectricGeneratorType": { + "Qto_ElectricGeneratorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricMotor + IfcElectricMotorType": { + "Qto_ElectricMotorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcElectricTimeControl + IfcElectricTimeControlType": { + "Qto_ElectricTimeControlBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEvaporativeCooler + IfcEvaporativeCoolerType": { + "Qto_EvaporativeCoolerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcEvaporator + IfcEvaporatorType": { + "Qto_EvaporatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFacilityPart": { + "Qto_FacilityPartBaseQuantities": { + "Area": null, + "Height": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcFan + IfcFanType": { + "Qto_FanBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFilter + IfcFilterType": { + "Qto_FilterBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFireSuppressionTerminal + IfcFireSuppressionTerminalType": { + "Qto_FireSuppressionTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFlowInstrument + IfcFlowInstrumentType": { + "Qto_FlowInstrumentBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFlowMeter + IfcFlowMeterType": { + "Qto_FlowMeterBaseQuantities": { + "GrossWeight": null + } + }, + "IfcFooting + IfcFootingType": { + "Qto_FootingBaseQuantities": { + "CrossSectionArea": "get_cross_section_area", + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Height": "get_height", + "Length": "get_length", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area", + "Width": "get_width" + } + }, + "IfcGeotechnicalStratum": { + "Qto_ArealStratumBaseQuantities": { + "Area": null, + "Length": null, + "PlanLength": null + }, + "Qto_LinearStratumBaseQuantities": { + "Diameter": null, + "Length": null + }, + "Qto_VolumetricStratumBaseQuantities": { + "Area": null, + "Mass": null, + "PlanArea": null, + "Volume": null + } + }, + "IfcHeatExchanger + IfcHeatExchangerType": { + "Qto_HeatExchangerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcHumidifier + IfcHumidifierType": { + "Qto_HumidifierBaseQuantities": { + "GrossWeight": null + } + }, + "IfcImpactProtectionDevice + IfcImpactProtectionDeviceType": { + "Qto_ImpactProtectionDeviceBaseQuantities": { + "Weight": null + } + }, + "IfcInterceptor + IfcInterceptorType": { + "Qto_InterceptorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcJunctionBox + IfcJunctionBoxType": { + "Qto_JunctionBoxBaseQuantities": { + "GrossWeight": null, + "Height": null, + "Length": null, + "NumberOfGangs": null, + "Width": null + } + }, + "IfcKerb + IfcKerbType": { + "Qto_KerbBaseQuantities": { + "Depth": null, + "Height": null, + "Length": null, + "Volume": null, + "Weight": null, + "Width": null + } + }, + "IfcLaborResource + IfcLaborResourceType": { + "Qto_LaborResourceBaseQuantities": { + "OvertimeWork": null, + "StandardWork": null + } + }, + "IfcLamp + IfcLampType": { + "Qto_LampBaseQuantities": { + "GrossWeight": null + } + }, + "IfcLightFixture + IfcLightFixtureType": { + "Qto_LightFixtureBaseQuantities": { + "GrossWeight": null + } + }, + "IfcMarineFacility": { + "Qto_MarineFacilityBaseQuantities": { + "Area": null, + "Height": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcMember + IfcMemberType": { + "Qto_MemberBaseQuantities": { + "CrossSectionArea": "get_cross_section_area", + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetSurfaceArea": "get_net_surface_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcMotorConnection + IfcMotorConnectionType": { + "Qto_MotorConnectionBaseQuantities": { + "GrossWeight": null + } + }, + "IfcOpeningElement": { + "Qto_OpeningElementBaseQuantities": { + "Area": "get_opening_mapping_area", + "Depth": "get_opening_depth", + "Height": "get_opening_height", + "Volume": "get_net_volume", + "Width": "get_length" + } + }, + "IfcOutlet + IfcOutletType": { + "Qto_OutletBaseQuantities": { + "GrossWeight": null + } + }, + "IfcPavement + IfcPavementType": { + "Qto_PavementBaseQuantities": { + "Depth": null, + "GrossArea": null, + "GrossVolume": null, + "Length": null, + "NetArea": null, + "NetVolume": null, + "Width": null + } + }, + "IfcPile + IfcPileType": { + "Qto_PileBaseQuantities": { + "CrossSectionArea": "get_cross_section_area", + "GrossSurfaceArea": "get_gross_surface_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcPipeFitting + IfcPipeFittingType": { + "Qto_PipeFittingBaseQuantities": { + "GrossCrossSectionArea": null, + "GrossWeight": null, + "Length": null, + "NetCrossSectionArea": null, + "NetWeight": null, + "OuterSurfaceArea": null + } + }, + "IfcPipeSegment + IfcPipeSegmentType": { + "Qto_PipeSegmentBaseQuantities": { + "FootPrintArea": null, + "GrossCrossSectionArea": null, + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetCrossSectionArea": "get_cross_section_area", + "NetWeight": "get_net_weight", + "OuterSurfaceArea": "get_outer_surface_area" + } + }, + "IfcPlate + IfcPlateType": { + "Qto_PlateBaseQuantities": { + "GrossArea": "get_gross_footprint_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "NetArea": "get_net_footprint_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "Perimeter": "get_gross_perimeter", + "Width": "get_height" + } + }, + "IfcProduct": { + "Qto_BodyGeometryValidation": { + "GrossSurfaceArea": null, + "GrossVolume": null, + "NetSurfaceArea": null, + "NetVolume": null, + "SurfaceGenusAfterFeatures": null, + "SurfaceGenusBeforeFeatures": null + } + }, + "IfcProjectionElement": { + "Qto_ProjectionElementBaseQuantities": { + "Area": "get_net_side_area", + "Volume": "get_net_volume" + } + }, + "IfcProtectiveDevice + IfcProtectiveDeviceType": { + "Qto_ProtectiveDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcProtectiveDeviceTrippingUnit + IfcProtectiveDeviceTrippingUnitType": { + "Qto_ProtectiveDeviceTrippingUnitBaseQuantities": { + "GrossWeight": null + } + }, + "IfcPump + IfcPumpType": { + "Qto_PumpBaseQuantities": { + "GrossWeight": null + } + }, + "IfcRail + IfcRailType": { + "Qto_RailBaseQuantities": { + "Length": null, + "Volume": null, + "Weight": null + } + }, + "IfcRailing + IfcRailingType": { + "Qto_RailingBaseQuantities": { + "Length": "get_length" + } + }, + "IfcRampFlight + IfcRampFlightType": { + "Qto_RampFlightBaseQuantities": { + "GrossArea": "get_gross_stair_area", + "GrossVolume": "get_gross_volume", + "Length": "get_stair_length", + "NetArea": "get_net_stair_area", + "NetVolume": "get_net_volume", + "Width": "get_width" + } + }, + "IfcReinforcedSoil": { + "Qto_ReinforcedSoilBaseQuantities": { + "Area": null, + "Depth": null, + "Length": null, + "Volume": null, + "Width": null + } + }, + "IfcReinforcingElement + IfcReinforcingElementType": { + "Qto_ReinforcingElementBaseQuantities": { + "Count": null, + "Length": "get_length", + "Weight": null + } + }, + "IfcRoof + IfcRoofType": { + "Qto_RoofBaseQuantities": { + "GrossArea": "get_gross_top_area", + "NetArea": "get_net_top_area", + "ProjectedArea": null + } + }, + "IfcSanitaryTerminal + IfcSanitaryTerminalType": { + "Qto_SanitaryTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcSensor + IfcSensorType": { + "Qto_SensorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcSign + IfcSignType": { + "Qto_SignBaseQuantities": { + "Height": null, + "Thickness": null, + "Weight": null, + "Width": null + } + }, + "IfcSign, PredefinedType=\"PICTORAL\" + IfcSignType, PredefinedType=\"PICTORAL\"": { + "Qto_PictorialSignQuantities": { + "Area": null, + "SignArea": null + } + }, + "IfcSignal + IfcSignalType": { + "Qto_SignalBaseQuantities": { + "Weight": null + } + }, + "IfcSite": { + "Qto_SiteBaseQuantities": { + "GrossArea": "get_gross_footprint_area", + "GrossPerimeter": "get_gross_perimeter" + } + }, + "IfcSlab + IfcSlabType": { + "Qto_SlabBaseQuantities": { + "Depth": "get_height", + "GrossArea": "get_gross_footprint_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Length": "get_length", + "NetArea": "get_net_footprint_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "Perimeter": "get_gross_perimeter", + "Width": "get_width" + } + }, + "IfcSolarDevice + IfcSolarDeviceType": { + "Qto_SolarDeviceBaseQuantities": { + "GrossArea": null, + "GrossWeight": null + } + }, + "IfcSpace + IfcSpaceType": { + "Qto_SpaceBaseQuantities": { + "FinishCeilingHeight": "get_finish_ceiling_height", + "FinishFloorHeight": "get_finish_floor_height", + "GrossCeilingArea": "get_gross_ceiling_area", + "GrossFloorArea": "get_gross_footprint_area", + "GrossPerimeter": "get_gross_perimeter", + "GrossVolume": "get_gross_volume", + "GrossWallArea": null, + "Height": "get_height", + "NetCeilingArea": "get_net_ceiling_area", + "NetFloorArea": "get_net_floor_area", + "NetPerimeter": null, + "NetVolume": "get_space_net_volume", + "NetWallArea": null + } + }, + "IfcSpaceHeater + IfcSpaceHeaterType": { + "Qto_SpaceHeaterBaseQuantities": { + "GrossWeight": null, + "Length": "get_length", + "NetWeight": null + } + }, + "IfcSpatialZone + IfcSpatialZoneType": { + "Qto_SpatialZoneBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcStackTerminal + IfcStackTerminalType": { + "Qto_StackTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcStairFlight + IfcStairFlightType": { + "Qto_StairFlightBaseQuantities": { + "GrossVolume": "get_gross_volume", + "Length": "get_stair_length", + "NetVolume": "get_net_volume" + } + }, + "IfcSurfaceFeature": { + "Qto_SurfaceFeatureBaseQuantities": { + "Area": null, + "Length": null + } + }, + "IfcSwitchingDevice + IfcSwitchingDeviceType": { + "Qto_SwitchingDeviceBaseQuantities": { + "GrossWeight": null + } + }, + "IfcTank + IfcTankType": { + "Qto_TankBaseQuantities": { + "GrossWeight": null, + "NetWeight": null, + "TotalSurfaceArea": "get_outer_surface_area" + } + }, + "IfcTrackElement, PredefinedType=\"SLEEPER\" + IfcTrackElementType, PredefinedType=\"SLEEPER\"": { + "Qto_SleeperBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcTransformer + IfcTransformerType": { + "Qto_TransformerBaseQuantities": { + "GrossWeight": null + } + }, + "IfcTubeBundle + IfcTubeBundleType": { + "Qto_TubeBundleBaseQuantities": { + "GrossWeight": null, + "NetWeight": null + } + }, + "IfcUnitaryControlElement + IfcUnitaryControlElementType": { + "Qto_UnitaryControlElementBaseQuantities": { + "GrossWeight": null + } + }, + "IfcUnitaryEquipment + IfcUnitaryEquipmentType": { + "Qto_UnitaryEquipmentBaseQuantities": { + "GrossWeight": null + } + }, + "IfcValve + IfcValveType": { + "Qto_ValveBaseQuantities": { + "GrossWeight": null + } + }, + "IfcVehicle, PredefinedType=\"ROLLINGSTOCK\" + IfcVehicle, PredefinedType=\"VEHICLEAIR\" + IfcVehicle, PredefinedType=\"VEHICLEMARINE\" + IfcVehicle, PredefinedType=\"VEHICLE\" + IfcVehicle, PredefinedType=\"VEHICLETRACKED\" + IfcVehicleType, PredefinedType=\"ROLLINGSTOCK\" + IfcVehicleType, PredefinedType=\"VEHICLEAIR\" + IfcVehicleType, PredefinedType=\"VEHICLEMARINE\" + IfcVehicleType, PredefinedType=\"VEHICLE\" + IfcVehicleType, PredefinedType=\"VEHICLETRACKED\"": { + "Qto_VehicleBaseQuantities": { + "Height": null, + "Length": null, + "Width": null + } + }, + "IfcVibrationIsolator + IfcVibrationIsolatorType": { + "Qto_VibrationIsolatorBaseQuantities": { + "GrossWeight": null + } + }, + "IfcWall + IfcWallType": { + "Qto_WallBaseQuantities": { + "GrossFootPrintArea": null, + "GrossSideArea": "get_gross_side_area", + "GrossVolume": "get_gross_volume", + "GrossWeight": "get_gross_weight", + "Height": "get_height", + "Length": "get_x", + "NetFootPrintArea": null, + "NetSideArea": "get_net_side_area", + "NetVolume": "get_net_volume", + "NetWeight": "get_net_weight", + "Width": "get_width" + } + }, + "IfcWasteTerminal + IfcWasteTerminalType": { + "Qto_WasteTerminalBaseQuantities": { + "GrossWeight": null + } + }, + "IfcWindow + IfcWindowType": { + "Qto_WindowBaseQuantities": { + "Area": "get_net_side_area", + "Height": "get_height", + "Perimeter": "get_rectangular_perimeter", + "Width": "get_length" + } + } + } + } +} diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index e23ae2f442..6856d78fba 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -30,12 +30,22 @@ import ifcopenshell.util.shape import ifcopenshell.util.representation import ifcopenshell.util.type import multiprocessing -from collections import namedtuple, defaultdict -from typing import Any, Literal, get_args, Union, Iterable +from collections import defaultdict +from typing import Any, Literal, get_args, Union, Iterable, NamedTuple -Function = namedtuple("Function", ["measure", "name", "description"]) -RULE_SET = Literal["IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"] +class Function(NamedTuple): + measure: str + name: str + description: str + + +RULE_SET = Literal[ + "IFC4QtoBaseQuantities", + "IFC4QtoBaseQuantitiesBlender", + "IFC4X3QtoBaseQuantities", + "IFC4X3QtoBaseQuantitiesBlender", +] rules: dict[RULE_SET, dict[str, Any]] = {} ResultsDict = dict[ifcopenshell.entity_instance, dict[str, dict[str, float]]] QtosFormulas = dict[str, dict[str, str]] @@ -47,10 +57,12 @@ for name in get_args(RULE_SET): def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict) -> ResultsDict: - """ + """Quantify elements from a rules using preset quantification rules + + Rules placed as a JSON configuration file in the ``ifc5d`` folder will be + autodetected and loaded with the module for convenience. :param rules: Set of rules from `ifc5d.qto.rules`. - """ results: ResultsDict = {} elements_by_classes: defaultdict[str, set[ifcopenshell.entity_instance]] = defaultdict(set) @@ -59,13 +71,8 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst for calculator, queries in rules["calculators"].items(): calculator = calculators[calculator] - for ifc_class, qtos in queries.items(): - filtered_elements = set() - ifc_classes = [ifc_class] + ifcopenshell.util.type.get_applicable_types(ifc_class, ifc_file.schema) - for ifc_class in ifc_classes: - if ifc_class not in elements_by_classes: - continue - filtered_elements.update(elements_by_classes[ifc_class]) + for query, qtos in queries.items(): + filtered_elements = ifcopenshell.util.selector.filter_elements(ifc_file, query, elements) if filtered_elements: calculator.calculate(ifc_file, filtered_elements, qtos, results) return results @@ -159,7 +166,23 @@ class IteratorForTypes: return True -class IfcOpenShell: +class QtoCalculator: + """Abstract class for Qto calculators.""" + + functions: dict[str, Function] + + @classmethod + def calculate( + cls, + ifc_file: ifcopenshell.file, + elements: set[ifcopenshell.entity_instance], + qtos: dict[str, dict[str, Union[str, None]]], + results: ResultsDict, + ) -> None: + raise NotImplementedError + + +class IfcOpenShell(QtoCalculator): """Calculates Model body context geometry using the default IfcOpenShell iterator on triangulation elements.""" @@ -211,6 +234,14 @@ class IfcOpenShell: ), # IfcVolumeMeasure "get_volume": Function("IfcVolumeMeasure", "Volume", "Calculates the volume of a manifold shape"), + # IfcMassMeasure + "get_weight": Function( + "IfcMassMeasure", + "Weight", + "The weight of the object based on it's length and Pset_ProfileMechanical.MassPerLength " + "(for profile based objects, though objects with openings are not supported for net calculations)" + "or it's volume and material density (from Pset_MaterialCommon.MassDensity).", + ), } functions = {} @@ -218,14 +249,13 @@ class IfcOpenShell: functions[f"gross_{k}"] = Function(v.measure, f"Gross {v.name}", v.description) functions[f"net_{k}"] = Function(v.measure, f"Net {v.name}", v.description) + internal_functions = ( + "get_segment_length", + "get_weight", + ) + @classmethod - def calculate( - cls, - ifc_file: ifcopenshell.file, - elements: set[ifcopenshell.entity_instance], - qtos: dict[str, dict[str, Union[str, None]]], - results: ResultsDict, - ) -> None: + def calculate(cls, ifc_file, elements, qtos, results): formula_functions: dict[str, types.FunctionType] = {} cls.gross_settings = ifcopenshell.geom.settings() @@ -241,7 +271,7 @@ class IfcOpenShell: if not formula: continue gross_or_net_qtos = gross_qtos if formula.startswith("gross_") else net_qtos - if formula.endswith("get_segment_length"): + if formula.endswith(cls.internal_functions): gross_or_net_qtos.setdefault(name, {})[quantity] = formula.partition("_")[2] elif formula.startswith(("gross_", "net_")): formula = formula.partition("_")[2] @@ -255,7 +285,7 @@ class IfcOpenShell: tasks.append((iterator, gross_qtos)) if net_qtos: - for iterator in IfcOpenShell.create_iterators(ifc_file, cls.gross_settings, list(elements)): + for iterator in IfcOpenShell.create_iterators(ifc_file, cls.net_settings, list(elements)): tasks.append((iterator, net_qtos)) cls.unit_converter = SI2ProjectUnitConverter(ifc_file) @@ -263,6 +293,7 @@ class IfcOpenShell: for iterator, qtos_ in tasks: if iterator.initialize(): while True: + geometry: ifcopenshell.geom.main.ShapeType if isinstance(iterator, ifcopenshell.geom.iterator): shape = iterator.get() geometry = shape.geometry @@ -275,12 +306,19 @@ class IfcOpenShell: results[element].setdefault(name, {}) for quantity, formula in quantities.items(): if formula == "get_segment_length": - results[element][name][quantity] = cls.get_segment_length(element) + value = cls.get_segment_length(element) + if value is None: + continue + elif formula == "get_weight": + calculation_type = "GROSS" if iterator.settings is cls.gross_settings else "NET" + value = cls.get_weight(element, geometry, calculation_type) + if value is None: + continue else: - results[element][name][quantity] = cls.unit_converter.convert( - formula_functions[formula](geometry), - IfcOpenShell.raw_functions[formula].measure, - ) + value = formula_functions[formula](geometry) + assert isinstance(value, (float, int)) + value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure) + results[element][name][quantity] = value if not iterator.next(): break @@ -301,7 +339,13 @@ class IfcOpenShell: return iterators @classmethod - def get_segment_length(cls, element: ifcopenshell.entity_instance) -> float: + def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]: + """Get segment length. + + :param element: IFC element entity. + :return: ``float`` segment length in project units + or ``None`` if element doesn't have a representation or it's not supported. + """ rep = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if rep and len(rep.Items or []) == 1 and rep.Items[0].is_a("IfcExtrudedAreaSolid"): item = rep.Items[0] @@ -324,12 +368,68 @@ class IfcOpenShell: z = item.Depth return max([x, y, z]) + @classmethod + def get_weight( + cls, + element: ifcopenshell.entity_instance, + geometry: ifcopenshell.geom.ShapeType, + calculation_type: Literal["GROSS", "NET"], + ) -> Union[float, None]: + """Get element's weight. -class Blender: + :param element: IFC element entity. + :return: ``float`` weight in project units + or ``None`` if mass density calculation for this element is not supported. + """ + + if calculation_type == "gross" or not ifcopenshell.util.element.has_openings(element): + weight = cls.get_weight_profile_based(element) + if weight is not None: + return weight + + density = ifcopenshell.util.element.get_element_mass_density(element) + if density is None: + return + volume = ifcopenshell.util.shape.get_volume(geometry) + return volume * density + + @classmethod + def get_weight_profile_based(cls, element: ifcopenshell.entity_instance) -> Union[float, None]: + """Get weight of the profile based element. + + :return: A float weight value if calculation was successful + or ``None`` if it's either not profile based object + or it's not supported. + """ + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not representation: + return None + items = representation.Items + if not all(item.is_a("IfcExtrudedAreaSolid") for item in items): + return None + mass = 0.0 + for item in items: + profile = item.SweptArea + # TODO: there are also bunch of other similar props we will need to consider in the future. + # Examples: + # - Pset_CableSegmentTypeBusBarSegment.MassPerLength + # - Pset_CableCarrierSegmentTypeCatenaryWire.MassPerLength + mass_per_length = ifcopenshell.util.element.get_pset(profile, "Pset_ProfileMechanical", "MassPerLength") + if not isinstance(mass_per_length, float): + return None + mass += mass_per_length * item.Depth + return mass + + +class Blender(QtoCalculator): """Calculates geometry based on currently loaded Blender objects.""" + # Implementations are located in bonsai.bim.module.qto.calculator. functions = { # IfcLengthMeasure + "get_x": Function("IfcLengthMeasure", "X", ""), + "get_y": Function("IfcLengthMeasure", "Y", ""), + "get_z": Function("IfcLengthMeasure", "Z", ""), "get_covering_width": Function("IfcLengthMeasure", "Covering Width", ""), "get_finish_ceiling_height": Function("IfcLengthMeasure", "Finish Ceiling Height", ""), "get_finish_floor_height": Function("IfcLengthMeasure", "Finish Floor Height", ""), @@ -369,35 +469,55 @@ class Blender: "get_net_weight": Function("IfcMassMeasure", "Net Weight", ""), } - @staticmethod - def calculate( - ifc_file: ifcopenshell.file, - elements: set[ifcopenshell.entity_instance], - qtos: dict[str, dict[str, Union[str, None]]], - results: ResultsDict, - ) -> None: + description_populated = False + + @classmethod + def populate_descriptions(cls) -> None: + """Populate the descriptions based on the function docstrings. + + The action is postponed to ensure ifc5d package works without Blender. + """ + if cls.description_populated: + return + + import bonsai.bim.module.qto.calculator as calculator + + for function in cls.functions: + doc = getattr(calculator, function).__doc__ or "" + doc = doc[: doc.find(":param")].strip() + old_function = cls.functions[function] + cls.functions[function] = Function(old_function.measure, old_function.name, doc) + + @classmethod + def calculate(cls, ifc_file, elements, qtos, results): import bonsai.tool as tool import bonsai.bim.module.qto.calculator as calculator unit_converter = SI2ProjectUnitConverter(ifc_file) - formula_functions = {} + formula_functions: dict[str, types.FunctionType] = {} for element in elements: obj = tool.Ifc.get_object(element) if not obj or obj.type != "MESH": continue - results.setdefault(element, {}) + element_results = {} for name, quantities in qtos.items(): - results[element].setdefault(name, {}) + qto_results = {} for quantity, formula in quantities.items(): if not formula: continue if not (formula_function := formula_functions.get(formula)): formula_function = formula_functions[formula] = getattr(calculator, formula) if (value := formula_function(obj)) is not None: - results[element][name][quantity] = unit_converter.convert( - value, Blender.functions[formula].measure - ) + qto_results[quantity] = unit_converter.convert(value, Blender.functions[formula].measure) + if qto_results: + element_results[name] = qto_results + # Avoid adding empty qsets if nothing was calculated. + if element_results: + results[element] = element_results -calculators = {"Blender": Blender, "IfcOpenShell": IfcOpenShell} +calculators: dict[str, type[QtoCalculator]] = { + "Blender": Blender, + "IfcOpenShell": IfcOpenShell, +} diff --git a/src/ifcconvert/IfcConvert.cpp b/src/ifcconvert/IfcConvert.cpp index f6918fe396..71fb8cab28 100644 --- a/src/ifcconvert/IfcConvert.cpp +++ b/src/ifcconvert/IfcConvert.cpp @@ -300,6 +300,10 @@ int main(int argc, char** argv) { "Can take several minutes on large models.") ("center-model-geometry", "Centers the elements by applying the center point of all mesh vertices as an offset.") + ("model-offset", po::value(&offset_str), + "Applies an arbitrary offset of form 'x;y;z' to all placements.") + ("model-rotation", po::value(&rotation_str), + "Applies an arbitrary quaternion rotation of form 'x;y;z;w' to all placements.") ("include", po::value(&include_filter)->multitoken(), "Specifies that the instances that match a specific filtering criteria are to be included in the geometrical output:\n" "1) 'entities': the following list of types should be included. SVG output defaults " @@ -450,6 +454,8 @@ int main(int argc, char** argv) { const bool center_model = vmap.count("center-model") != 0; const bool center_model_geometry = vmap.count("center-model-geometry") != 0; + const bool model_offset = vmap.count("model-offset") != 0; + const bool model_rotation = vmap.count("model-rotation") != 0; if (!quiet || vmap.count("version")) { print_version(); @@ -951,6 +957,44 @@ int main(int argc, char** argv) { } else { Logger::SetOutput(quiet ? nullptr : &cout_, vcounter.count > 1 ? &cout_ : &log_stream); } + + if (model_rotation) { + std::vector rotation(4); + int n = 0; + if (sscanf(rotation_str.c_str(), "%lf;%lf;%lf;%lf %n", &rotation[0], &rotation[1], &rotation[2], &rotation[3], &n) != 4 || n != rotation_str.size()) { + cerr_ << "[Error] Invalid use of --model-rotation\n"; + IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename)); + print_options(serializer_options); + return EXIT_FAILURE; + } + + std::stringstream msg; + msg << "Using model rotation (" << rotation[0] << "," << rotation[1] << "," << rotation[2] << "," << rotation[3] << ")"; + Logger::Notice(msg.str()); + + geometry_settings.get().value = rotation; + } + + if (model_offset && (center_model || center_model_geometry)) { + Logger::Notice("--model-offset ignored with --center-model or --center-model-geometry"); + } + + if (model_offset && !(center_model || center_model_geometry)) { + std::vector offset(3); + int n = 0; + if (sscanf(offset_str.c_str(), "%lf;%lf;%lf %n", &offset[0], &offset[1], &offset[2], &n) != 3 || n != offset_str.size()) { + cerr_ << "[Error] Invalid use of --model-offset\n"; + IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename)); + print_options(serializer_options); + return EXIT_FAILURE; + } + + std::stringstream msg; + msg << std::setprecision(std::numeric_limits::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")"; + Logger::Notice(msg.str()); + + geometry_settings.get().value = offset; + } if (is_tesselated && (center_model || center_model_geometry)) { std::vector offset(3); @@ -984,7 +1028,7 @@ int main(int argc, char** argv) { offset[2] = -center(2); std::stringstream msg; - msg << std::setprecision (std::numeric_limits< double >::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")"; + msg << std::setprecision (std::numeric_limits::max_digits10) << "Using model offset (" << offset[0] << "," << offset[1] << "," << offset[2] << ")"; Logger::Notice(msg.str()); geometry_settings.get().value = offset; diff --git a/src/ifcgeom/AbstractKernel.cpp b/src/ifcgeom/AbstractKernel.cpp index c5ecbc0684..80b7eb63c9 100644 --- a/src/ifcgeom/AbstractKernel.cpp +++ b/src/ifcgeom/AbstractKernel.cpp @@ -75,12 +75,12 @@ bool is_valid_for_kernel(const ifcopenshell::geometry::kernels::AbstractKernel* } class HybridKernel : public ifcopenshell::geometry::kernels::AbstractKernel { - std::vector kernels_; + std::vector> kernels_; ifcopenshell::geometry::abstract_mapping* mapping_; public: - HybridKernel(const std::string& name, IfcParse::IfcFile* file, Settings& settings, std::vector kernels) + HybridKernel(const std::string& name, IfcParse::IfcFile* file, Settings& settings, std::vector>&& kernels) : AbstractKernel(name, settings) - , kernels_(kernels) + , kernels_(std::move(kernels)) , mapping_(ifcopenshell::geometry::impl::mapping_implementations().construct(file, settings)) {} virtual bool convert(const taxonomy::ptr item, IfcGeom::ConversionResults& rs) { @@ -88,7 +88,7 @@ public: bool has_openings = ops && ops->size(); for (auto& k : kernels_) { #ifdef IFOPSH_WITH_CGAL - if (has_openings && dynamic_cast(k)) { + if (has_openings && dynamic_cast(k.get())) { // @todo this would fail later on in the find_openings() call, because we have a // SimpleCgalShape which cannot be used on a kernel that supports booleans. // @todo 1 implement the translation between various conversion result shapes @@ -138,7 +138,7 @@ public: for (auto& k : kernels_) { bool is_valid = true; for (auto& s : entity_shapes) { - if (!is_valid_for_kernel(k, s)) { + if (!is_valid_for_kernel(k.get(), s)) { is_valid = false; break; } @@ -179,7 +179,7 @@ ifcopenshell::geometry::kernels::AbstractKernel* ifcopenshell::geometry::kernels if (geometry_library_lower.rfind("hybrid-", 0) == 0) { geometry_library_lower = geometry_library_lower.substr(strlen("hybrid")); - std::vector kernels; + std::vector> kernels; while (!geometry_library_lower.empty()) { if (geometry_library_lower.find("-", 0) == 0) { geometry_library_lower = geometry_library_lower.substr(strlen("-")); @@ -189,19 +189,19 @@ ifcopenshell::geometry::kernels::AbstractKernel* ifcopenshell::geometry::kernels auto n = kernels.size(); #ifdef IFOPSH_WITH_OPENCASCADE if (geometry_library_lower.find("opencascade", 0) == 0) { - kernels.push_back(new IfcGeom::OpenCascadeKernel(conv_settings)); + kernels.emplace_back(new IfcGeom::OpenCascadeKernel(conv_settings)); geometry_library_lower = geometry_library_lower.substr(strlen("opencascade")); } #endif #ifdef IFOPSH_WITH_CGAL if (geometry_library_lower.find("cgal-simple", 0) == 0) { - kernels.push_back(new SimpleCgalKernel(conv_settings)); + kernels.emplace_back(new SimpleCgalKernel(conv_settings)); geometry_library_lower = geometry_library_lower.substr(strlen("cgal-simple")); } if (geometry_library_lower.find("cgal", 0) == 0) { - kernels.push_back(new CgalKernel(conv_settings)); + kernels.emplace_back(new CgalKernel(conv_settings)); geometry_library_lower = geometry_library_lower.substr(strlen("cgal")); } #endif @@ -215,7 +215,7 @@ ifcopenshell::geometry::kernels::AbstractKernel* ifcopenshell::geometry::kernels } if (!kernels.empty()) { - return new HybridKernel(geometry_library, file, conv_settings, kernels); + return new HybridKernel(geometry_library, file, conv_settings, std::move(kernels)); } } diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index 65b11ba394..a82be36b05 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -47,7 +47,9 @@ namespace ifcopenshell { // boost program options does not seem to handle optional types, so in case // of vector settings we need to strip away the optional and detect argument presence // with !vector::empty() - std::conditional_t>, T, boost::optional> value; + // tfk: we no longer do this because negative values can not be passed like this as boost confuses them with options + // std::conditional_t>, T, boost::optional> value; + boost::optional value; SettingBase() {} @@ -64,14 +66,15 @@ namespace ifcopenshell { value.emplace(); desc.add_options()(Derived::name, apply_default(po::bool_switch(&*value)), Derived::description); } else if constexpr (std::is_same_v>) { - desc.add_options()(Derived::name, apply_default(po::value(&value)->multitoken()), Derived::description); + // these options have to be supplied manually in IfcConvert.cpp + // desc.add_options()(Derived::name, apply_default(po::value(&value)->multitoken()), Derived::description); } else { desc.add_options()(Derived::name, apply_default(po::value(&value)), Derived::description); } } T get() const { - if constexpr (std::is_same_v>) { + if constexpr (false && std::is_same_v>) { return value; } else { if (value) { @@ -85,7 +88,7 @@ namespace ifcopenshell { } bool has() const { - if constexpr (std::is_same_v>) { + if constexpr (false && std::is_same_v>) { return !value.empty(); } else { // @todo this is not reliable, better use vmap[...].defaulted() @@ -365,6 +368,12 @@ namespace ifcopenshell { static constexpr bool defaultvalue = false; }; + struct ComputeCurvature : public SettingBase { + static constexpr const char* const name = "compute-curvature"; + static constexpr const char* const description = "Specifies whether function_item_evaluator.evaluate() computes curvature."; + static constexpr bool defaultvalue = false; + }; + enum FunctionStepMethod { MAXSTEPSIZE, MINSTEPS }; @@ -385,12 +394,12 @@ namespace ifcopenshell { struct ModelOffset : public SettingBase> { static constexpr const char* const name = "model-offset"; - static constexpr const char* const description = "Applies an arbitrary offset of form 'x,y,z' to all placements."; + static constexpr const char* const description = "Applies an arbitrary offset of form x,y,z to all placements."; }; struct ModelRotation : public SettingBase> { static constexpr const char* const name = "model-rotation"; - static constexpr const char* const description = "Applies an arbitrary quaternion rotation of form 'x,y,z,w' to all placements."; + static constexpr const char* const description = "Applies an arbitrary quaternion rotation of form x,y,z,w to all placements."; }; enum TriangulationMethod { @@ -407,7 +416,73 @@ namespace ifcopenshell { static constexpr TriangulationMethod defaultvalue = TRIANGLE_MESH; }; + struct CgalEmitOriginalEdges : public SettingBase { + static constexpr const char* const name = "cgal-original-edges"; + static constexpr const char* const description = "Try to emit original edge face boundary edges instead of recomputed ones based on face normal. Falls back to triangulated data in case of boolean operands and faces with holes."; + static constexpr bool defaultvalue = false; + }; + } + + namespace impl { + template + struct readable_name { + static constexpr const char* name = "Unknown Type"; + }; + template <> + struct readable_name { + static constexpr const char* name = "bool"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "int"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "double"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "std::string"; + }; + + template <> + struct readable_name> { + static constexpr const char* name = "std::set"; + }; + + template <> + struct readable_name> { + static constexpr const char* name = "std::set"; + }; + + template <> + struct readable_name> { + static constexpr const char* name = "std::vector"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "IteratorOutputOptions"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "FunctionStepMethod"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "OutputDimensionalityTypes"; + }; + + template <> + struct readable_name { + static constexpr const char* name = "TriangulationMethod"; + }; } template @@ -437,17 +512,34 @@ namespace ifcopenshell { } } + template + std::string get_type_(const std::string& name) const { + if (std::tuple_element_t::name == name) { + return impl::readable_name::base_type>::name; + } + if constexpr (Index + 1 < std::tuple_size_v) { + return get_type_(name); + } else { + throw std::runtime_error("Setting not available"); + } + } + template void set_option_(const std::string& name, const value_variant_t& val) { if (std::tuple_element_t::name == name) { if constexpr (std::is_enum_v::base_type>) { - if (val.which() == 1) { - auto val_as_enum = (typename std::tuple_element_t::base_type) boost::get(val); + if (auto* val_ptr = boost::get(&val)) { + auto val_as_enum = (typename std::tuple_element_t::base_type) *val_ptr; std::get(settings).value = val_as_enum; return; } } - std::get(settings).value = boost::get::base_type>(val); + try { + std::get(settings).value = boost::get::base_type>(val); + } catch (const boost::bad_get&) { + std::string ty = impl::readable_name::base_type>::name; + throw std::runtime_error("Expected a value of type <" + ty + "> for setting '" + name + "'"); + } } else if constexpr (Index + 1 < std::tuple_size_v) { set_option_(name, val); } else { @@ -492,6 +584,10 @@ namespace ifcopenshell { set_option_<0>(name, val); } + std::string get_type(const std::string& name) { + return get_type_<0>(name); + } + std::vector setting_names() const { std::vector r; get_setting_names_<0>(r); @@ -500,7 +596,7 @@ namespace ifcopenshell { }; class IFC_GEOM_API Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } diff --git a/src/ifcgeom/Converter.cpp b/src/ifcgeom/Converter.cpp index 9324e84329..9c6739292d 100644 --- a/src/ifcgeom/Converter.cpp +++ b/src/ifcgeom/Converter.cpp @@ -13,6 +13,16 @@ ifcopenshell::geometry::Converter::Converter(const std::string& geometry_library settings_ = mapping_->settings(); } +ifcopenshell::geometry::Converter::~Converter() +{ + if (kernel_ != nullptr) { + delete kernel_; + } + if (mapping_ != nullptr) { + delete mapping_; + } +} + namespace { void substitute_with_box_based_on_density(IfcGeom::ConversionResults& items, double& density) { int nv = 0; diff --git a/src/ifcgeom/Converter.h b/src/ifcgeom/Converter.h index 900faf827a..f0dcc72515 100644 --- a/src/ifcgeom/Converter.h +++ b/src/ifcgeom/Converter.h @@ -28,7 +28,7 @@ namespace ifcopenshell { namespace geometry { Converter(const std::string& geometry_library, IfcParse::IfcFile* file, ifcopenshell::geometry::Settings& settings); - ~Converter() {} + ~Converter(); ifcopenshell::geometry::abstract_mapping* mapping() const { return mapping_; } diff --git a/src/ifcgeom/Iterator.h b/src/ifcgeom/Iterator.h index 78c6ddc532..199566bc14 100644 --- a/src/ifcgeom/Iterator.h +++ b/src/ifcgeom/Iterator.h @@ -150,10 +150,6 @@ namespace IfcGeom { int done; int total; - // @todo these appear uninitialized? - std::string unit_name_; - double unit_magnitude_; - ifcopenshell::geometry::taxonomy::point3 bounds_min_; ifcopenshell::geometry::taxonomy::point3 bounds_max_; @@ -166,8 +162,8 @@ namespace IfcGeom { public: void set_cache(GeometrySerializer* cache) { cache_ = cache; } - const std::string& unit_name() const { return unit_name_; } - double unit_magnitude() const { return unit_magnitude_; } + const std::string& unit_name() const { return converter_->mapping()->get_length_unit_name(); } + double unit_magnitude() const { return converter_->mapping()->get_length_unit(); } // Check if error occurred during iterator initialization or iteration over elements. bool had_error_processing_elements() const { return had_error_processing_elements_; } @@ -667,6 +663,10 @@ namespace IfcGeom { /// Use get() to retrieve the created geometry. const IfcUtil::IfcBaseClass* next() { using std::chrono::high_resolution_clock; + + delete *task_result_iterator_; + delete *native_task_result_iterator_; + if (num_threads_ != 1) { if (!wait_for_element()) { Logger::SetProduct(boost::none); @@ -889,20 +889,19 @@ namespace IfcGeom { init_future_.wait(); } } - - if (settings_.get().get() != ifcopenshell::geometry::settings::NATIVE) { - for (auto& p : all_processed_native_elements_) { - delete p; - } - } for (auto& k : kernel_pool) { delete k; } - - for (auto& p : all_processed_elements_) { - delete p; + + if (task_result_ptr_initialized) { + while (task_result_iterator_ != --all_processed_elements_.end()) { + delete *task_result_iterator_++; + delete *native_task_result_iterator_++; + } } + + delete converter_; } }; } diff --git a/src/ifcgeom/abstract_mapping.h b/src/ifcgeom/abstract_mapping.h index c8174d5363..c8ba61e1b4 100644 --- a/src/ifcgeom/abstract_mapping.h +++ b/src/ifcgeom/abstract_mapping.h @@ -45,6 +45,7 @@ namespace geometry { virtual const IfcUtil::IfcBaseEntity* get_product_type(const IfcUtil::IfcBaseEntity*) = 0; virtual const IfcUtil::IfcBaseEntity* get_single_material_association(const IfcUtil::IfcBaseEntity*) = 0; virtual double get_length_unit() const = 0; + virtual const std::string& get_length_unit_name() const = 0; virtual IfcUtil::IfcBaseEntity* representation_of(const IfcUtil::IfcBaseEntity* product) = 0; const Settings& settings() const { return settings_; } diff --git a/src/ifcgeom/function_item_evaluator.cpp b/src/ifcgeom/function_item_evaluator.cpp index 9ca71b3450..b4dade6ab8 100644 --- a/src/ifcgeom/function_item_evaluator.cpp +++ b/src/ifcgeom/function_item_evaluator.cpp @@ -1,8 +1,25 @@ #include "function_item_evaluator.h" #include "profile_helper.h" +#include + using namespace ifcopenshell::geometry; +std::vector ifcopenshell::geometry::helmert_curve_point(double A0, double A1, double A2, double s) { + auto theta = [A0, A1, A2](double t) -> double { + auto a0 = A0 ? t / A0 : 0.0; + auto a1 = A1 ? A1 * std::pow(t, 2) / (2 * fabs(std::pow(A1, 3))) : 0.0; + auto a2 = A2 ? std::pow(t, 3) / (3 * std::pow(A2, 3)) : 0.0; + return a0 + a1 + a2; + }; + + auto fn_x = [theta](double t) -> double { return cos(theta(t)); }; + auto fn_y = [theta](double t) -> double { return sin(theta(t)); }; + auto x = boost::math::quadrature::trapezoidal(fn_x, 0.0, s); + auto y = boost::math::quadrature::trapezoidal(fn_y, 0.0, s); + auto angle = theta(x); + return {x, y, angle}; +} struct functor_fn_evaluator : public fn_evaluator { functor_fn_evaluator(taxonomy::functor_item::const_ptr fn, const ifcopenshell::geometry::Settings& settings) : fn_evaluator(settings), @@ -89,12 +106,27 @@ struct gradient_fn_evaluator : public fn_evaluator { auto xy = horizontal_evaluator_.evaluate(u + start_); auto uz = vertical_evaluator_.evaluate(u); - uz.col(3)(0) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal + // curvature is stored in row 3 - capture it and remove it from the xy and uz matrices + // so the matrix operations (ie multiplication) works correct.y + auto horizontal_curvature = xy.row(3); + xy.row(3) = Eigen::Vector4d(0, 0, 0, 1); + + auto vertical_curvature = uz.row(3); + uz.row(3) = Eigen::Vector4d(0, 0, 0, 1); + + uz(0, 3) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z uz.row(1).swap(uz.row(2)); Eigen::Matrix4d m; m = xy * uz; // combine horizontal and vertical + + // Put curvature back into the solution matrix + // curvature for vertical is in column 0, need it to be in column 1 + // so it doesn't add to curvature for horizontal + std::swap(vertical_curvature(3, 0), vertical_curvature(3, 1)); + m.row(3) = horizontal_curvature + vertical_curvature; + return m; } @@ -121,6 +153,15 @@ struct cant_fn_evaluator : public fn_evaluator { auto g = gradient_evaluator_.evaluate(u + start_); auto c = cant_evaluator_.evaluate(u); + + // curvature is stored in row 3 - capture it and remove it from the xy and uz matrices + // so the matrix operations (ie multiplication) works correctly + auto gradient_curvature = g.row(3); + g.row(3) = Eigen::Vector4d(0, 0, 0, 1); + + auto cant_curvature = c.row(3); + c.row(3) = Eigen::Vector4d(0, 0, 0, 1); + // Need to multiply g and c so the axis vectors // from cant have the correct rotation applied so // they are relative to the gradient curve coordinate system @@ -147,6 +188,11 @@ struct cant_fn_evaluator : public fn_evaluator { m(1, 3) = y; m(2, 3) = z + s; + // reinstate values for curvature. + // cant_curvature is cant alone. this needs to be combined with gradient in column 3 + gradient_curvature[3] = gradient_curvature[2] + cant_curvature[3]; + m.row(3) = gradient_curvature; + return m; } @@ -266,5 +312,9 @@ taxonomy::item::ptr function_item_evaluator::evaluate(const std::vector& } Eigen::Matrix4d function_item_evaluator::evaluate(double u) const { - return fn_evaluator_->evaluate(u); + Eigen::Matrix4d m = fn_evaluator_->evaluate(u); + if (!fn_evaluator_->settings_.get().get()) { + m.row(3) = Eigen::Vector4d(0, 0, 0, 1); + } + return m; } diff --git a/src/ifcgeom/function_item_evaluator.h b/src/ifcgeom/function_item_evaluator.h index 0e29c6149c..a936bfbe89 100644 --- a/src/ifcgeom/function_item_evaluator.h +++ b/src/ifcgeom/function_item_evaluator.h @@ -7,6 +7,10 @@ namespace ifcopenshell { namespace geometry { +/// @brief Computes a point on a helmert curve at s. +/// Returns (x,y,theta) at L/2. The results are in a vector so they can be returned to python +std::vector helmert_curve_point(double A0, double A1, double A2, double s); + /// @brief Abstract class for evaluating a function_item. This class is specialized for each of the function_item types. struct fn_evaluator { fn_evaluator(const ifcopenshell::geometry::Settings& settings) : settings_(settings) { @@ -55,7 +59,7 @@ class function_item_evaluator { /// @brief evaluates the function at u /// @param u u is constrained to be between start_ and start_+length - /// @return 4x4 placement matrix + /// @return 4x4 placement matrix. Curvature values for horizontal, vertical, and vertical + cant are stored in the last row. Eigen::Matrix4d evaluate(double u) const; private: diff --git a/src/ifcgeom/infra_sweep_helper.cpp b/src/ifcgeom/infra_sweep_helper.cpp index 7559d4c9d6..f3f79d4c18 100644 --- a/src/ifcgeom/infra_sweep_helper.cpp +++ b/src/ifcgeom/infra_sweep_helper.cpp @@ -63,31 +63,37 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index + 1) - *profile_index); const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry; const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset; + const auto& rotation_a = cross_sections[std::distance(longitudes.begin(), profile_index)].rotation; taxonomy::geom_item::ptr interpolated = nullptr; // Only interpolate if: // - there is a profile ahead of us, and - // - we're not exactly at the location of the current profile or whether there is an offset involved. + // - we're not exactly at the location of the current profile or whether there is an offset involved bool should_interpolate = (profile_index + 1 < longitudes.end()) && - (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0.); + (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0. || rotation_a); + + boost::optional interpolated_rotation; if (should_interpolate) { taxonomy::geom_item::ptr profile_b; Eigen::Vector3d offset_b; + boost::optional rotation_b; if ((profile_index + 1 < longitudes.end())) { profile_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].section_geometry; offset_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].offset; + rotation_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].rotation; } else { profile_b = profile_a; offset_b = offset_a; + rotation_b = rotation_a; } // Only interpolate if the profiles are different or either of the offsets is non-zero bool should_interpolate2 = (profile_a->instance != profile_b->instance) || - (offset_a.cwiseAbs().maxCoeff() > 0. || offset_b.cwiseAbs().maxCoeff() > 0.); + (offset_a.cwiseAbs().maxCoeff() > 0. || offset_b.cwiseAbs().maxCoeff() > 0. || rotation_b); if (should_interpolate2) { @@ -130,6 +136,13 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, } auto interpolated_offset = lerp(offset_a, offset_b, relative_dist_along); + if (rotation_a && rotation_b) { + // @todo we don't support an overridden rotation on only one of the placements + // in which case we would need to lerp with the rotation component below in m4b. + interpolated_rotation = lerp(*rotation_a, *rotation_b, relative_dist_along); + } else { + Logger::Error("Direction vectors on cross section placements only supported when used consistently"); + } taxonomy::loop::ptr w1, w2; taxonomy::edge::ptr e1, e2; for (auto tmp_ : boost::combine(loops_a, loops_b)) { @@ -149,6 +162,7 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, auto& p2 = boost::get(e2->start); auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + // auto p4 = (interpolated_rotation * p3).eval(); points.push_back(taxonomy::make(p3)); } if (!points.empty()) { @@ -173,9 +187,16 @@ taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, }*/ Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); - m4b.col(0).head<3>() = m4.col(1).head<3>().normalized(); - m4b.col(1).head<3>() = m4.col(2).head<3>().normalized(); - m4b.col(2).head<3>() = m4.col(0).head<3>().normalized(); + if (interpolated_rotation) { + // direction vectors on the linear placement overwrite the placement otherwise inferred from the tangent + m4b.col(0).head<3>() = interpolated_rotation->col(1); + m4b.col(1).head<3>() = interpolated_rotation->col(2); + m4b.col(2).head<3>() = interpolated_rotation->col(0); + } else { + m4b.col(0).head<3>() = m4.col(1).head<3>().normalized(); + m4b.col(1).head<3>() = m4.col(2).head<3>().normalized(); + m4b.col(2).head<3>() = m4.col(0).head<3>().normalized(); + } m4b.col(3).head<3>() = m4.col(3).head<3>(); if (interpolated) { diff --git a/src/ifcgeom/infra_sweep_helper.h b/src/ifcgeom/infra_sweep_helper.h index 351206dd03..bacc4bd4f0 100644 --- a/src/ifcgeom/infra_sweep_helper.h +++ b/src/ifcgeom/infra_sweep_helper.h @@ -12,6 +12,7 @@ namespace ifcopenshell { double dist_along; taxonomy::geom_item::ptr section_geometry; Eigen::Vector3d offset; + boost::optional rotation; bool operator <(const cross_section& other) const { return dist_along < other.dist_along; diff --git a/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp b/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp index ac991e40a3..e7a3b2a9ce 100644 --- a/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp +++ b/src/ifcgeom/kernels/cgal/CgalConversionResult.cpp @@ -144,10 +144,15 @@ ifcopenshell::geometry::CgalShape::CgalShape(const cgal_shape_t& shape, bool con } if (shape.size_of_facets() != 1) { - // this is for handling the specical case of storing a single point in a polyhedron, + // the size_of_facets() == 1 check is for handling the specical case of + // storing a single point in a polyhedron as a degenerate triangle + // // @todo come up with a proper variant for storing lower dimensional entities - CGAL::Polygon_mesh_processing::triangulate_faces(*shape_); - CGAL::Polygon_mesh_processing::remove_degenerate_faces(*shape_); + + // @todo we don't have access to settings here so we don't know whether we should triangulate + // remove_degenerate_faces() is also called in the triangulate() call below though... + // CGAL::Polygon_mesh_processing::triangulate_faces(*shape_); + // CGAL::Polygon_mesh_processing::remove_degenerate_faces(*shape_); } } @@ -183,6 +188,15 @@ void ifcopenshell::geometry::CgalShape::Triangulate(ifcopenshell::geometry::Sett // ... also becuase of transforming the vertex positions, right? cgal_shape_t s = *this; + const bool setting_use_original_edges = settings.get().get(); + + std::set> original_edges; + if (setting_use_original_edges) { + for (auto it = s.edges_begin(); it != s.edges_end(); ++it) { + original_edges.insert({ it->vertex()->point(), it->prev()->vertex()->point() }); + } + } + if (!place.is_identity()) { const auto& m = place.ccomponents(); @@ -199,14 +213,11 @@ void ifcopenshell::geometry::CgalShape::Triangulate(ifcopenshell::geometry::Sett } if (!std::all_of(s.facets_begin(), s.facets_end(), [](auto f) { return f.is_triangle(); })) { - if (!s.is_valid()) { Logger::Message(Logger::LOG_ERROR, "Invalid Polyhedron_3 in object (before triangulation)"); return; } - CGAL::Polygon_mesh_processing::remove_degenerate_faces(s); - bool success = false; try { success = CGAL::Polygon_mesh_processing::triangulate_faces(s); @@ -215,27 +226,29 @@ void ifcopenshell::geometry::CgalShape::Triangulate(ifcopenshell::geometry::Sett return; } + CGAL::Polygon_mesh_processing::remove_degenerate_faces(s); + if (!success) { Logger::Message(Logger::LOG_ERROR, "Triangulation failed"); return; } - // std::cout << "Triangulated model: " << s.size_of_facets() << " facets and " << s.size_of_vertices() << " vertices" << std::endl; if (!s.is_valid()) { Logger::Message(Logger::LOG_ERROR, "Invalid Polyhedron_3 in object (after triangulation)"); // return; } - } // Facet -> planar component map for determining which // edges are to be registered. std::vector> components; - partition_coplanar_components(s, components); std::map facet_to_component; - for (auto it = components.begin(); it != components.end(); ++it) { - for (auto& f : *it) { - facet_to_component[f] = it; + if (!setting_use_original_edges) { + partition_coplanar_components(s, components); + for (auto it = components.begin(); it != components.end(); ++it) { + for (auto& f : *it) { + facet_to_component[f] = it; + } } } @@ -305,8 +318,10 @@ void ifcopenshell::geometry::CgalShape::Triangulate(ifcopenshell::geometry::Sett } vertexidx[i] = (int)vidx; - is_face_boundary[i] = facet_to_component[face] != facet_to_component[current_halfedge->opposite()->face()]; - + is_face_boundary[i] = setting_use_original_edges + ? original_edges.find({ current_halfedge->vertex()->point(), current_halfedge->prev()->vertex()->point() }) != original_edges.end() + : facet_to_component[face] != facet_to_component[current_halfedge->opposite()->face()]; + ++i; ++num_vertices; ++current_halfedge; diff --git a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp index 14efa8f65e..15e94bff05 100644 --- a/src/ifcgeom/kernels/opencascade/boolean_utils.cpp +++ b/src/ifcgeom/kernels/opencascade/boolean_utils.cpp @@ -929,11 +929,11 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To bool is_2d = count(a, TopAbs_FACE) > 0 && count(a, TopAbs_SHELL) == 0; bool success = false; - BRepAlgoAPI_BooleanOperation* builder; + std::unique_ptr builder; TopTools_ListOfShape b_tmp; if (op == BOPAlgo_CUT) { - builder = new BRepAlgoAPI_Cut(); + builder.reset(new BRepAlgoAPI_Cut()); if (do_subtraction_eliminate_disjoint_bbox) { PERF("boolean subtraction: eliminate disjoint bbox"); @@ -968,9 +968,9 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To } } else if (op == BOPAlgo_COMMON) { - builder = new BRepAlgoAPI_Common(); + builder.reset(new BRepAlgoAPI_Common()); } else if (op == BOPAlgo_FUSE) { - builder = new BRepAlgoAPI_Fuse(); + builder.reset(new BRepAlgoAPI_Fuse()); } else { return false; } @@ -1429,7 +1429,6 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To Logger::Notice(str_str); } } - delete builder; if (!success) { if (allow_retry) { return boolean_operation(settings, a, b, op, result, new_fuzziness); diff --git a/src/ifcgeom/mapping/IfcArbitraryClosedProfileDef.cpp b/src/ifcgeom/mapping/IfcArbitraryClosedProfileDef.cpp index 6a51091f99..3cce6f31dc 100644 --- a/src/ifcgeom/mapping/IfcArbitraryClosedProfileDef.cpp +++ b/src/ifcgeom/mapping/IfcArbitraryClosedProfileDef.cpp @@ -26,6 +26,10 @@ using namespace ifcopenshell::geometry; taxonomy::ptr mapping::map_impl(const IfcSchema::IfcArbitraryClosedProfileDef* inst) { auto loop = taxonomy::cast(map(inst->OuterCurve())); if (loop) { + if (inst->ProfileType() == IfcSchema::IfcProfileTypeEnum::IfcProfileType_CURVE) { + return loop; + } + auto face = taxonomy::make(); loop->external = true; face->children = { loop }; diff --git a/src/ifcgeom/mapping/IfcCurveSegment.cpp b/src/ifcgeom/mapping/IfcCurveSegment.cpp index f82569f840..bfe8a191ca 100644 --- a/src/ifcgeom/mapping/IfcCurveSegment.cpp +++ b/src/ifcgeom/mapping/IfcCurveSegment.cpp @@ -100,18 +100,15 @@ typedef boost::mpl::vector< struct parent_curve_function { parent_curve_function() = default; parent_curve_function(const parent_curve_function&) = default; - parent_curve_function(std::function fn) : fn_(fn) { - } - - parent_curve_function& operator=(std::function fn) { - fn_ = fn; - return *this; + parent_curve_function(std::function fn, std::function cfn) : fn_(fn), cfn_(cfn) { } virtual Eigen::Matrix4d operator()(double u) const { return fn_(u); } + virtual Eigen::Matrix4d curvature(double u) const { return cfn_(u); } private: std::function fn_; + std::function cfn_; }; struct polynomial_parent_curve : public parent_curve_function { @@ -142,7 +139,7 @@ struct curve_segment_function { Eigen::Matrix4d operator()(double u) const { Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u); Eigen::Matrix4d curve_segment_point = curve_segment_placement_ * remove_parent_curve_rotation_ * remove_parent_curve_translation_ * parent_curve_point; - return curve_segment_point; + return curve_segment_point + parent_curve_fn_->curvature(u); } private: @@ -167,7 +164,7 @@ struct cant_curve_segment_function { Eigen::Matrix4d parent_curve_point = (*parent_curve_fn_)(u); Eigen::Matrix4d cant_increment = parent_curve_point - parent_curve_start_point_; Eigen::Matrix4d curve_segment_point = curve_segment_placement_ + cant_increment; - return curve_segment_point; + return curve_segment_point + parent_curve_fn_->curvature(u); } private: @@ -246,11 +243,12 @@ class curve_segment_evaluator { Logger::Error(std::runtime_error("multiple uses of IfcSegmentCurve not supported"), inst_); } - segment_type_ = is_horizontal ? ST_HORIZONTAL : is_vertical ? ST_VERTICAL : ST_CANT; + segment_type_ = is_horizontal ? ST_HORIZONTAL : is_vertical ? ST_VERTICAL : is_cant ? ST_CANT : ST_HORIZONTAL; start_ = translate_if_param_value(inst->ParentCurve(), inst->SegmentStart()) * length_unit; length_ = translate_if_param_value(inst->ParentCurve(), inst->SegmentLength()) * length_unit; + projected_length_ = length_; // initialize with something reasonable if (inst) { curve_segment_placement_ = taxonomy::cast(mapping_->map(inst->Placement()))->ccomponents(); @@ -335,10 +333,8 @@ class curve_segment_evaluator { } } - void set_spiral_function(double s, std::function fnX, std::function fnY) { + void set_spiral_function(double s, std::function fnX, std::function fnY, std::function curvature) { if (segment_type_ == ST_HORIZONTAL || segment_type_ == ST_VERTICAL) { - projected_length_ = length_; - // start of trimmed curve double pcStartX = 0.0, pcStartY = 0.0; double pcStartDx = 1.0, pcStartDy = 0.0; @@ -382,30 +378,71 @@ class curve_segment_evaluator { }; } - parent_curve_fn_ = std::make_shared([start=start_, s, convert_u, fnX, fnY](double u) { - u = convert_u(u+start); + parent_curve_fn_ = std::make_shared( + [start=start_, s, convert_u, fnX, fnY](double u)->Eigen::Matrix4d { + u = convert_u(u+start); - // integration limits, integrate from a to b - auto b = s ? u / s : 0.0; + // integration limits, integrate from a to b + auto b = s ? u / s : 0.0; - // point on parent curve - auto x = boost::math::quadrature::trapezoidal(fnX, 0.0, b); - auto y = boost::math::quadrature::trapezoidal(fnY, 0.0, b); - auto dx = s ? fnX(b) / s : 1.0; - auto dy = s ? fnY(b) / s : 0.0; + // point on parent curve + auto x = boost::math::quadrature::trapezoidal(fnX, 0.0, b); + auto y = boost::math::quadrature::trapezoidal(fnY, 0.0, b); + auto dx = s ? fnX(b) / s : 1.0; + auto dy = s ? fnY(b) / s : 0.0; - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = Eigen::Vector4d(dx, dy, 0, 0); - m.col(1) = Eigen::Vector4d(-dy, dx, 0, 0); - m.col(3) = Eigen::Vector4d(x, y, 0, 1); - return m; - }); + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.col(0) = Eigen::Vector4d(dx, dy, 0, 0); + m.col(1) = Eigen::Vector4d(-dy, dx, 0, 0); + m.col(3) = Eigen::Vector4d(x, y, 0, 1); + return m; + }, + [start = start_, convert_u, curvature](double u) -> Eigen::Matrix4d { + u = convert_u(u + start); + Eigen::Matrix4d c = Eigen::Matrix4d::Zero(); + c(3, 0) = curvature(u); + return c; + } + ); + + if (segment_type_ == ST_VERTICAL) { + // for vertical, the input curve length is measured along the spiral. + // projected_length_ is the domain of the curve, measured in the horizontal "Distance Along" coordinate + // The quickest way to get the projected length is the difference of the i-ordinates at the start and end of the spiral. + + // Evalute the parent curve at the start and end + Eigen::Matrix4d m1 = (*parent_curve_fn_)(start_); + Eigen::Matrix4d m2 = (*parent_curve_fn_)(start_ + length_); + + // parent curve point at start + double x1 = m1(0, 3); + double y1 = m1(1, 3); + + // parent curve point at end + double x2 = m2(0, 3); + double y2 = m2(1, 3); + + // direction of tangent at start of parent curve in curve coordinates + auto dx = (*curve_segment_placement_)(0, 0); + auto dy = -(*curve_segment_placement_)(1, 0); // -1 to rotation in opposite direction + auto X1 = x1 * dx - y1 * dy; // X of start point in global coordinates + auto X2 = x2 * dx - y2 * dy; // X of end point in global coordinates + projected_length_ = X2 - X1; // distance between points on the global X-axis + } else { + projected_length_ = length_; + } } else if (segment_type_ == ST_CANT) { Logger::Error(std::runtime_error("Unexpected segment type encountered - cant is handled in set_cant_spiral_function - should never get here")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); } + ); } else { Logger::Error(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); } + ); } } @@ -424,33 +461,40 @@ class curve_segment_evaluator { auto end_cant = Cant(/* start_ + */ length_); auto delta_cant = end_cant - start_cant; - parent_curve_fn_ = std::make_shared([start_angle,delta_angle,start_cant,delta_cant,Superelevation, SuperelevationSlope, Cant](double u) -> Eigen::Matrix4d { - // departure of the curve segment from the base curve (superelevation) - auto super_elevation = Superelevation(u); - auto slope = SuperelevationSlope(u); + parent_curve_fn_ = std::make_shared( + [start_angle,delta_angle,start_cant,delta_cant,Superelevation, SuperelevationSlope, Cant](double u) -> Eigen::Matrix4d { + // departure of the curve segment from the base curve (superelevation) + auto super_elevation = Superelevation(u); + auto slope = SuperelevationSlope(u); - // direction along curve segment - auto angle = atan(slope); - auto dx = cos(angle); - auto dy = sin(angle); - Eigen::Vector4d ref_dir(dx, dy, 0.0, 0.0); + // direction along curve segment + auto angle = atan(slope); + auto dx = cos(angle); + auto dy = sin(angle); + Eigen::Vector4d ref_dir(dx, dy, 0.0, 0.0); - // tilt angle in the plane of the cross section - auto cant = Cant(u); - auto tilt_angle = start_angle + delta_angle * (cant - start_cant) / delta_cant; - Eigen::Vector4d z(0.0, cos(tilt_angle), sin(tilt_angle), 0.0); + // tilt angle in the plane of the cross section + auto cant = Cant(u); + auto tilt_angle = start_angle + delta_angle * (cant - start_cant) / delta_cant; + Eigen::Vector4d z(0.0, cos(tilt_angle), sin(tilt_angle), 0.0); - // compute axis direction - Eigen::Vector4d y = z.cross3(ref_dir); - Eigen::Vector4d axis = ref_dir.cross3(y); + // compute axis direction + Eigen::Vector4d y = z.cross3(ref_dir); + Eigen::Vector4d axis = ref_dir.cross3(y); - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = ref_dir; - m.col(1) = y; - m.col(2) = axis; - m.col(3) = Eigen::Vector4d(u, super_elevation, 0.0, 1.0); - return m; - }); + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.col(0) = ref_dir; + m.col(1) = y; + m.col(2) = axis; + m.col(3) = Eigen::Vector4d(u, super_elevation, 0.0, 1.0); + return m; + }, + [Cant](double u) -> Eigen::Matrix4d { + Eigen::Matrix4d c = Eigen::Matrix4d::Zero(); + c(3, 0) = Cant(u); + return c; + } + ); parent_curve_start_point_ = (*parent_curve_fn_)(0.0); } @@ -500,7 +544,8 @@ class curve_segment_evaluator { auto s = fabs(A * sqrt(PI)); // curve length when u = 1.0 auto fn_x = [A, s](double t) -> double { return A ? s * cos(PI * A * t * t / (2 * fabs(A))) : 0.0; }; auto fn_y = [A, s](double t) -> double { return A ? s * sin(PI * A * t * t / (2 * fabs(A))) : 0.0; }; - set_spiral_function(s, fn_x, fn_y); + auto curvature = [A](double t) -> double { return A ? A * t / fabs(A * A * A) : 0.0; }; + set_spiral_function(s, fn_x, fn_y, curvature); } } #endif @@ -522,8 +567,13 @@ class curve_segment_evaluator { }; auto fn_x = [theta](double t) -> double { return cos(theta(t)); }; auto fn_y = [theta](double t) -> double { return sin(theta(t)); }; + auto curvature = [constant_term, cosine_term, L](double t) -> double { + auto a0 = constant_term.has_value() ? L / constant_term.value() : 0.0; + auto a1 = (L / cosine_term) * cos((PI / L) * t); + return a0 + a1; + }; double s = 1.0; - set_spiral_function(s, fn_x, fn_y); + set_spiral_function(s, fn_x, fn_y, curvature); } else if (segment_type_ == ST_CANT) { boost::optional> super, slope; std::tie(super, slope) = get_superelevation_functions(); @@ -548,10 +598,16 @@ class curve_segment_evaluator { set_cant_spiral_function(*super, *slope, cant); } else if (segment_type_ == ST_VERTICAL) { Logger::Error(std::runtime_error("IfcCosineSpiral cannot be used for vertical alignment")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); } + ); } else { Logger::Error(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); } + ); } } #endif @@ -577,8 +633,14 @@ class curve_segment_evaluator { }; auto fn_x = [theta](double t) -> double { return cos(theta(t)); }; auto fn_y = [theta](double t) -> double { return sin(theta(t)); }; + auto curvature = [constant_term, linear_term, sine_term, L](double t) -> double { + auto a0 = constant_term.has_value() ? L / constant_term.value() : 0.0; + auto a1 = linear_term.has_value() ? sign(linear_term.value()) * pow(L / linear_term.value(), 2.0)*(t/L) : 0.0; + auto a2 = (L / sine_term) * sin(2 * PI * t / L); + return a0 + a1 + a2; + }; double s = 1.0; - set_spiral_function(s, fn_x, fn_y); + set_spiral_function(s, fn_x, fn_y, curvature); } else if (segment_type_ == ST_CANT) { boost::optional> super, slope; std::tie(super, slope) = get_superelevation_functions(); @@ -605,16 +667,20 @@ class curve_segment_evaluator { set_cant_spiral_function(*super, *slope, cant); } else if (segment_type_ == ST_VERTICAL) { Logger::Error(std::runtime_error("IfcSineSpiral cannot be used for vertical alignment")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } else { Logger::Error(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } } #endif void polynomial_spiral(boost::optional A0, boost::optional A1, boost::optional A2, boost::optional A3, boost::optional A4, boost::optional A5, boost::optional A6, boost::optional A7) { - auto theta = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, lu = length_unit_](double t) { + auto theta = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_ * length_unit_, lu = length_unit_](double t) -> double { auto a0 = A0.has_value() ? t / (A0.value() * lu) : 0.0; auto a1 = A1.has_value() ? A1.value() * lu * std::pow(t, 2) / (2 * fabs(std::pow(A1.value() * lu, 3))) : 0.0; auto a2 = A2.has_value() ? std::pow(t, 3) / (3 * std::pow(A2.value() * lu, 3)) : 0.0; @@ -629,15 +695,31 @@ class curve_segment_evaluator { auto fn_x = [theta](double t) -> double { return cos(theta(t)); }; auto fn_y = [theta](double t) -> double { return sin(theta(t)); }; + + // this is same as cant function in polynomial_cant_spiral + auto curvature = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_, L = length_, lu = length_unit_, length = length_](double t) -> double { + t += start; + auto a0 = A0.has_value() ? 1 / (A0.value() * lu) : 0.0; + auto a1 = A1.has_value() ? A1.value() * lu * t / fabs(std::pow(A1.value() * lu, 3)) : 0.0; + auto a2 = A2.has_value() ? std::pow(t, 2) / std::pow(A2.value() * lu, 3) : 0.0; + auto a3 = A3.has_value() ? A3.value() * lu * std::pow(t, 3) / fabs(std::pow(A3.value() * lu, 5)) : 0.0; + auto a4 = A4.has_value() ? std::pow(t, 4) / std::pow(A4.value() * lu, 5) : 0.0; + auto a5 = A5.has_value() ? A5.value() * lu * std::pow(t, 5) / fabs(std::pow(A5.value() * lu, 7)) : 0.0; + auto a6 = A6.has_value() ? std::pow(t, 6) / std::pow(A6.value() * lu, 7) : 0.0; + auto a7 = A7.has_value() ? A7.value() * lu * std::pow(t, 7) / fabs(std::pow(A7.value() * lu, 9)) : 0.0; + return L * (a0 + a1 + a2 + a3 + a4 + a5 + a6 + a7); + }; + + double s = 1.0; - set_spiral_function(s, fn_x, fn_y); + set_spiral_function(s, fn_x, fn_y, curvature); } void polynomial_cant_spiral(boost::optional A0, boost::optional A1, boost::optional A2, boost::optional A3, boost::optional A4, boost::optional A5, boost::optional A6, boost::optional A7) { boost::optional> super, slope; std::tie(super, slope) = get_superelevation_functions(); - auto cant = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_, L = length_, lu = length_unit_, length = length_](double t) { + auto cant = [A0, A1, A2, A3, A4, A5, A6, A7, start = start_, L = length_, lu = length_unit_, length = length_](double t) -> double { t += start; auto a0 = A0.has_value() ? 1 / (A0.value() * lu) : 0.0; auto a1 = A1.has_value() ? A1.value() * lu * t / fabs(std::pow(A1.value() * lu, 3)) : 0.0; @@ -655,7 +737,7 @@ class curve_segment_evaluator { } if (!slope.has_value()) { - slope = [A1, A2, A3, A4, A5, A6, A7, start = start_, L = length_, lu = length_unit_, length = length_](double t) { + slope = [A1, A2, A3, A4, A5, A6, A7, start = start_, L = length_, lu = length_unit_, length = length_](double t) -> double { t += start; auto a1 = A1.has_value() ? A1.value() * lu / fabs(std::pow(A1.value() * lu, 3)) : 0.0; auto a2 = A2.has_value() ? 2 * t / std::pow(A2.value() * lu, 3) : 0.0; @@ -787,29 +869,36 @@ class curve_segment_evaluator { }; } - parent_curve_fn_ = std::make_shared([segment_type = segment_type_, R, pcCenterX, pcCenterY, start_angle, sign_l, convert_u](double u) { - u = convert_u(u); + parent_curve_fn_ = std::make_shared( + [segment_type = segment_type_, R, pcCenterX, pcCenterY, start_angle, sign_l, convert_u](double u)->Eigen::Matrix4d { + u = convert_u(u); - // u is measured along the circle - // angle from the X=0 axis to the current point - auto delta = R ? sign_l * u / R : 0.0; - auto sweep_angle = start_angle + delta; - auto cos_sweep_angle = cos(sweep_angle); - auto sin_sweep_angle = sin(sweep_angle); + // u is measured along the circle + // angle from the X=0 axis to the current point + auto delta = R ? sign_l * u / R : 0.0; + auto sweep_angle = start_angle + delta; + auto cos_sweep_angle = cos(sweep_angle); + auto sin_sweep_angle = sin(sweep_angle); - // point on the parent curve - auto pcX = R * cos_sweep_angle + pcCenterX; - auto pcY = R * sin_sweep_angle + pcCenterY; + // point on the parent curve + auto pcX = R * cos_sweep_angle + pcCenterX; + auto pcY = R * sin_sweep_angle + pcCenterY; - auto pcDx = -sign_l * sin_sweep_angle; - auto pcDy = sign_l * cos_sweep_angle; + auto pcDx = -sign_l * sin_sweep_angle; + auto pcDy = sign_l * cos_sweep_angle; - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0); - m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0); - m.col(3) = Eigen::Vector4d(pcX, pcY, 0.0, 1.0); - return m; - }); + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0); + m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0); + m.col(3) = Eigen::Vector4d(pcX, pcY, 0.0, 1.0); + return m; + }, + [R](double) -> Eigen::Matrix4d { + Eigen::Matrix4d c = Eigen::Matrix4d::Zero(); + c(3, 0) = 1 / R; + return c; + } + ); if (segment_type_ == ST_HORIZONTAL) { parent_curve_start_point_ = (*parent_curve_fn_)(start_); @@ -839,10 +928,14 @@ class curve_segment_evaluator { } else if (segment_type_ == ST_CANT) { Logger::Warning(std::runtime_error("Use of IfcCircle for cant is not supported")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } else { Logger::Error(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } } @@ -890,23 +983,32 @@ class curve_segment_evaluator { convert_u = [pcDx](double u) { return u/pcDx; }; } - parent_curve_fn_ = std::make_shared([pcX, pcY, pcDx, pcDy, convert_u](double u) { - u = convert_u(u); + parent_curve_fn_ = std::make_shared( + [pcX, pcY, pcDx, pcDy, convert_u](double u)->Eigen::Matrix4d { + u = convert_u(u); - auto x = pcX + pcDx * u; - auto y = pcY + pcDy * u; + auto x = pcX + pcDx * u; + auto y = pcY + pcDy * u; - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0); - m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0); - m.col(3) = Eigen::Vector4d(x, y, 0.0, 1.0); - return m; - }); + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.col(0) = Eigen::Vector4d(pcDx, pcDy, 0, 0); + m.col(1) = Eigen::Vector4d(-pcDy, pcDx, 0, 0); + m.col(3) = Eigen::Vector4d(x, y, 0.0, 1.0); + return m; + }, + [](double /*u*/) -> Eigen::Matrix4d { + // curvature is zero for a line. identity initializes c(3,0) = 0 + Eigen::Matrix4d c = Eigen::Matrix4d::Zero(); + return c; + } + ); parent_curve_start_point_ = (*parent_curve_fn_)(start_); } else { - Logger::Warning(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + Logger::Warning(std::runtime_error("Unexpected segment type encountered")); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } } @@ -996,50 +1098,62 @@ class curve_segment_evaluator { } // This functor evaluates the polynomial at a distance u along the curve - parent_curve_fn_ = std::make_shared([start = start_, lu = length_unit_, coeffX, coeffY, convert_u](double u)->Eigen::Matrix4d { - auto x = convert_u(u + start); // find x for u - // evaluate the polynomial at x - std::array*, 2> coefficients{&coeffX, &coeffY}; - std::array position{0.0, 0.0}; // = SUM(coeff*u^pos) - std::array slope{0.0, 0.0}; // slope is derivative of the curve = SUM( coeff*pos*u^(pos-1) ) - for (int i = 0; i < 2; i++) { // loop over X and Y - auto begin = coefficients[i]->cbegin(); - auto end = coefficients[i]->cend(); - for (auto iter = begin; iter != end; iter++) { - auto exp = std::distance(begin, iter); - auto coeff = (*iter); - position[i] += coeff * pow(lu, 1-exp) * pow(x, exp); + parent_curve_fn_ = std::make_shared( + [start = start_, lu = length_unit_, coeffX, coeffY, convert_u](double u)->Eigen::Matrix4d { + auto x = convert_u(u + start); // find x for u + // evaluate the polynomial at x + std::array*, 2> coefficients{&coeffX, &coeffY}; + std::array position{0.0, 0.0}; // = SUM(coeff*u^pos) + std::array slope{0.0, 0.0}; // slope is derivative of the curve = SUM( coeff*pos*u^(pos-1) ) + for (int i = 0; i < 2; i++) { // loop over X and Y + auto begin = coefficients[i]->cbegin(); + auto end = coefficients[i]->cend(); + for (auto iter = begin; iter != end; iter++) { + auto exp = std::distance(begin, iter); + auto coeff = (*iter); + position[i] += coeff * pow(lu, 1-exp) * pow(x, exp); - if (iter != begin) { - slope[i] += exp * coeff * pow(lu, 1-exp) * pow(x, exp - 1); - } - } + if (iter != begin) { + slope[i] += exp * coeff * pow(lu, 1-exp) * pow(x, exp - 1); + } + } + } + + auto X = position[0]; + auto Y = position[1]; + + auto Dx = slope[0]; + auto Dy = slope[1]; + + auto angle = atan2(Dy, Dx); + Dx = cos(angle); + Dy = sin(angle); + + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.col(0) = Eigen::Vector4d(Dx, Dy, 0, 0); + m.col(1) = Eigen::Vector4d(-Dy, Dx, 0, 0); + m.col(3) = Eigen::Vector4d(X, Y, 0.0, 1.0); + return m; + }, + [start = start_, lu = length_unit_, coeffX, coeffY, convert_u](double u) -> Eigen::Matrix4d { + auto x = convert_u(u + start); // find x for u + Eigen::Matrix4d c = Eigen::Matrix4d::Zero(); + c(3, 0) = coeffY[2]; // this may need a unit conversion (also assume there is only 3 coefficients) + return c; } - - auto X = position[0]; - auto Y = position[1]; - - auto Dx = slope[0]; - auto Dy = slope[1]; - - auto angle = atan2(Dy, Dx); - Dx = cos(angle); - Dy = sin(angle); - - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.col(0) = Eigen::Vector4d(Dx, Dy, 0, 0); - m.col(1) = Eigen::Vector4d(-Dy, Dx, 0, 0); - m.col(3) = Eigen::Vector4d(X, Y, 0.0, 1.0); - return m; - }); + ); parent_curve_start_point_ = (*parent_curve_fn_)(0.0); // start is added to u in parent_curve_fn_, so use 0.0 here } else if (segment_type_ == ST_CANT) { Logger::Warning(std::runtime_error("Use of IfcPolynomialCurve for cant is not supported")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } else { Logger::Error(std::runtime_error("Unexpected segment type encountered")); - parent_curve_fn_ = std::make_shared([](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); + parent_curve_fn_ = std::make_shared( + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }, + [](double /*u*/) -> Eigen::Matrix4d { return Eigen::Matrix4d::Identity(); }); } } }; diff --git a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp index df790d2738..e6d3afdfce 100644 --- a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp +++ b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp @@ -39,7 +39,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOffsetCurveByDistances* inst auto first_offset_value = *(offset_values->begin()); auto basis_curve = inst->BasisCurve(); - auto curve = taxonomy::dcast(map(basis_curve)); + auto curve = taxonomy::dcast(map(basis_curve)); + if (!curve) { + // Only implement on alignment curves + Logger::Warning("IfcOffsetCurveByDistances is only implemented for BasisCurves curves based on taxonomy::function_item", inst); + return nullptr; + } double start = curve->start(); double basis_curve_length = curve->length(); diff --git a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp index 14ee9899ad..fe5b31c56f 100644 --- a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp @@ -31,10 +31,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in std::vector cross_sections; auto dir = map(inst->Directrix()); - auto pwf = taxonomy::dcast(dir); - if (!pwf) { + auto fn = taxonomy::dcast(dir); + if (!fn) { // Only implement on alignment curves - Logger::Warning("IfcSectionedSolidHorizontal is only implemented for piecewise function Directrix curves", inst); + Logger::Warning("IfcSectionedSolidHorizontal is only implemented for Directrix curves based on taxonomy::function_item", inst); return nullptr; } @@ -43,12 +43,13 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in auto csps = inst->CrossSectionPositions(); std::vector faces; - // The PointByDistanceExpressesions are factored out into (a) a cartesian offset relative to the + // The PointByDistanceExpressions are factored out into (a) a cartesian offset relative to the // reference frame along a certain curve location (b) the longitude. // The longitudes determine the range of the sweep and the offsets are interpolated in between // sweep segments. std::vector profile_offsets; + std::vector> profile_rotations; std::vector longitudes; for (auto& cs : *css) { @@ -69,10 +70,20 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in ); profile_offsets.push_back(po); + + boost::optional rot; + if (csp->Axis() && csp->RefDirection()) { + rot = taxonomy::matrix4( + Eigen::Vector3d(0, 0, 0), + taxonomy::cast(map(csp->Axis()))->ccomponents(), + taxonomy::cast(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0); + } else if (csp->Axis()) { + rot = taxonomy::matrix4( + Eigen::Vector3d(0, 0, 0), + taxonomy::cast(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0); + } + profile_rotations.push_back(rot); } -#else - return nullptr; -#endif if (faces.size() != profile_offsets.size()) { Logger::Warning("Expected CrossSections and CrossSectionPositions to be equal length, but got " + std::to_string(faces.size()) + " and " + std::to_string(profile_offsets.size()) + " respectively", inst); return nullptr; @@ -83,11 +94,14 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in } for (size_t i = 0; i < faces.size(); ++i) { - cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i] }); - } + cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i], profile_rotations[i]}); } +#else + return nullptr; +#endif + } - return make_loft(settings_, inst, pwf, cross_sections); + return make_loft(settings_, inst, fn, cross_sections); } #endif diff --git a/src/ifcgeom/mapping/IfcSectionedSurface.cpp b/src/ifcgeom/mapping/IfcSectionedSurface.cpp index 3edcde7946..4948ddd0a0 100644 --- a/src/ifcgeom/mapping/IfcSectionedSurface.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSurface.cpp @@ -49,6 +49,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { // The longitudes determine the range of the sweep and the offsets are interpolated in between // sweep segments. std::vector profile_offsets; + std::vector> profile_rotations; std::vector longitudes; for (auto& cs : *css) { @@ -69,6 +70,19 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { ); profile_offsets.push_back(po); + + boost::optional rot; + if (csp->Axis() && csp->RefDirection()) { + rot = taxonomy::matrix4( + Eigen::Vector3d(0, 0, 0), + taxonomy::cast(map(csp->Axis()))->ccomponents(), + taxonomy::cast(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0); + } else if (csp->Axis()) { + rot = taxonomy::matrix4( + Eigen::Vector3d(0, 0, 0), + taxonomy::cast(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0); + } + profile_rotations.push_back(rot); } #else return nullptr; @@ -83,7 +97,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { } for (size_t i = 0; i < faces.size(); ++i) { - cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i] }); + cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i], profile_rotations[i] }); } } diff --git a/src/ifcgeom/mapping/IfcSweptDiskSolid.cpp b/src/ifcgeom/mapping/IfcSweptDiskSolid.cpp index 1eec8c6bc7..bd779fd6cd 100644 --- a/src/ifcgeom/mapping/IfcSweptDiskSolid.cpp +++ b/src/ifcgeom/mapping/IfcSweptDiskSolid.cpp @@ -58,8 +58,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSweptDiskSolid* inst) { auto ep = inst->EndParam(); #else boost::optional sp, ep; - sp = inst->StartParam(); - ep = inst->EndParam(); + try { + sp = inst->StartParam(); + ep = inst->EndParam(); + } catch (const IfcParse::IfcException& e) { + Logger::Warning(e); + } #endif const double tol = settings_.get().get(); diff --git a/src/ifcgeom/mapping/mapping.cpp b/src/ifcgeom/mapping/mapping.cpp index 666ff43dc2..04f8db4589 100644 --- a/src/ifcgeom/mapping/mapping.cpp +++ b/src/ifcgeom/mapping/mapping.cpp @@ -874,7 +874,7 @@ void mapping::initialize_units_() { if (settings_.get().has()) { auto vs = settings_.get().get(); if (vs.size() == 4) { - auto m3 = Eigen::Quaterniond(vs[0], vs[1], vs[2], vs[3]).matrix(); + auto m3 = Eigen::Quaterniond(vs[0], vs[1], vs[2], vs[3]).normalized().matrix(); Eigen::Matrix4d m4 = Eigen::Matrix4d::Identity(); m4 << m3; offset_and_rotation_ *= m4; diff --git a/src/ifcgeom/mapping/mapping.h b/src/ifcgeom/mapping/mapping.h index 61e8b4d7b6..054e0a7efc 100644 --- a/src/ifcgeom/mapping/mapping.h +++ b/src/ifcgeom/mapping/mapping.h @@ -86,6 +86,7 @@ namespace geometry { virtual std::map get_layers(IfcUtil::IfcBaseEntity*); virtual void initialize_settings(); virtual double get_length_unit() const { return length_unit_; } + virtual const std::string& get_length_unit_name() const { return length_unit_name_; } virtual aggregate_of_instance::ptr find_openings(const IfcUtil::IfcBaseEntity*); virtual IfcUtil::IfcBaseEntity* representation_of(const IfcUtil::IfcBaseEntity* product); diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index b2d48478f3..4d74a6b4e5 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -799,38 +799,88 @@ boost::optional ifcopenshell::geometry::taxonomy::curve_to_face_upgra return face_; } +namespace { + // @todo eliminate redundancy with cgal kernel + void evaluate_curve(const circle::ptr& c, double u, point3& p) { + Eigen::Vector4d xy{ c->radius * std::cos(u), c->radius * std::sin(u), 0, 1. }; + p.components() = (c->matrix->ccomponents() * xy).head<3>(); + } -boost::optional ifcopenshell::geometry::taxonomy::loop_to_piecewise_function_upgrade_impl(ptr item) { - boost::optional pwf_; + // @todo eliminate redundancy with cgal kernel + void evaluate_curve_d1(const circle::ptr& c, double u, direction3& p) { + Eigen::Vector4d xy{ -std::sin(u), cos(u), 0, 0. }; + p.components() = (c->matrix->ccomponents() * xy).head<3>(); + } + + double project_onto_curve(const circle::ptr& c, const point3& p) { + Eigen::Vector2d xy = (c->matrix->ccomponents().inverse() * p.ccomponents().homogeneous()).head<2>(); + return std::atan2(xy(1), xy(0)); + } +} + + +boost::optional ifcopenshell::geometry::taxonomy::loop_to_function_item_upgrade_impl(ptr item) { + boost::optional fi_; auto loop_ = dcast(item); if (loop_) { - if (loop_->pwf.is_initialized()) { - pwf_ = loop_->pwf; + if (loop_->fi.is_initialized()) { + fi_ = loop_->fi; } else { + // piecewise_function is a specialization of function_item - callers don't need to know this detail piecewise_function::spans_t spans; spans.reserve(loop_->children.size()); for (auto& edge_ : loop_->children) { - // the edge could be an arc or trimmed circle in the case of IfcIndexPolyCurve - support for this isn't implemented yet - if (edge_->basis) { - Logger::Message(Logger::Severity::LOG_NOTICE, "Shape of basis curve ignored - edge is treated as a straight line edge"); - } + if (edge_->basis && edge_->basis->kind() == CIRCLE) { + const circle::ptr circ = std::static_pointer_cast(edge_->basis); - const auto& s = boost::get(edge_->start)->ccomponents(); - const auto& e = boost::get(edge_->end)->ccomponents(); - Eigen::Vector3d v = e - s; - auto l = v.norm(); // the norm of a vector is a measure of its length - v.normalize(); // normalize the vector so that it is a unit direction vector - std::function fn = [s, v](double u) { - Eigen::Vector3d o(s + u * v), axis(0, 0, 1), refDirection(v); - auto Y = axis.cross(refDirection).normalized(); - axis = refDirection.cross(Y).normalized(); - return make(o, axis, refDirection)->components(); - }; - spans.emplace_back(taxonomy::make(l, fn)); + auto* s_pnt = boost::get(&edge_->start); + auto* e_pnt = boost::get(&edge_->end); + auto* s_param = boost::get(&edge_->start); + auto* e_param = boost::get(&edge_->end); + + if (!s_pnt && !s_param) { + return boost::none; + } + if (!e_pnt && !e_param) { + return boost::none; + } + + double s = s_pnt ? project_onto_curve(circ, **s_pnt) : *s_param; + double e = e_pnt ? project_onto_curve(circ, **e_pnt) : *e_param; + + auto l = std::fabs(s - e) * circ->radius; + std::function fn = [circ, s](double u) { + point3 P; + direction3 d; + evaluate_curve(circ, u / circ->radius + s, P); + evaluate_curve_d1(circ, u / circ->radius + s, d); + return matrix4(P.ccomponents(), circ->matrix->ccomponents().col(2).head<3>(), d.ccomponents()).components(); + }; + spans.emplace_back(taxonomy::make(l, fn)); + } else if (edge_->start.which() == 1 && edge_->end.which() == 1) { + if (edge_->basis && edge_->basis->kind() != LINE) { + Logger::Message(Logger::Severity::LOG_WARNING, "Basis curve not supported - edge is treated as a straight line edge"); + } + const auto& s = boost::get(edge_->start)->ccomponents(); + const auto& e = boost::get(edge_->end)->ccomponents(); + Eigen::Vector3d v = e - s; + auto l = v.norm(); // the norm of a vector is a measure of its length + v.normalize(); // normalize the vector so that it is a unit direction vector + std::function fn = [s, v](double u) { + Eigen::Vector3d o(s + u * v), axis(0, 0, 1), refDirection(v); + auto Y = axis.cross(refDirection).normalized(); + axis = refDirection.cross(Y).normalized(); + return make(o, axis, refDirection)->components(); + }; + spans.emplace_back(taxonomy::make(l, fn)); + } else { + Logger::Message(Logger::Severity::LOG_ERROR, "Basis curve not supported"); + return boost::none; + } } - pwf_ = make(0.0,spans); - loop_->pwf = pwf_; + fi_ = make(0.0,spans); + loop_->fi = fi_; } } - return pwf_; + return fi_; } diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index 4f73f10a66..703504abc2 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -892,7 +892,7 @@ typedef item const* ptr; DECLARE_PTR(loop) boost::optional external, closed; - boost::optional pwf; + boost::optional fi; bool is_polyhedron() const { for (auto& e : children) { @@ -1374,27 +1374,27 @@ typedef item const* ptr; } }; - boost::optional loop_to_piecewise_function_upgrade_impl(ptr item); + boost::optional loop_to_function_item_upgrade_impl(ptr item); template - class loop_to_piecewise_function_upgrade { + class loop_to_function_item_upgrade { private: - boost::optional pwf_; + boost::optional fi_; public: - loop_to_piecewise_function_upgrade(taxonomy::ptr item) { - if constexpr (std::is_same_v) { - pwf_ = loop_to_piecewise_function_upgrade_impl(item); + loop_to_function_item_upgrade(taxonomy::ptr item) { + if constexpr (std::is_same_v) { + fi_ = loop_to_function_item_upgrade_impl(item); } } operator bool() const { - return pwf_.is_initialized(); + return fi_.is_initialized(); } operator typename T::ptr() const { - if constexpr (std::is_same_v) { - if (pwf_) { - return *pwf_; + if constexpr (std::is_same_v) { + if (fi_) { + return *fi_; } } return nullptr; @@ -1435,7 +1435,7 @@ typedef item const* ptr; } } { - loop_to_piecewise_function_upgrade upg(u); + loop_to_function_item_upgrade upg(u); if (upg) { return upg; } @@ -1479,7 +1479,7 @@ typedef item const* ptr; } } { - loop_to_piecewise_function_upgrade upg(u); + loop_to_function_item_upgrade upg(u); if (upg) { return upg; } diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 44a08b2c96..6e99b5d6d9 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -61,12 +61,6 @@ IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.8.1-c test: pytest -p no:pytest-blender test -# safe version of tests without mathutils dependency -# for tests to work for github workflow with python <3.10 #3895 -.PHONY: test-safe -test-safe: - pytest -p no:pytest-blender test --ignore=test/util/test_shape_builder.py - .PHONY: build-ids-docs build-ids-docs: mkdir -p test/build diff --git a/src/ifcopenshell-python/docs/_static/custom.css b/src/ifcopenshell-python/docs/_static/custom.css index 1c1de1de08..2912d9390d 100644 --- a/src/ifcopenshell-python/docs/_static/custom.css +++ b/src/ifcopenshell-python/docs/_static/custom.css @@ -82,3 +82,8 @@ dl.py.property, dl.py.attribute, dl.py.method, dl.py.function { color: var(--color-brand-content); font-style: italic; } + +/* Workaround for highlighting issue in Furo */ +[role=main] .highlighted { + -webkit-text-fill-color: initial !important; +} \ No newline at end of file diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_processing.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_processing.rst index df7ae3321e..0c4753807a 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_processing.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_processing.rst @@ -306,39 +306,43 @@ In addition to geometry settings, serialisation has its own set of .. code-block:: python - import ifcopenshell - import ifcopenshell.geom - import multiprocessing - - settings = ifcopenshell.geom.settings() - - # Settings for glTF / glb - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - # Note that applying default materials is required in glTF serialisation. - settings.set("apply-default-materials", True) - - # Settings for obj - # settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - # settings.set("apply-default-materials", True) - # settings.set("use-world-coords", True) - - # Serialise to glTF / glb - serialiser = ifcopenshell.geom.serializers.gltf("output.glb", settings) - self.serialiser_settings = ifcopenshell.geom.serializer_settings() - # Setting element GUIDs is optional, but useful to uniquely identify objects in non-semantic formats. - serialiser_settings.set("use-element-guids", True) - - # Serialise to obj - # serialiser = ifcopenshell.geom.serializers.obj('output.obj', 'output.mtl', settings, serialiser_settings) - - serialiser.setFile(self.file) - serialiser.setUnitNameAndMagnitude("METER", 1.0) - serialiser.writeHeader() - - iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count()) - if iterator.initialize(): - while True: - serialiser.write(iterator.get()) - if not iterator.next(): - break - serialiser.finalize() + import multiprocessing + + import ifcopenshell + import ifcopenshell.geom + + ifc_file = ifcopenshell.open("model.ifc") + + settings = ifcopenshell.geom.settings() + + # Settings for glTF / glb + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + # Note that applying default materials is required in glTF serialisation. + settings.set("apply-default-materials", True) + + # Settings for obj + # settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + # settings.set("apply-default-materials", True) + # settings.set("use-world-coords", True) + + serialiser_settings = ifcopenshell.geom.serializer_settings() + # Setting element GUIDs is optional, but useful to uniquely identify objects in non-semantic formats. + serialiser_settings.set("use-element-guids", True) + + # Serialise to glTF / glb + serialiser = ifcopenshell.geom.serializers.gltf("output.glb", settings, serialiser_settings) + + # Serialise to obj + # serialiser = ifcopenshell.geom.serializers.obj('output.obj', 'output.mtl', settings, serialiser_settings) + + serialiser.setFile(ifc_file) + serialiser.setUnitNameAndMagnitude("METER", 1.0) + serialiser.writeHeader() + + iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count()) + if iterator.initialize(): + while True: + serialiser.write(iterator.get()) + if not iterator.next(): + break + serialiser.finalize() diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst index a5eeb47c1b..3dd4456fdc 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst @@ -102,7 +102,7 @@ will search through all IfcTypeProducts and IfcProducts in the IFC project. "Class", "Add", "``[!] {{ifc_class_name}}``", "``IfcWall`` adds all IfcWall elements and their subclasses. ``! IfcWall`` subtracts all non-IfcWall elements from the filter group." "GlobalId", "Add", "``[!] {{global_id}}``", "``325Q7Fhnf67OZC$$r43uzK`` adds the single element with that GlobalId attribute. ``! 325Q7Fhnf67OZC$$r43uzK`` subtracts that single element." - "Attribute", "Filter", "``{{name}}{{=}}{{value}}``", "``Name=Foo`` specifies the criteria that elements must have a ``Name`` attribute with a value of ``Foo``. Attribute names must be spelled exactly the same as in IFC, which means that they must start with an uppercase character." + "Attribute", "Filter", "``{{name}}{{=}}{{value}}``", "``Name=Foo`` specifies the criteria that elements must have a ``Name`` attribute with a value of ``Foo``. Attribute names must be spelled exactly the same as in IFC, which means that they must start with an uppercase character. For convenience, ``PredefinedType`` will be get using :func:`ifcopenshell.util.element.get_predefined_type` instead of getting the attribute directly." "Property", "Filter", "``{{pset}}.{{prop}}{{=}}{{value}}``", "``Pset_WallCommon.FireRating=2HR`` specifies the criteria that elements must have a ``Pset_WallCommon`` property set, with a ``FireRating`` property within it with a value of ``2HR``. The property set name and the property name are separated by a ``.``." "Type", "Filter", "``type{{=}}{{value}}``", "``type=Foo`` specifies the criteria that elements must have a type which has a ``Name`` attribute with a value of ``Foo``." "Material", "Filter", "``material{{=}}{{value}}``", "``material=Foo`` specifies the criteria that elements must have a IfcMaterial assigned directly or indirectly (such as within a layer set). That IfcMaterial must have either a ``Name`` or ``Category`` attribute with a value of ``Foo``." @@ -161,8 +161,9 @@ without needing to write complex code for it. "``types.count``", "Count the number of occurrences of a type." "``storey.Name``", "Get the ``Name`` attribute of the storey that the element is contained in." "``materials.count``", "Count the number of materials assigned to an element." - "``material.Name``", "Get the name of the assigned material." - "``material.item.0.Name``", "Get the name of the first item in a material set (e.g. the first material layer)" + "``material.Name``", "**IfcMaterial**: The name of the assigned material. **IfcMaterialLayerSet**: name of the LayerSetName. **IfcMaterialProfileSet**: The name of the overall material profile set. **IfcMaterialConstituent**: The name of the overall material constituent set." + "``material.item.0.Name``", "**IfcMaterial**: N/A. **IfcMaterialLayerSet**: The name of the 1st material layer. **IfcMaterialProfileSet**: The name of the 1st material profile. **IfcMaterialConstituent**: The name of the 1st material constituent." + "``material.item.Material.Name.0``", "**IfcMaterial**: The assigned material name. **IfcMaterialLayerSet**: The material name of the 1st material layer. **IfcMaterialProfileSet**: The material name of the 1st material profile. **IfcMaterialConstituent**: The material name of the 1st material constituent." The element value syntax works by specifying one or more query keys separated by a ``.`` character. Each query key returns data based of the results of the @@ -194,6 +195,7 @@ Valid keys are: "``classification``", "Gets the element's classification reference(s)" "``group``", "Gets the element's group(s)" "``system``", "Gets the element's system(s). This is a subset of group(s)." + "``zone``", "Gets the element's zone(s). This is a subset of group(s)." "``material`` or ``mat``", "Gets the assigned material, which may be a material set." "``item`` or ``i``", "If the previous key returns a material set, gets the relevant material set items" "``materials`` or ``mats``", "Gets a list of IfcMaterials assigned directly or indirectly (such as via a material set) to the element" diff --git a/src/ifcopenshell-python/docs/ifctester.rst b/src/ifcopenshell-python/docs/ifctester.rst index 0682bff803..56208dd198 100644 --- a/src/ifcopenshell-python/docs/ifctester.rst +++ b/src/ifcopenshell-python/docs/ifctester.rst @@ -11,3 +11,96 @@ PyPI .. code-block:: pip install ifctester + +Examples +-------- + +You can execute IfcTester using a CLI. + +.. code-block:: console + + # Validate an IFC with an IDS and report to console + python -m ifctester example.ids example.ifc + + # Generate a HTML report instead + python -m ifctester example.ids example.ifc -r Html -o report.html + +Alternatively, you can use Python: + +.. code-block:: python + + import ifcopenshell + from ifctester import ids, reporter + + # Create new IDS + specs = ids.Ids(title="My IDS") + + # add specification to it + spec = ids.Specification(name="My first specification") + spec.applicability.append(ids.Entity(name="IFCWALL")) + requirement = ids.Property( + baseName="IsExternal", + value="TRUE", + propertySet="Pset_WallCommon", + dataType="IfcBoolean", + uri="https://identifier.buildingsmart.org/uri/.../prop/LoadBearing", + instructions="Walls need to be load bearing.", + cardinality="required") + spec.requirements.append(requirement) + specs.specifications.append(spec) + + # Save to a file + specs.to_xml("IDS.xml") + + # Open IFC file: + my_ifc = ifcopenshell.open("model.ifc") + + # Validate IFC model against IDS requirements: + specs.validate(my_ifc) + + # Show results in a console + reporter.Console(specs).report() + + # Alternatively, to JSON + report = reporter.Json(specs) + report.report() + report.to_file("report.json") + + # Or to ODS spreadsheet + report = reporter.Ods(specs) + report.report() + report.to_file("report.ods") + + # Or to HTML spreadsheet + report = reporter.Html(specs) + report.report() + report.to_file("report.html") + + # Or to BCF + report = reporter.Bcf(specs) + report.report() + report.to_file("report.bcf") + +CLI manual +---------- + +.. code-block:: console + + $ python -m ifctester -h + + usage: __main__.py [-h] [-r REPORTER] [--no-color] [--excel-safe] [-o OUTPUT] ids [ifc] + + Uses an IDS to audit an IFC + + positional arguments: + ids Path to an IDS + ifc Path to an IFC + + options: + -h, --help show this help message and exit + -r REPORTER, --reporter REPORTER + The reporting method to view audit results + --no-color Disable colour output (supported by Console reporting) + --excel-safe Make sure exported ODS is safely exported for Excel + -o OUTPUT, --output OUTPUT + Output file (supported for all types of reporting except Console) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 68fc758455..ecbd27f81a 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -237,6 +237,10 @@ def schema_by_name( :param schema: Which IFC schema to use, chosen from "IFC2X3", "IFC4", or "IFC4X3". These refer to the ISO approved versions of IFC. + E.g. from ``ifcopenshell.file.schema_identifier``. + Passing ``ifcopenshell.file.schema`` also will work but may result + in not precisely matching schema but it's only conern if you're + using not one of the main schemas. :param schema_version: If you want to specify an exact version of IFC that may not be an ISO approved version, use this argument instead of ``schema``. IFC versions on technical.buildingsmart.org are diff --git a/src/ifcopenshell-python/ifcopenshell/alignment.py b/src/ifcopenshell-python/ifcopenshell/alignment.py deleted file mode 100644 index a40151068d..0000000000 --- a/src/ifcopenshell-python/ifcopenshell/alignment.py +++ /dev/null @@ -1,658 +0,0 @@ -# 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 math -from typing import Sequence - -import numpy as np - -import ifcopenshell -import ifcopenshell.geom -import ifcopenshell.guid -import ifcopenshell.template -from ifcopenshell import entity_instance -from ifcopenshell import ifcopenshell_wrapper - - -def evaluate_representation(shape_rep: entity_instance, dist_along: float) -> np.ndarray: - """ - Calculate the 4x4 geometric transform at a point on an alignment segment - @param shape_rep: The representation shape (composite curve, gradient curve, or segmented reference curve) to evaluate - @param dist_along: The distance along this representation at the point of interest (point to be calculated) - """ - supported_rep_types = ["IFCCOMPOSITECURVE", "IFCGRADIENTCURVE", "IFCSEGMENTEDREFERENCECURVE"] - shape_rep_type = shape_rep.is_a().upper() - if not shape_rep_type in supported_rep_types: - raise NotImplementedError( - f"Expected entity type to be one of {[_ for _ in supported_rep_types]}, got '{shape_rep_type}" - ) - - # TODO: confirm point is not beyond limits of alignment - - s = ifcopenshell.geom.settings() - piecewise_function = ifcopenshell_wrapper.map_shape(s, shape_rep.wrapped_data) - pwf_evaluator = ifcopenshell_wrapper.piecewise_function_evaluator(piecewise_function, s) - - trans_matrix = pwf_evaluator.evaluate(dist_along) - - return np.array(trans_matrix, dtype=np.float64).T - - -def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray: - """ - Calculate the 4x4 geometric transform at a point on an alignment segment - @param segment: The segment containing the point that we would like to - @param dist_along: The distance along this segment at the point of interest (point to be calculated) - """ - supported_segment_types = ["IFCCURVESEGMENT"] - segment_type = segment.is_a().upper() - if not segment_type in supported_segment_types: - raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}") - if dist_along > segment.SegmentLength: - raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).") - - s = ifcopenshell.geom.settings() - piecewise_function = ifcopenshell_wrapper.map_shape(s, segment.wrapped_data) - pwf_evaluator = ifcopenshell_wrapper.piecewise_function_evaluator(piecewise_function, s) - - trans_matrix = pwf_evaluator.evaluate(dist_along) - - return np.array(trans_matrix, dtype=np.float64).T - - -def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0) -> np.ndarray: - """ - Generate vertices along an alignment - - @param rep_curve: The alignment's representation curve to use to generate vertices. - - Note: rep_curve must be IfcCompositeCurve, IfcGradientCurve, or IfcSegmentedReferenceCurve - - @param distance_interval: The distance between points along the alignment at which to generate the points - """ - if rep_curve is None: - raise ValueError("Alignment representation not found.") - - s = ifcopenshell.geom.settings() - s.set("PIECEWISE_STEP_PARAM", distance_interval) - shape = ifcopenshell.geom.create_shape(s, rep_curve) - vertices = shape.verts - if len(vertices) == 0: - msg = f"[ERROR] No vertices generated by ifcopenshell.geom.create_shape()." - raise ValueError(msg) - return np.array(vertices).reshape((-1, 3)) - - -def print_structure(alignment, indent=0): - """ - Debugging function to print alignment decomposition - """ - print(" " * indent, str(alignment)[0:100]) - for rel in alignment.IsNestedBy: - for child in rel.RelatedObjects: - print_structure(child, indent + 2) - - -def name_segments(prefix: str, segments: Sequence[entity_instance]) -> None: - """ - Sets the segment name like ("H1" for horizontal, "V1" for vertical, "C1" for cant) - """ - for i, segment in enumerate(segments): - segment.Name = f"{prefix}{i + 1}" - - -class IfcAlignmentHelper: - """ - Create a new IfcAlignment including horizontal and vertical alignments by PI points. - - Currently only supports horizontal lines and circular arcs (no spirals or other transitions) - Currently only supports parabolic vertical curves. - Does not yet accommodate cant alignment considerations. - """ - - # TODO: add missing functionality noted in the docstring - - def __init__( - self, - file: ifcopenshell.file = None, - filename: str = None, - creator: str = None, - organization: str = None, - application: str = None, - project_globalid=None, - project_name: str = None, - ): - """ - @param file: An existing model that the alignment will be added to - @param filename: Name for a new model to be created that will contain the alignment - @param creator: Name of the actor creating the file - @param organization: Name of the creator's organization - @param application: Name of the authoring application - @param project_globalid: value for the file's IfcProject.GlobalId attribute - @param project_name: value for the file's IfcProject.Name attribute - """ - if file is None: - self._file = ifcopenshell.template.create( - filename=filename, - creator=creator, - organization=organization, - application=application, - project_globalid=project_globalid, - project_name=project_name, - schema_identifier="IFC4X3_ADD2", - ) - else: - self._file = file - - self._geom_context = self._file.by_type("IfcGeometricRepresentationContext")[0] - self._axis_geom_subcontext = self._file.createIfcGeometricRepresentationSubContext( - ContextIdentifier="Axis", ContextType="Model", ParentContext=self._geom_context, TargetView="GRAPH_VIEW" - ) - - def _create_segment_representations( - self, - global_placement: entity_instance, - curve_segments: Sequence[entity_instance], - segments: Sequence[entity_instance], - ): - for curve_segment, alignment_segment in zip(curve_segments, segments): - axis_representation = self._file.create_entity( - type="IfcShapeRepresentation", - ContextOfItems=self._axis_geom_subcontext, - RepresentationIdentifier="Axis", - RepresentationType="Segment", - Items=(curve_segment,), - ) - product = self._file.create_entity( - type="IfcProductDefinitionShape", Name=None, Description=None, Representations=(axis_representation,) - ) - alignment_segment.ObjectPlacement = global_placement - alignment_segment.Representation = product - - def _map_alignment_horizontal_segment(self, segment: entity_instance) -> Sequence[entity_instance]: - segment_type = segment.is_a().upper() - expected_type = "IFCALIGNMENTHORIZONTALSEGMENT" - if not segment_type == expected_type: - raise TypeError(f"Expected to see type '{expected_type}', instead received '{segment_type}'.") - - start_point = segment.StartPoint - start_direction = segment.StartDirection - start_radius = segment.StartRadiusOfCurvature - length = segment.SegmentLength - _type = segment.PredefinedType - - if math.isclose(length, 0): - # set transition value based on whether this is the final zero-length segment - transition = "DISCONTINUOUS" - else: - transition = "CONTSAMEGRADIENTSAMECURVATURE" - - match _type: - case "LINE": - parent_curve = self._file.create_entity( - type="IfcLine", - Pnt=self._file.create_entity( - type="IfcCartesianPoint", - Coordinates=(0.0, 0.0), - ), - Dir=self._file.create_entity( - type="IfcVector", - Orientation=self._file.create_entity( - type="IfcDirection", - DirectionRatios=(1.0, 0.0), - ), - Magnitude=1.0, - ), - ) - curve_segment = self._file.create_entity( - type="IfcCurveSegment", - Transition=transition, - Placement=self._file.create_entity( - type="IfcAxis2Placement2D", - Location=start_point, - RefDirection=self._file.createIfcDirection( - (math.cos(start_direction), math.sin(start_direction)), - ), - ), - SegmentStart=self._file.createIfcLengthMeasure(0.0), - SegmentLength=self._file.createIfcLengthMeasure(length), - ParentCurve=parent_curve, - ) - result = (curve_segment, None) - case "CIRCULARARC": - parent_curve = self._file.createIfcCircle( - Position=self._file.createIfcAxis2Placement2D( - Location=self._file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)), - RefDirection=self._file.createIfcDirection( - (math.cos(start_direction), math.sin(start_direction)) - ), - ), - Radius=abs(start_radius), - ) - - curve_segment = self._file.create_entity( - type="IfcCurveSegment", - Transition=transition, - Placement=self._file.create_entity( - type="IfcAxis2Placement2D", - Location=start_point, - RefDirection=self._file.createIfcDirection( - (math.cos(start_direction), math.sin(start_direction)) - ), - ), - SegmentStart=self._file.createIfcLengthMeasure(0.0), - SegmentLength=self._file.createIfcLengthMeasure(length * start_radius / abs(start_radius)), - ParentCurve=parent_curve, - ) - result = (curve_segment, None) - - case _: - result = (None, None) - - return result - - def _create_horizontal_alignment( - self, - name: str, - description: str, - points: Sequence[Sequence[float]], - radii: Sequence[float], - include_geometry: bool = True, - ): - """ - Create a horizontal alignment using the PI layout method. - - @param name: value for Name attribute - @param description: value for Description attribute - @param points: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE). - @param radii: radii values to use for transition - @param include_geometry: optionally create the alignment geometric representation as well as the semantic business logic - """ - horizontal_segments = list() # business logic - horizontal_curve_segments = list() # geometry - - xBT, yBT = points[0] - xPI, yPI = points[1] - - i = 1 - - for radius in radii: - # back tangent - dxBT = xPI - xBT - dyBT = yPI - yBT - angleBT = math.atan2(dyBT, dxBT) - lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT) - - # forward tangent - i += 1 - xFT, yFT = points[i] - dxFT = xFT - xPI - dyFT = yFT - yPI - angleFT = math.atan2(dyFT, dxFT) - - delta = angleFT - angleBT - - tangent = abs(radius * math.tan(delta / 2)) - - lc = abs(radius * delta) - - radius *= delta / abs(delta) - - xPC = xPI - tangent * math.cos(angleBT) - yPC = yPI - tangent * math.sin(angleBT) - - xPT = xPI + tangent * math.cos(angleFT) - yPT = yPI + tangent * math.sin(angleFT) - - tangent_run = lengthBT - tangent - - # create back tangent run - pt = self._file.create_entity( - type="IfcCartesianPoint", - Coordinates=(xBT, yBT), - ) - design_parameters = self._file.create_entity( - type="IfcAlignmentHorizontalSegment", - StartTag=None, - EndTag=None, - StartPoint=pt, - StartDirection=angleBT, - StartRadiusOfCurvature=0.0, - EndRadiusOfCurvature=0.0, - SegmentLength=tangent_run, - GravityCenterLineHeight=None, - PredefinedType="LINE", - ) - alignment_segment = self._file.create_entity( - type="IfcAlignmentSegment", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=None, - Description=None, - ObjectType=None, - ObjectPlacement=None, - Representation=None, - DesignParameters=design_parameters, - ) - horizontal_segments.append(alignment_segment) - - if include_geometry: - horizontal_curve_segments.append(self._map_alignment_horizontal_segment(design_parameters)[0]) - - # create circular curve - pc = self._file.create_entity( - type="IfcCartesianPoint", - Coordinates=(xPC, yPC), - ) - design_parameters = self._file.create_entity( - type="IfcAlignmentHorizontalSegment", - StartTag=None, - EndTag=None, - StartPoint=pc, - StartDirection=angleBT, - StartRadiusOfCurvature=float(radius), - EndRadiusOfCurvature=float(radius), - SegmentLength=lc, - GravityCenterLineHeight=None, - PredefinedType="CIRCULARARC", - ) - alignment_segment = self._file.create_entity( - type="IfcAlignmentSegment", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=None, - Description=None, - ObjectType=None, - ObjectPlacement=None, - Representation=None, - DesignParameters=design_parameters, - ) - horizontal_segments.append(alignment_segment) - - if include_geometry: - horizontal_curve_segments.append(self._map_alignment_horizontal_segment(design_parameters)[0]) - - xBT = xPT - yBT = yPT - xPI = xFT - yPI = yFT - - # done processing radii - # create last tangent run - dx = xPI - xBT - dy = yPI - yBT - angleBT = math.atan2(dy, dx) - tangent_run = math.sqrt(dx * dx + dy * dy) - pt = self._file.create_entity(type="IfcCartesianPoint", Coordinates=(xBT, yBT)) - - design_parameters = self._file.create_entity( - type="IfcAlignmentHorizontalSegment", - StartTag=None, - EndTag=None, - StartPoint=pt, - StartDirection=angleBT, - StartRadiusOfCurvature=0.0, - EndRadiusOfCurvature=0.0, - SegmentLength=tangent_run, - GravityCenterLineHeight=None, - PredefinedType="LINE", - ) - alignment_segment = self._file.create_entity( - type="IfcAlignmentSegment", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=None, - Description=None, - ObjectType=None, - ObjectPlacement=None, - Representation=None, - DesignParameters=design_parameters, - ) - horizontal_segments.append(alignment_segment) - if include_geometry: - horizontal_curve_segments.append(self._map_alignment_horizontal_segment(design_parameters)[0]) - - # create zero length terminator segment - poe = self._file.create_entity(type="IfcCartesianPoint", Coordinates=(xPI, yPI)) - - design_parameters = self._file.create_entity( - type="IfcAlignmentHorizontalSegment", - StartTag="POE", - EndTag="POE", - StartPoint=poe, - StartDirection=angleBT, - StartRadiusOfCurvature=0.0, - EndRadiusOfCurvature=0.0, - SegmentLength=0.0, - GravityCenterLineHeight=None, - PredefinedType="LINE", - ) - alignment_segment = self._file.create_entity( - type="IfcAlignmentSegment", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=None, - Description=None, - ObjectType=None, - ObjectPlacement=None, - Representation=None, - DesignParameters=design_parameters, - ) - horizontal_segments.append(alignment_segment) - if include_geometry: - horizontal_curve_segments.append(self._map_alignment_horizontal_segment(design_parameters)[0]) - - if include_geometry: - composite_curve = self._file.create_entity( - type="IfcCompositeCurve", - Segments=horizontal_curve_segments, - SelfIntersect=False, - ) - else: - composite_curve = None - - return horizontal_segments, horizontal_curve_segments, composite_curve - - def _add_horizontal_alignment( - self, - alignment_name: str, - points: Sequence[Sequence[float]], - radii: Sequence[float], - include_geometry: bool = True, - alignment_description: str = None, - start_station: float = 1000.0, - ): - horizontal_segments, horizontal_curve_segments, composite_curve = self._create_horizontal_alignment( - alignment_name, - alignment_description, - points, - radii, - include_geometry, - ) - - name_segments(prefix="H", segments=horizontal_segments) - - # Create the horizontal alignment (IfcAlignmentHorizontal) and nest alignment segments - horizontal_alignment = self._file.create_entity( - type="IfcAlignmentHorizontal", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=f"{alignment_name} - Horizontal", - Description=alignment_description, - ObjectType=None, - ObjectPlacement=None, - Representation=None, - ) - - nests_horizontal_segments = self._file.create_entity( - type="IfcRelNests", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name="Nests horizontal alignment segments under horizontal alignment", - RelatingObject=horizontal_alignment, - RelatedObjects=horizontal_segments, - ) - - placement = self._file.createIfcLocalPlacement( - PlacementRelTo=None, - RelativePlacement=self._file.createIfcAxis2Placement2D( - Location=self._file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)) - ), - ) - - # create the alignment - alignment = self._file.create_entity( - type="IfcAlignment", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name=alignment_name, - Description=alignment_description, - ObjectType=None, - ObjectPlacement=placement, - Representation=None, - PredefinedType=None, - ) - - # create geometric representation - if include_geometry: - # create the footprint representation - footprint_shape_representation = self._file.create_entity( - type="IfcShapeRepresentation", - ContextOfItems=self._axis_geom_subcontext, - RepresentationIdentifier="FootPrint", - RepresentationType="Curve2D", - Items=(composite_curve,), - ) - - # create the alignment product definition - product_definition_shape = self._file.create_entity( - type="IfcProductDefinitionShape", - Name="Alignment Product Definition Shape", - Description=None, - Representations=(footprint_shape_representation,), - ) - - # create representations for each segment - self._create_segment_representations(placement, horizontal_curve_segments, horizontal_segments) - - # add the representation to the alignment - alignment.Representation = product_definition_shape - - # create referent for start station - start_referent = self._file.createIfcReferent( - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name="Start Station", - Description=None, - ObjectType=None, - ObjectPlacement=self._file.createIfcLinearPlacement( - RelativePlacement=self._file.createIfcAxis2PlacementLinear( - Location=self._file.createIfcPointByDistanceExpression( - DistanceAlong=self._file.createIfcLengthMeasure(0.0), - OffsetLateral=None, - OffsetVertical=None, - OffsetLongitudinal=None, - BasisCurve=composite_curve, - ), - ), - CartesianPosition=None, - ), - Representation=None, - PredefinedType="STATION", - ) - - # nest the horizontal and the referent under the alignment - nesting_of_alignment = self._file.create_entity( - type="IfcRelNests", - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name="Nests horizontal alignment and referents under overall alignment", - RelatingObject=alignment, - RelatedObjects=(horizontal_alignment, start_referent), - ) - - # aggregate the horizontal under the project - project = self._file.by_type("IfcProject")[0] - alignment_within_project = self._file.createIfcRelAggregates( - GlobalId=ifcopenshell.guid.new(), - OwnerHistory=None, - Name="Aggregates alignment under the project", - RelatingObject=project, - RelatedObjects=(alignment,), - ) - - return alignment - - def add_vertical_alignment( - self, - name: str, - description: str, - vpoints: Sequence[Sequence[float]], - vclengths: Sequence[Sequence[float]], - include_geometry: bool = True, - ): - """ - Create a vertical alignment using the PI layout method. - - @param name: value for Name attribute - @param description: value for Description attribute - @param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end. - @param vclengths: radii values to use for transition - @param include_geometry: optionally create the alignment geometric representation as well as the semantic business logic - """ - pass - - def add_alignment( - self, - name: str, - hpoints: Sequence[Sequence[float]], - radii: Sequence[float], - include_geometry: bool = True, - description: str = None, - start_station: float = 1000.0, - ): - """ - Create a new alignment with a horizontal alignment using the PI layout method - """ - self._add_horizontal_alignment( - alignment_name=name, - points=hpoints, - radii=radii, - include_geometry=include_geometry, - alignment_description=description, - start_station=start_station, - ) - - def save_file(self, filename) -> None: - self._file.write(filename) - - -if __name__ == "__main__": - import sys - from matplotlib import pyplot as plt - - f = ifcopenshell.open(sys.argv[1]) - print_structure(f.by_type("IfcAlignment")[0]) - - al_hor_rep = f.by_type("IfcCompositeCurve")[0] - - xy = generate_vertices(rep_curve=al_hor_rep, distance_interval=10.0) - - plt.plot(xy[0], xy[1]) - plt.savefig("horizontal_alignment.png") diff --git a/src/ifcopenshell-python/ifcopenshell/api/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/__init__.py index 31e2cff06e..7f798d06da 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/__init__.py @@ -163,7 +163,7 @@ def remove_all_listeners(): post_listeners.clear() -def extract_docs(module, usecase): +def extract_docs(module: str, usecase: str) -> dict[str, Any]: import typing import collections diff --git a/src/ifcopenshell-python/ifcopenshell/api/aggregate/assign_object.py b/src/ifcopenshell-python/ifcopenshell/api/aggregate/assign_object.py index dac156daea..b03f003594 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/aggregate/assign_object.py +++ b/src/ifcopenshell-python/ifcopenshell/api/aggregate/assign_object.py @@ -64,13 +64,10 @@ def assign_object( :param products: The list of parts of the aggregate, typically of IfcElement or IfcSpatialStructureElement subclass - :type product: list[ifcopenshell.entity_instance] :param relating_object: The whole of the aggregate, typically an IfcElement or IfcSpatialStructureElement subclass - :type relating_object: ifcopenshell.entity_instance :return: The IfcRelAggregate relationship instance or `None` if `products` was empty list. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -86,16 +83,10 @@ def assign_object( # The site has a building ifcopenshell.api.aggregate.assign_object(model, products=[subelement], relating_object=element) """ - settings = { - "products": products, - "relating_object": relating_object, - } - - if not settings["products"]: + if not products: return - products = set(settings["products"]) - relating_object = settings["relating_object"] + products_set = set(products) is_decomposed_by = next((i for i in relating_object.IsDecomposedBy if i.is_a("IfcRelAggregates")), None) previous_aggregates_rels: set[ifcopenshell.entity_instance] = set() @@ -103,7 +94,7 @@ def assign_object( products_with_aggregates: list[ifcopenshell.entity_instance] = [] # check if there is anything to change - for product in products: + for product in products_set: product_rel = next(iter(product.Decomposes), None) if product_rel is None: @@ -129,7 +120,7 @@ def assign_object( # unassign elements from previous aggregates for decomposes in previous_aggregates_rels: - related_objects = set(decomposes.RelatedObjects) - products + related_objects = set(decomposes.RelatedObjects) - products_set if related_objects: decomposes.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, **{"element": decomposes}) @@ -141,7 +132,7 @@ def assign_object( # assign elements to a new aggregate if is_decomposed_by: - is_decomposed_by.RelatedObjects = list(set(is_decomposed_by.RelatedObjects) | products) + is_decomposed_by.RelatedObjects = list(set(is_decomposed_by.RelatedObjects) | products_set) ifcopenshell.api.owner.update_owner_history(file, **{"element": is_decomposed_by}) else: is_decomposed_by = file.create_entity( @@ -149,7 +140,7 @@ def assign_object( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": list(products), + "RelatedObjects": list(products_set), "RelatingObject": relating_object, } ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py new file mode 100644 index 0000000000..bb127bc568 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -0,0 +1,102 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# 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 . + +""" +Manages alignment layout (business logical) and alignment geometry (geometric representations). + +This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance. + +This API is under development and subject to code breaking changes in the future. + +Presently, this API supports: + 1. Creating alignments, both horizontal and vertical, using the PI method. Alignment definition can be read from a CSV file. + 2. Adding business logic and geometric segments to the end of an alignment + 3. Adding and removing the zero length segment at the end of alignments + 4. Creating geometric representations from a business logical definition + 5. Mapping individual business logical segments to geometric segments (complete for horizontal, missing clothoid for vertical, not implemented for cant) + 6. Using curve geometry to determine IfcCurveSegment.Transition transition code. + 7. Utility functions for printing business logical and geometric representations, as well as minimumal geometry evaluations + +Future versions of this API will support: + 1. Defining alignments using the PI method, including transition spirals + 2. Updating horizontal curve definitions by revising transition spiral parameters and circular curve radii + 3. Updating vertical curve definitions by revising horizontal length of curves + 4. Removing a segment at any location along a curve + 5. Adding a segment at any location along a curve +""" + +from .add_segment_to_curve import add_segment_to_curve +from .add_segment_to_layout import add_segment_to_layout +from .add_stationing_to_alignment import add_stationing_to_alignment +from .add_vertical_alignment_by_pi_method import add_vertical_alignment_by_pi_method +from .add_vertical_alignment import add_vertical_alignment +from .add_zero_length_segment import add_zero_length_segment +from .create_alignment_by_pi_method import create_alignment_by_pi_method +from .create_alignment_from_csv import create_alignment_from_csv +from .create_horizontal_alignment_by_pi_method import create_horizontal_alignment_by_pi_method +from .create_geometric_representation import create_geometric_representation +from .create_vertical_alignment_by_pi_method import create_vertical_alignment_by_pi_method +from .distance_along_from_station import distance_along_from_station +from .get_alignment_layouts import get_alignment_layouts +from .get_axis_subcontext import get_axis_subcontext +from .get_basis_curve import get_basis_curve +from .get_child_alignments import get_child_alignments +from .get_curve import get_curve +from .get_parent_alignment import get_parent_alignment +from .has_zero_length_segment import has_zero_length_segment +from .map_alignment_segments import map_alignment_segments +from .map_alignment_segment import map_alignment_segment +from .map_alignment_horizontal_segment import map_alignment_horizontal_segment +from .map_alignment_vertical_segment import map_alignment_vertical_segment +from .map_alignment_cant_segment import map_alignment_cant_segment +from .name_segments import name_segments +from .remove_last_segment import remove_last_segment +from .remove_zero_length_segment import remove_zero_length_segment +from .update_curve_segment_transition_code import update_curve_segment_transition_code +from .util import * + +__all__ = [ + "add_segment_to_curve", + "add_segment_to_layout", + "add_stationing_to_alignment", + "add_vertical_alignment_by_pi_method", + "add_vertical_alignment", + "add_zero_length_segment", + "create_alignment_by_pi_method", + "create_alignment_from_csv", + "create_horizontal_alignment_by_pi_method", + "create_geometric_representation", + "create_vertical_alignment_by_pi_method", + "distance_along_from_station", + "get_alignment_layouts", + "get_axis_subcontext", + "get_basis_curve", + "get_child_alignments", + "get_curve", + "get_parent_alignment", + "has_zero_length_segment", + "map_alignment_segments", + "map_alignment_segment", + "map_alignment_horizontal_segment", + "map_alignment_vertical_segment", + "map_alignment_cant_segment", + "name_segments", + "remove_last_segment", + "remove_zero_length_segment", + "update_curve_segment_transition_code", +] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_curve.py new file mode 100644 index 0000000000..7b8370fdcc --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_curve.py @@ -0,0 +1,76 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.geom +from ifcopenshell import entity_instance + + +def add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, composite_curve: entity_instance) -> None: + """ + Adds a segment to a composite curve. The segment must not belong to another composite curve (len(segment.UsingCurves) == 0). + If the composite curve does not have any segments, the segment is simply appended to the curve. + If the composite curve has segments, the position, ref. direction, and curvature at the end of the last segment is + compared to the position, ref. direction and curvature at the start of the new segment. The IfcCurveSegment.Transition of the last curve segment is updated. + + :param segment: The segment to be added to the curve + :param composite_curve: The curve receiving the segment + :return: None + """ + expected_type = "IfcCurveSegment" + if not segment.is_a(expected_type): + raise TypeError(f"Expected to see '{expected_type}', instead received '{segment.is_a()}'.") + + if 0 < len(segment.UsingCurves): + raise TypeError("IfcCurveSegment cannot belong to other curves") + + expected_type = "IfcCompositeCurve" + if not composite_curve.is_a(expected_type): + raise TypeError(f"Expected to see '{expected_type}', instead received '{composite_curve.is_a()}'.") + + settings = ifcopenshell.geom.settings() + if composite_curve.Segments == None or 0 == len(composite_curve.Segments): + # this is the first segment so just add it + if composite_curve.Segments == None: + composite_curve.Segments = [] + + # the last segment is always discontinuous + segment.Transition = "DISCONTINUOUS" + + composite_curve.Segments += (segment,) + assert len(segment.UsingCurves) == 1 + else: + zero_length_segment = ( + ifcopenshell.api.alignment.remove_zero_length_segment(file, composite_curve) + if ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) + else None + ) + + prev_segment = composite_curve.Segments[-1] + + # the last segment is always discontinuous + segment.Transition = "DISCONTINUOUS" + + # must add the new segment to the curve before updating the transition code + composite_curve.Segments += (segment,) + + ifcopenshell.api.alignment.update_curve_segment_transition_code(prev_segment, segment) + + if zero_length_segment: + ifcopenshell.api.alignment.add_segment_to_curve(zero_length_segment, composite_curve) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_layout.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_layout.py new file mode 100644 index 0000000000..c92e475106 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_segment_to_layout.py @@ -0,0 +1,52 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +from ifcopenshell import entity_instance +from typing import Sequence + + +def add_segment_to_layout(file: ifcopenshell.file, alignment: entity_instance, segment: entity_instance) -> None: + """ + Adds a segment to a layout alignment (horizontal, vertical, or cant) + + :param alignment: The alignment + :param segment: The segment to be appended + :return: None + """ + expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"] + if not alignment.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{alignment.is_a()}" + ) + + if not (segment.is_a("IfcAlignmentSegment")): + raise TypeError(f"Expected to see IfcAlignmentSegment, instead received '{segment.is_a()}.") + + zero_length_segment = ( + ifcopenshell.api.alignment.remove_zero_length_segment(file, alignment) + if ifcopenshell.api.alignment.has_zero_length_segment(alignment) + else None + ) + + ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=alignment) + + if zero_length_segment: + ifcopenshell.api.nest.assign_object(file, related_objects=[zero_length_segment], relating_object=alignment) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_to_alignment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_to_alignment.py new file mode 100644 index 0000000000..ffc42c70d8 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_to_alignment.py @@ -0,0 +1,79 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.guid +from ifcopenshell import entity_instance + + +def add_stationing_to_alignment(file: ifcopenshell.file, alignment: entity_instance, start_station: float) -> None: + """ + Adds stationing to an alignment by creating an IfcReferent with the Pset_Stationing property set to establish the stationing at the start of the alignment. + Note - this function assumes the stationing has not been previously defined + + :param alignment: the alignment to be stationed + :param start_station: station value at the start of the alignment + :return: None + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] + ifcopenshell.api.alignment.add_stationing_to_alignment(model,alignment=alignment,start_station=100.0) + """ + # this commented out code is what you would do to add a geometric representation of the referent + # the example is a circle. a better way would be to pass a representation into the function + object_placement = None + representation = None + # basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + # if basis_curve: + # object_placement = file.createIfcLinearPlacement( + # RelativePlacement=file.createIfcAxis2PlacementLinear( + # Location=file.createIfcPointByDistanceExpression( + # DistanceAlong=file.createIfcLengthMeasure(0.0), + # OffsetLateral=None, + # OffsetVertical=None, + # OffsetLongitudinal=None, + # BasisCurve=basis_curve, + # ) + # ), + # CartesianPosition=None, + # ) + # representation = file.create_entity( + # name="IfcCircle", + # position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)), + # radius=1.0) + # ) + + # create referent for start station + start_referent = file.createIfcReferent( + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=ifcopenshell.util.stationing.station_as_string(start_station), + Description=None, + ObjectType=None, + ObjectPlacement=object_placement, + Representation=representation, + PredefinedType="STATION", + ) + pset_stationing = ifcopenshell.api.pset.add_pset(file, product=start_referent, name="Pset_Stationing") + ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": start_station}) + ifcopenshell.api.nest.assign_object(file, related_objects=[start_referent], relating_object=alignment) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment.py new file mode 100644 index 0000000000..d2f7d228a8 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment.py @@ -0,0 +1,198 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.alignment +import ifcopenshell.api.geometry +import ifcopenshell.api.nest +import ifcopenshell.guid +import ifcopenshell.util.element +import ifcopenshell.util.representation +import ifcopenshell.util.stationing +import ifcopenshell.api +from ifcopenshell import entity_instance + + +def _move_vertical_to_child_alignment( + file: ifcopenshell.file, parent_alignment: entity_instance, vertical_alignment: entity_instance +): + """ + Creates a new child alignment and aggregates it to the parent alignment. Moves the vertical alignment from the parent + alignment to the child alignment. Also moves the "Axis/Curve3D" representation to the child alignment, if present. + This function supports the transition of vertical alignment between CT 4.1.4.4.1.1 and 4.1.4.4.1.2 because a subsequent + vertical alignment is being added and the Alignment Layout - Reusing Horizontal Layout concept applies. + """ + # unhook the vertical alignment from the parent alignment + ifcopenshell.api.nest.unassign_object(file, related_objects=[vertical_alignment]) + + # create the child alignment + child_alignment = ifcopenshell.api.root.create_entity( + file, ifc_class="IfcAlignment", name=f"Child of {parent_alignment.Name}" + ) + + # nest the vertical alignment onto the child alignment + ifcopenshell.api.nest.assign_object(file, related_objects=[vertical_alignment], relating_object=child_alignment) + + # aggreage the child alignment to the parent alignment + ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment) + + # if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment + base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment) + if base_curve: + representations = ifcopenshell.util.representation.get_representations_iter(parent_alignment) + for representation in representations: + if representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Curve3D": + ifcopenshell.api.geometry.unassign_representation(file, parent_alignment, representation) + ifcopenshell.api.geometry.assign_representation(file, child_alignment, representation) + break + + +def add_vertical_alignment( + file: ifcopenshell.file, parent_alignment: entity_instance, vertical_alignment: entity_instance +) -> None: + """ + Adds a vertical alignment to a previously created alignment. + + If this is the first vertical alignment assigned to the parent_alignment the IFC CT 4.1.4.4.1.1 Alignment Layout - Horizontal, Vertical and Cant + is followed. If this is the second or subsequent vertical alignment assigned to the parent_alignment the + IFC CT 4.1.4.4.1.2 Alignment Layout - Reusing Horizontal Layout is followed. + + When the second vertical alignment is added, the structure of the IFC model must transition from one concept template to the other. + Specifically, the following occurs: + + 1) The first child IfcAlignment is created and is IfcRelAggregates with the parent alignment. + 2) The first vertical alignment is unassigned from the IfcRelNests of the parent alignment and assigned to the new child alignment IfcRelNests + 3) A second child IfcAlignment is created ant is is IfcRelAggregates with the parent alignment. + 4) The vertical_alignment is assigned to the second child alignment + + For the third and subsequent vertical alignments, a new child alignment is created and aggregated to the parent alignment and an IfcAlignmentVertical is created + from vpoints and lengths and assigned to the new child alignment. + + If the parent_alignment has a geometric representation, a geometric representation will be created for the vertical alignment. + + :param parent_alignment: The parent alignment + :param vertical_alignment: The vertical alignment to be added + :return: None + """ + + # get all the child alignments under alignment + child_alignments = [ + c for c in ifcopenshell.util.element.get_decomposition(parent_alignment) if c.is_a("IfcAlignment") + ] + + # Get all the IfcAlignmentVertical that are nesting alignment (there should be 0 or 1) + # if 0, alignment is just horizontal and we are adding the first vertical so it will nest to the alignment, + # or there are multiple vertical and they nest to the aggregated child alignments + # if 1, there is one vertical alignments. Move it to a child alignment + vertical_alignments_nesting_alignment = [ + c for c in ifcopenshell.util.element.get_components(parent_alignment) if c.is_a("IfcAlignmentVertical") + ] + + # move the vertical alignment to a child alignment because there is going to be more than one vertical + assert len(vertical_alignments_nesting_alignment) == 0 or len(vertical_alignments_nesting_alignment) == 1 + for vertical_alignment_nesting_alignment in vertical_alignments_nesting_alignment: + _move_vertical_to_child_alignment(file, parent_alignment, vertical_alignment_nesting_alignment) + + if len(child_alignments) == 0 and len(vertical_alignments_nesting_alignment) == 0: + # this is the first vertical alignment so nest it into the parent alignment (IFC CT 4.1.4.4.1.1) + ifcopenshell.api.nest.assign_object( + file, related_objects=[vertical_alignment], relating_object=parent_alignment + ) + + base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment) + if base_curve: + # the parent alignment has a Representation so create a representation for the vertical + gradient_curve = file.create_entity( + type="IfcGradientCurve", Segments=[], SelfIntersect=False, BaseCurve=base_curve, EndPoint=None + ) + + # using the business logic definition of vertical_alignment, create the curve segments and assign to gradient_curve + ifcopenshell.api.alignment.map_alignment_segments(file, vertical_alignment, gradient_curve) + + # Per IFC CT 4.1.7.1.1.1, the shape representation for Horizontal geometry only is + # RepresentationIdentifier="Axis" and RepresentationType="Curve2D". + # However, per IFC CT 4.1.7.1.1.2 and 3 the shape represenation with Horizontal, Vertical and Cant + # is RepresentationIdentifier="FootPrint" and RepresentationType="Curve2D" for the horizontal and + # RepresentationIdentifier="Axis" and RepresentationType="Curve3D" for the 2.5D curve. + # Since the alignment is transitioning from horizontal only to horizontal+vertical, the + # RepresentationIdentifier must change from "Axis" to "FootPrint" + representations = ifcopenshell.util.representation.get_representations_iter(parent_alignment) + for representation in representations: + if representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Curve2D": + representation.RepresentationIdentifier = "FootPrint" + break + + # create the Axis,Curve3D representation + axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) + axis3d_shape_representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(gradient_curve,), + ) + + ifcopenshell.api.geometry.assign_representation(file, parent_alignment, axis3d_shape_representation) + else: + # there are multiple vertical reusing the horizontal (IFC CT 4.1.4.4.1.2) + # this is the second or subsequent vertical reusing the horizontal + + # create a new child alignment for the new vertical + child_alignment = file.create_entity( + type="IfcAlignment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=f"Child of {parent_alignment.Name}", + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + PredefinedType=None, + ) + + # Aggregate the child alignment to the parent alignment + ifcopenshell.api.aggregate.assign_object(file, (child_alignment,), parent_alignment) + + # nest the vertical under the child alignment + ifcopenshell.api.nest.assign_object(file, related_objects=[vertical_alignment], relating_object=child_alignment) + + base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment) + if base_curve: + child_alignment.ObjectPlacement = parent_alignment.ObjectPlacement + + # the parent alignment has a Representation so create a representation for the vertical + gradient_curve = file.create_entity( + type="IfcGradientCurve", Segments=[], SelfIntersect=False, BaseCurve=base_curve, EndPoint=None + ) + + ifcopenshell.api.alignment.map_alignment_segments(file, vertical_alignment, gradient_curve) + + axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) + + # create the Curve3D representation + axis3d_shape_representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(gradient_curve,), + ) + + # add the representation to the child alignment + ifcopenshell.api.geometry.assign_representation(file, child_alignment, axis3d_shape_representation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment_by_pi_method.py new file mode 100644 index 0000000000..67d55f70dc --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_alignment_by_pi_method.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment.add_vertical_alignment +from ifcopenshell import entity_instance +from typing import Sequence + + +def add_vertical_alignment_by_pi_method( + file: ifcopenshell.file, + parent_alignment: entity_instance, + vpoints: Sequence[Sequence[float]], + lengths: Sequence[float], +) -> None: + """ + Adds a vertical alignment to a previously created alignment using the PI method. + + If this is the first vertical alignment assigned to the parent_alignment the IFC CT 4.1.4.4.1.1 Alignment Layout - Horizontal, Vertical and Cant + is followed. If this is the second or subsequent vertical alignment assigned to the parent_alignment the + IFC CT 4.1.4.4.1.2 Alignment Layout - Reusing Horizontal Layout is followed. + + When the second vertical alignment is added, the structure of the IFC model must transition from one concept template to the other. + Specifically, the following occurs: + + 1) The first child IfcAlignment is created and is IfcRelAggregates with the parent alignment. + 2) The first vertical alignment is unassigned from the IfcRelNests of the parent alignment and assigned to the new child alignment IfcRelNests + 3) A second child IfcAlignment is created and it is IfcRelAggregates with the parent alignment. + 4) An IfcAlignmentVertical is created from vpoints and lengths and it is assigned to the second child alignment + + For the third and subsequent vertical alignments, a new child alignment is created and aggregated to the parent alignment and an IfcAlignmentVertical is created + from vpoints and lengths and assigned to the new child alignment. + + If the parent_alignment has a geometric representation, a geometric representation will be created for the vertical alignment. + + :param parent_alignment: The parent alignment + :param vpoints: A sequence of (D,Z) points where D is distance along horizontal and Z is elevation + :param: lengths: Lengths of parabolic vertical curves occuring at each VPI + :return: None + """ + vertical_alignment = ifcopenshell.api.alignment.create_vertical_alignment_by_pi_method( + file, parent_alignment.Name, vpoints, lengths + ) + ifcopenshell.api.alignment.add_vertical_alignment(file, parent_alignment, vertical_alignment) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py new file mode 100644 index 0000000000..a161757213 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_zero_length_segment.py @@ -0,0 +1,101 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.geom +import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper +import numpy as np +from ifcopenshell import entity_instance + + +def add_zero_length_segment(file: ifcopenshell.file, entity: entity_instance) -> None: + """ + Adds a zero length segment to the end of entity. + + :param entity: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant or IfcCompositeCurve (or subtype) + :return: None + """ + expected_types = [ + "IfcAlignmentHorizontal", + "IfcAlignmentVertical", + "IfcAlignmentCant", + "IfcCompositeCurve", + "IfcGradientCurve", + "IfcSegmentedReferenceCurve", + ] + if not entity.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{entity.is_a()}" + ) + + if entity.is_a("IfcCompositeCurve"): + last_segment = entity.Segments[-1] + settings = ifcopenshell.geom.settings() + segment_fn = ifcopenshell_wrapper.map_shape(settings, last_segment.wrapped_data) + segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) + e = segment_evaluator.evaluate(segment_fn.end()) + end = np.array(e) + x = float(end[0, 3]) + y = float(end[1, 3]) + dx = float(end[0, 0]) + dy = float(end[1, 0]) + + parent_curve = file.createIfcLine( + Pnt=file.createIfcCartesianPoint(Coordinates=((0.0, 0.0))), + Dir=file.createIfcVector( + Orientation=file.createIfcDirection(DirectionRatios=((1.0, 0.0))), + Magnitude=1.0, + ), + ) + curve_segment = file.createIfcCurveSegment( + Transition="DISCONTINUOUS", + Placement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint(Coordinates=((x, y))), + RefDirection=file.createIfcDirection((dx, dy)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(0.0), + ParentCurve=parent_curve, + ) + ifcopenshell.api.alignment.add_segment_to_curve(file, curve_segment, entity) + else: + for rel in entity.IsNestedBy: + if 0 < len(rel.RelatedObjects): + last_segment = rel.RelatedObjects[-1] + if last_segment.is_a("IfcAlignmentSegment"): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint( + (0.0, 0.0) + ), # this is a little problematic. need to know the end point and tangent + StartDirection=0.0, # of the previous segment, which requires geometry mapping + SegmentLength=0.0, + PredefinedType="LINE", + ) + segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + ifcopenshell.api.nest.assign_object( + file, + related_objects=[ + segment, + ], + relating_object=entity, + ) + break diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_by_pi_method.py new file mode 100644 index 0000000000..47dc64ef60 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_by_pi_method.py @@ -0,0 +1,80 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance +from typing import Sequence + + +def create_alignment_by_pi_method( + file: ifcopenshell.file, + alignment_name: str, + hpoints: Sequence[Sequence[float]], + radii: Sequence[float], + vpoints: Sequence[Sequence[float]] = None, + lengths: Sequence[float] = None, + alignment_description: str = None, +) -> entity_instance: + """ + Create an alignment using the PI layout method for both horizontal and vertical alignments. + If vpoints and lengths are omitted, only a horizontal alignment is created. Only the business logic + entities are creaed. Use create_geometric_representation() to create the geometric entities. + + :param alignment_name: value for Name attribute + :param points: (X,Y) pairs denoting the location of the horizontal PIs, including start and end + :param radii: radii values to use for transition + :param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end. + :param lengths: parabolic vertical curve horizontal length values to use for transition + :param alignment_description: value for Description attribute + :return: Returns an IfcAlignment + """ + alignments = [] + + horizontal_alignment = ifcopenshell.api.alignment.create_horizontal_alignment_by_pi_method( + file, alignment_name, hpoints, radii + ) + alignments.append(horizontal_alignment) + + if vpoints and lengths: + vertical_alignment = ifcopenshell.api.alignment.create_vertical_alignment_by_pi_method( + file, alignment_name, vpoints, lengths + ) + alignments.append(vertical_alignment) + + # create the alignment + alignment = file.create_entity( + type="IfcAlignment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=alignment_name, + Description=alignment_description, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + PredefinedType=None, + ) + + # nest the horizontal and vertical under the alignment + ifcopenshell.api.nest.assign_object(file, related_objects=alignments, relating_object=alignment) + + # IFC 4.1.4.1.1 Alignment Aggregation To Project + project = file.by_type("IfcProject")[0] + ifcopenshell.api.aggregate.assign_object(file, products=[alignment], relating_object=project) + + return alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_from_csv.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_from_csv.py new file mode 100644 index 0000000000..8643ea1405 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_alignment_from_csv.py @@ -0,0 +1,116 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.alignment +import ifcopenshell.api.geometry +import ifcopenshell.api.nest +import ifcopenshell.guid +import ifcopenshell.util.element +import ifcopenshell.util.representation +import ifcopenshell.util.stationing +import ifcopenshell.api +from ifcopenshell import entity_instance +from ifcopenshell.api.alignment import get_axis_subcontext + +import math +from typing import Sequence + +import csv + + +def create_alignment_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: + """ + Creates an alignment from PI data stored in a CSV file. Only the business logic + entities are creaed. Use create_geometric_representation() to create the geometric entities. + + The format of the file is: + + X1,Y1,R1,X2,Y2,R2 ... Xn-1,Yn-1,Rn-1,Xn,Yn + + D1,Z1,L1,D2,Z2,L2 ... Dn-1,Zn-1,Ln-1,Dn,Zn + + D1,Z1,L1,D2,Z2,L2 ... Dn-1,Zn-1,Ln-1,Dn,Zn + + ... + + where: + X,Y are PI coordinates + + R is the horizontal circular curve radius + + D,Z are VPI coordinates as "Distance Along","Elevation" + + L is the horizontal length of a parabolic vertical transition curve + + R1 and Rn, as well as L1 and Ln are placeholders and not used. They are recommended to have values of 0.0. + + R2 and Rn-2 are the radii of the first and last horizontal curves. + + L2 and Ln-2 are the length of the first and last vertical curves. + + The CSV file contains one horizontal alignment, zero, one, or more vertical alignments + + :param filepath: path the to CSV file + :return: IfcAlignment + """ + with open(filepath, newline="") as csvfile: + reader = csv.reader(csvfile) + row_count = 0 + for row in reader: + data = list(map(float, row)) # Convert all values to float + coordinates: list[list[float]] = ( + [] + ) # horizontal coordinates for first row, vertical coordinates for subsequent rows + radii: list[float] = [] # horizontal curve radii for first row, vertical curve length for subsequent rows + + row_count += 1 + + i = 0 + while i < len(data): + if i + 1 < len(data): + x, y = float(data[i]), float(data[i + 1]) + coordinates.append((x, y)) # Store (X, Y) pair + i += 2 + if i < len(data) and (i + 1) % 3 == 0: # Every third element after an (X,Y) pair is R + radii.append(data[i]) + i += 1 + + radii = radii[1:-1] # The first radius value is a placeholder, remove it + + if row_count == 1: + # create the alignment + alignment = file.createIfcAlignment(GlobalId=ifcopenshell.guid.new()) + # create the horizontal alignment + horizontal_alignment = ifcopenshell.api.alignment.create_horizontal_alignment_by_pi_method( + file, "Alignment_from_CSV", coordinates, radii + ) + # nest them together + ifcopenshell.api.nest.assign_object( + file, related_objects=(horizontal_alignment,), relating_object=alignment + ) + else: + # add all subsequent vertical alignments + ifcopenshell.api.alignment.add_vertical_alignment_by_pi_method(file, alignment, coordinates, radii) + + # IFC 4.1.4.1.1 Alignment Aggregation To Project + project = file.by_type("IfcProject")[0] + ifcopenshell.api.aggregate.assign_object(file, products=[alignment], relating_object=project) + + return alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_geometric_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_geometric_representation.py new file mode 100644 index 0000000000..c15fac39a4 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_geometric_representation.py @@ -0,0 +1,173 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.geometry +from ifcopenshell import entity_instance + +import math +from typing import Sequence + + +def create_geometric_representation(file: ifcopenshell.file, alignment: entity_instance) -> None: + """ + Create geometric representation for the alignment. + + There are 5 different cases: + + 1) Horizontal only + 2) Horizontal + Vertical + 3) Horizontal + Vertical + Cant + 4) Vertical only (this occurs when horizontal is reused from a parent alignment) + 5) Vertical + Cant (this occurs when horizontal is reused from a parent alignment) + + :param alignment: The alignment for which the representation is being created + :return: None + """ + + expected_type = "IfcAlignment" + if not alignment.is_a(expected_type): + raise TypeError("Expected '{expected_type}' but got '{alignment.is_a()}'") + + placement = file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))), + ) + + alignment.ObjectPlacement = placement + + axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) + + layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment) + children = ifcopenshell.api.alignment.get_child_alignments(alignment) + + if len(layouts) == 1 and len(children) == 0: + assert layouts[0].is_a("IfcAlignmentHorizontal") + # Horizontal only - IFC CT 4.1.7.1.1.1 + composite_curve = file.createIfcCompositeCurve() + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[0], composite_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve2D", + Items=(composite_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + elif len(layouts) == 2 and len(children) == 0: + # Horizontal and Vertical - IFC CT 4.1.7.1.1.1 + assert layouts[0].is_a("IfcAlignmentHorizontal") + assert layouts[1].is_a("IfcAlignmentVertical") + composite_curve = file.createIfcCompositeCurve() + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[0], composite_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="FootPrint", + RepresentationType="Curve2D", + Items=(composite_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + + gradient_curve = file.createIfcGradientCurve(BaseCurve=composite_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[1], gradient_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(gradient_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + elif len(layouts) == 3 and len(children) == 0: + # Horizontal, Vertical, and Cant - IFC CT 4.1.7.1.1.3 + assert layouts[0].is_a("IfcAlignmentHorizontal") + assert layouts[1].is_a("IfcAlignmentVertical") + assert layouts[2].is_a("IfcAlignmentCant") + composite_curve = file.createIfcCompositeCurve() + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[0], composite_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="FootPrint", + RepresentationType="Curve2D", + Items=(composite_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + + gradient_curve = file.createIfcGradientCurve(BaseCurve=composite_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[1], gradient_curve) + segmented_reference_curve = file.createIfcSegmentedReferenceCurve(BaseCurve=gradient_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[2], segmented_reference_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(segmented_reference_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + else: + # Reusing Horizontal - CT 4.1.4.4.1.2 + # Create a representation on the parent alignment + composite_curve = file.createIfcCompositeCurve() + ifcopenshell.api.alignment.map_alignment_segments(file, layouts[0], composite_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="FootPrint", + RepresentationType="Curve2D", + Items=(composite_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, alignment, representation) + + for child_alignment in children: + child_alignment.ObjectPlacement = placement + child_layouts = ifcopenshell.api.alignment.get_alignment_layouts(child_alignment) + if len(child_layouts) == 1: + assert child_layouts[0].is_a("IfcAlignmentVertical") + base_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + gradient_curve = file.createIfcGradientCurve(BaseCurve=base_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, child_layouts[0], gradient_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(gradient_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, child_alignment, representation) + elif len(child_layouts) == 2: + assert child_layouts[0].is_a("IfcAlignmentVertical") + assert child_layouts[1].is_a("IfcAlignmentCant") + base_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + gradient_curve = file.createIfcGradientCurve(BaseCurve=base_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, child_layouts[0], gradient_curve) + segmented_reference_curve = file.createIfcSegmentedReferenceCurve(BaseCurve=gradient_curve) + ifcopenshell.api.alignment.map_alignment_segments(file, child_layouts[1], segmented_reference_curve) + representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Curve3D", + Items=(segmented_reference_curve,), + ) + ifcopenshell.api.geometry.assign_representation(file, child_alignment, representation) + else: + assert False # should never get here - can't have more than one vertical and cant in a child alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_horizontal_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_horizontal_alignment_by_pi_method.py new file mode 100644 index 0000000000..e7d15c942f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_horizontal_alignment_by_pi_method.py @@ -0,0 +1,216 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance + +import math +from typing import Sequence + + +def create_horizontal_alignment_by_pi_method( + file: ifcopenshell.file, name: str, hpoints: Sequence[Sequence[float]], radii: Sequence[float] +) -> entity_instance: + """ + Create a horizontal alignment using the PI layout method. + + :param name: value for Name attribute + :param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE). + :param radii: radius values to use for transition + :return: Returns a IfcAlignmentHorizontal + """ + if not (len(hpoints) - 2 == len(radii)): + raise ValueError("radii should have two fewer elements that hpoints") + + # Create the horizontal alignment (IfcAlignmentHorizontal) and nest alignment segments + horizontal_alignment = file.create_entity( + type="IfcAlignmentHorizontal", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=f"{name} - Horizontal", + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + ) + + xBT, yBT = hpoints[0] + xPI, yPI = hpoints[1] + + i = 1 + + for radius in radii: + # back tangent + dxBT = xPI - xBT + dyBT = yPI - yBT + angleBT = math.atan2(dyBT, dxBT) + lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT) + + # forward tangent + i += 1 + xFT, yFT = hpoints[i] + dxFT = xFT - xPI + dyFT = yFT - yPI + angleFT = math.atan2(dyFT, dxFT) + + delta = angleFT - angleBT + + tangent = abs(radius * math.tan(delta / 2)) + + lc = abs(radius * delta) + + radius *= delta / abs(delta) + + xPC = xPI - tangent * math.cos(angleBT) + yPC = yPI - tangent * math.sin(angleBT) + + xPT = xPI + tangent * math.cos(angleFT) + yPT = yPI + tangent * math.sin(angleFT) + + tangent_run = lengthBT - tangent + + # create back tangent run + pt = file.create_entity( + type="IfcCartesianPoint", + Coordinates=(xBT, yBT), + ) + design_parameters = file.create_entity( + type="IfcAlignmentHorizontalSegment", + StartTag=None, + EndTag=None, + StartPoint=pt, + StartDirection=angleBT, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=tangent_run, + GravityCenterLineHeight=None, + PredefinedType="LINE", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal_alignment, alignment_segment) + + # create circular curve + if radius != 0.0: + pc = file.create_entity( + type="IfcCartesianPoint", + Coordinates=(xPC, yPC), + ) + design_parameters = file.create_entity( + type="IfcAlignmentHorizontalSegment", + StartTag=None, + EndTag=None, + StartPoint=pc, + StartDirection=angleBT, + StartRadiusOfCurvature=float(radius), + EndRadiusOfCurvature=float(radius), + SegmentLength=lc, + GravityCenterLineHeight=None, + PredefinedType="CIRCULARARC", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal_alignment, alignment_segment) + + xBT = xPT + yBT = yPT + xPI = xFT + yPI = yFT + + # done processing radii + # create last tangent run + dx = xPI - xBT + dy = yPI - yBT + angleBT = math.atan2(dy, dx) + tangent_run = math.sqrt(dx * dx + dy * dy) + pt = file.create_entity(type="IfcCartesianPoint", Coordinates=(xBT, yBT)) + + design_parameters = file.create_entity( + type="IfcAlignmentHorizontalSegment", + StartTag=None, + EndTag=None, + StartPoint=pt, + StartDirection=angleBT, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=tangent_run, + GravityCenterLineHeight=None, + PredefinedType="LINE", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal_alignment, alignment_segment) + + # create zero length terminator segment + poe = file.create_entity(type="IfcCartesianPoint", Coordinates=(xPI, yPI)) + + design_parameters = file.create_entity( + type="IfcAlignmentHorizontalSegment", + StartTag="POE", + EndTag="POE", + StartPoint=poe, + StartDirection=angleBT, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=0.0, + GravityCenterLineHeight=None, + PredefinedType="LINE", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal_alignment, alignment_segment) + + return horizontal_alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_segment_representations.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_segment_representations.py new file mode 100644 index 0000000000..a5dc42cfc3 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_segment_representations.py @@ -0,0 +1,76 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance +from ifcopenshell import ifcopenshell_wrapper +import math +from typing import Sequence + + +def create_segment_representations( + file: ifcopenshell.file, + alignment: entity_instance, +) -> None: + """ + Creates curve segment representations for the alignment for IFC CT 4.1.7.1.1.4. The alignment is expected to have representations + for "Axis/Curve2D" (horizontal only) or "FootPrint/Curve2D" and "Axis/Curve3D" (horizontal + vertical/cant). There is the additional + expectation that there is a 1-to-1 relationship between IfcAlignmentSegment and IfcCurveSegment. + That is, no Helmert curves in the alignment which have a 1-to-2 relationship + + :param alignment: The alignment to create segment representations. + """ + expected_type = "IfcAlignment" + if not alignment.is_a(expected_type): + raise TypeError(f"Expected to see type '{expected_type}', instead received '{alignment.is_a()}'.") + + axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file) + representations = ifcopenshell.util.representation.get_representations_iter(alignment) + for representation in representations: + curve = None + nested_alignment = None + if (representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Curve2D") or ( + representation.RepresentationIdentifier == "FootPrint" and representation.RepresentationType == "Curve2D" + ): + curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + nested_alignment = [ + c for c in ifcopenshell.util.element.get_components(alignment) if c.is_a("IfcAlignmentHorizontal") + ][0] + elif representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Curve3D": + curve = ifcopenshell.api.alignment.get_curve(alignment) + nested_alignment = [ + c for c in ifcopenshell.util.element.get_components(alignment) if c.is_a("IfcAlignmentVertical") + ][0] + + curve_segments = curve.Segments + segments = nested_alignment.IsNestedBy[0].RelatingObjects + + for curve_segment, alignment_segment in zip(curve_segments, segments): + axis_representation = file.create_entity( + type="IfcShapeRepresentation", + ContextOfItems=axis_geom_subcontext, + RepresentationIdentifier="Axis", + RepresentationType="Segment", + Items=(curve_segment,), + ) + product = file.create_entity( + type="IfcProductDefinitionShape", Name=None, Description=None, Representations=(axis_representation,) + ) + alignment_segment.ObjectPlacement = alignment.ObjectPlacement + alignment_segment.Representation = product diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_vertical_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_vertical_alignment_by_pi_method.py new file mode 100644 index 0000000000..42652fc9b7 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_vertical_alignment_by_pi_method.py @@ -0,0 +1,194 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance + +import math +from typing import Sequence + + +def create_vertical_alignment_by_pi_method( + file: ifcopenshell.file, name: str, vpoints: Sequence[Sequence[float]], lengths: Sequence[float] +) -> entity_instance: + """ + Create a vertical alignment using the PI layout method. + + :param name: value for Name attribute + :param base_curve: base curve representing the 2D projection of the gradient curve + :param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end. + :param lengths: horizontal length of parabolic vertical curves + :return: IfcAlignmentHorizontal + """ + if not (len(vpoints) - 2 == len(lengths)): + raise ValueError("lengths should have two fewer elements that vpoints") + + # Create the vertical alignment (IfcAlignmentVertical) and nest alignment segments + vertical_alignment = file.create_entity( + type="IfcAlignmentVertical", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=f"{name} - Vertical", + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + ) + + xPBG, yPBG = vpoints[0] + xPVI, yPVI = vpoints[1] + i = 1 + for length in lengths: + # back gradient + dxBG = xPVI - xPBG + dyBG = yPVI - yPBG + start_slope = math.tan(math.atan2(dyBG, dxBG)) + + # forward gradient + i += 1 + xPFG, yPFG = vpoints[i] + dxFG = xPFG - xPVI + dyFG = yPFG - yPVI + end_slope = math.tan(math.atan2(dyFG, dxFG)) + + xEVC = xPVI + length / 2.0 + yEVC = yPVI + end_slope * length / 2.0 + + # create gradient + gradient_length = dxBG - length / 2.0 + design_parameters = file.create_entity( + type="IfcAlignmentVerticalSegment", + StartTag=None, + EndTag=None, + StartDistAlong=xPBG, + HorizontalLength=gradient_length, + StartHeight=yPBG, + StartGradient=start_slope, + EndGradient=start_slope, + RadiusOfCurvature=None, + PredefinedType="CONSTANTGRADIENT", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, vertical_alignment, alignment_segment) + + # create vertical curve + if 0.0 < length: + k = (end_slope - start_slope) / length + xBVC = xPVI - length / 2.0 + yBVC = yPVI - start_slope * length / 2.0 + + design_parameters = file.create_entity( + type="IfcAlignmentVerticalSegment", + StartTag=None, + EndTag=None, + StartDistAlong=xBVC, + HorizontalLength=length, + StartHeight=yBVC, + StartGradient=start_slope, + EndGradient=end_slope, + RadiusOfCurvature=1 / k, + PredefinedType="PARABOLICARC", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, vertical_alignment, alignment_segment) + + # start of next curve is end of this curve + xPBG = xEVC + yPBG = yEVC + xPVI = xPFG + yPVI = yPFG + + # create last gradient run + dx = xPVI - xPBG + dy = yPVI - yPBG + slope = math.tan(math.atan2(dy, dx)) + gradient_length = dx + + design_parameters = file.create_entity( + type="IfcAlignmentVerticalSegment", + StartTag=None, + EndTag=None, + StartDistAlong=xPBG, + HorizontalLength=gradient_length, + StartHeight=yPBG, + StartGradient=slope, + EndGradient=slope, + RadiusOfCurvature=None, + PredefinedType="CONSTANTGRADIENT", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, vertical_alignment, alignment_segment) + + # create zero length terminator segment + design_parameters = file.create_entity( + type="IfcAlignmentVerticalSegment", + StartTag="VPOE", + EndTag="VPOE", + StartDistAlong=xPVI, + HorizontalLength=0.0, + StartHeight=yPVI, + StartGradient=slope, + EndGradient=slope, + RadiusOfCurvature=None, + PredefinedType="CONSTANTGRADIENT", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + ifcopenshell.api.alignment.add_segment_to_layout(file, vertical_alignment, alignment_segment) + + return vertical_alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py new file mode 100644 index 0000000000..8bfbb8f03c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py @@ -0,0 +1,56 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.guid +from ifcopenshell import entity_instance + + +def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float: + """ + Given a station, returns the distance along the horizontal alignment. + + If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed + to be at station 0.0. That is, the station is the distance along. + + .. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment. + + :param alignment: the alignment + :param station: station value + :return: distance along the horizontal alignment + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] # alignment with start station 1+00.00 + dist_along = ifcopenshell.api.alignment.distance_along_from_station(model,alignment=alignment,station=200.0) + print(dist_along) # 100.00 + """ + + start_station = 0.0 + components = ifcopenshell.util.element.get_components(alignment) + for c in components: + if c.is_a("IfcReferent") and ifcopenshell.util.element.get_predefined_type(c) == "STATION": + start_station = ifcopenshell.util.element.get_pset(c, name="Pset_Stationing", prop="Station") + break + + dist_along = station - start_station + return dist_along diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_alignment_layouts.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_alignment_layouts.py new file mode 100644 index 0000000000..929634f6b9 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_alignment_layouts.py @@ -0,0 +1,41 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util +from ifcopenshell import entity_instance +from typing import Sequence + +import ifcopenshell.util.representation + + +def get_alignment_layouts(alignment: entity_instance) -> Sequence[entity_instance]: + """ + Returns the layout alignments nested to this alignment + """ + layouts = [] + for rel in alignment.IsNestedBy: + for layout in rel.RelatedObjects: + if ( + layout.is_a("IfcAlignmentHorizontal") + or layout.is_a("IfcAlignmentVertical") + or layout.is_a("IfcAlignmentCant") + ): + layouts.append(layout) + + return layouts diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_axis_subcontext.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_axis_subcontext.py new file mode 100644 index 0000000000..033c94d9bb --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_axis_subcontext.py @@ -0,0 +1,40 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util.representation +import ifcopenshell.api.context +from ifcopenshell import entity_instance + + +def get_axis_subcontext(file: ifcopenshell.file) -> entity_instance: + """ + Returns the IfcGeometricRepresentationSubContext for Model, Axis, MODEL_VIEW. If one does not exist, it is created. + """ + axis_geom_subcontext = ifcopenshell.util.representation.get_context(file, "Model", "Axis", "MODEL_VIEW") + if axis_geom_subcontext == None: + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_geom_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + return axis_geom_subcontext diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_basis_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_basis_curve.py new file mode 100644 index 0000000000..b59d623f26 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_basis_curve.py @@ -0,0 +1,51 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util +from ifcopenshell import entity_instance +from typing import Sequence + +import ifcopenshell.util.representation + + +def get_basis_curve(alignment: entity_instance) -> entity_instance: + """ + Returns the basis curve for an alignment. This curve is the geometric representation that is used + as the basis curve for vertical and cant alignments. + + :param alignment: The alignment + :return: The geometric representation that is used as a basis curve, typically an IfcCompositeCurve, or None if the alignment does not have a representation + + Example: + + .. code:: python + alignment = model.by_type("IfcAlignment")[0] + composite_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + """ + axis = None + + representations = ifcopenshell.util.representation.get_representations_iter(alignment) + for representation in representations: + if (representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Curve2D") or ( + representation.RepresentationIdentifier == "FootPrint" and representation.RepresentationType == "Curve2D" + ): + axis = representation + break + + return None if axis == None or axis.Items == None or len(axis.Items) == 0 else axis.Items[0] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_child_alignments.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_child_alignments.py new file mode 100644 index 0000000000..7e80477a03 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_child_alignments.py @@ -0,0 +1,44 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util +from ifcopenshell import entity_instance +from typing import Sequence + +import ifcopenshell.util.element + + +def get_child_alignments(alignment: entity_instance) -> Sequence[entity_instance]: + """ + Returns the aggregated child alignments to this alignment + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] + children = ifcopenshell.api.alignment.get_child_alignments(alignment) + """ + children = [] + for rel in alignment.IsDecomposedBy: + for child in rel.RelatedObjects: + if child.is_a("IfcAlignment"): + children.append(child) + + return children diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve.py new file mode 100644 index 0000000000..c2889bdc3f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_curve.py @@ -0,0 +1,52 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util +from ifcopenshell import entity_instance +from typing import Sequence + +import ifcopenshell.util.representation + + +def get_curve(alignment: entity_instance) -> entity_instance: + """ + Returns the geometric representation curve for an alignment. + A horizontal only will have a curve of type IfcCompositeCurve + A horizontal+vertical will have a curve of type IfcGradientCurve + A horizontal+vertical+cant will have a curve of tyep IfcSegmentedReferenceCurve + + :param alignment: The alignment + :return: The geometric representation of the alignemnt or None if the alignment does not have a representation + + Example: + + .. code:: python + alignment = model.by_type("IfcAlignment")[0] + gradient_curve = ifcopenshell.api.alignment.get_curve(alignment) + """ + axis = None + representations = ifcopenshell.util.representation.get_representations_iter(alignment) + for representation in representations: + if representation.RepresentationIdentifier == "Axis" and ( + representation.RepresentationType == "Curve2D" or representation.RepresentationType == "Curve3D" + ): + axis = representation + break + + return None if axis == None or len(axis.Items) == 0 else axis.Items[0] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_parent_alignment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_parent_alignment.py new file mode 100644 index 0000000000..dfd6fb8629 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_parent_alignment.py @@ -0,0 +1,45 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.util +from ifcopenshell import entity_instance +from typing import Sequence + +import ifcopenshell.util.representation + + +def get_parent_alignment(alignment: entity_instance) -> entity_instance: + """ + Returns the parent alignment. When multiple vertical alignments share a horizontal alignment + the horizontal alignment is nested to the parent alignment, a child alignment is aggregated + to the parent alignment for each vertical alignment, and the vertical alignment is nested with + its child alignment. + + Example: + + .. code:: python + alignment = model.by_type("IfcAlignment")[0] + parent = ifcopenshell.api.alignment.get_parent_alignment(alignment) + """ + + for rel in alignment.Decomposes: + if rel.RelatingObject.is_a("IfcAlignment"): + return rel.RelatingObject + + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/has_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/has_zero_length_segment.py new file mode 100644 index 0000000000..2b26cb6fe7 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/has_zero_length_segment.py @@ -0,0 +1,61 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.util.element +from ifcopenshell import entity_instance + + +def has_zero_length_segment(entity: entity_instance) -> bool: + """ + Returns true if the entity ends with a zero length segment. If the entity is an IfcCompositeCurve the IfcCurveSegment.Transition must be DISCONTINUOUS + + :param entity: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant or IfcCompositeCurve + :return: True if the zero length segment is present + """ + expected_types = [ + "IfcAlignmentHorizontal", + "IfcAlignmentVertical", + "IfcAlignmentCant", + "IfcCompositeCurve", + "IfcGradientCurve", + "IfcSegmentedReferenceCurve", + ] + if not entity.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{entity.is_a()}" + ) + + if entity.is_a("IfcCompositeCurve"): + last_segment = entity.Segments[-1] + return last_segment.Transition == "DISCONTINUOUS" and last_segment.SegmentLength.wrappedValue == 0.0 + else: + segments = ifcopenshell.util.element.get_components(entity) + for rel in entity.IsNestedBy: + if 0 < len(rel.RelatedObjects): + last_segment = rel.RelatedObjects[-1] + if last_segment.is_a("IfcAlignmentSegment"): + if last_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): + return last_segment.DesignParameters.SegmentLength == 0.0 + elif last_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): + return last_segment.DesignParameters.HorizontalLength == 0.0 + elif last_segment.DesignParameters.is_a("IfcAlignmentCantSegment"): + return last_segment.DesignParameters.HorizontalLength == 0.0 + + return False diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_cant_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_cant_segment.py new file mode 100644 index 0000000000..b911fc6b45 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_cant_segment.py @@ -0,0 +1,84 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +from ifcopenshell import entity_instance +from ifcopenshell.api.alignment import get_axis_subcontext +from typing import Sequence + + +def _map_constant_cant(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("CONSTANTCANT not implemented") + + +def _map_linear_transition(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("LINEARTRANSTION not implemented") + + +def _map_helmert_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("HELMERTCURVE not implemented") + + +def _map_bloss_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("BLOSSCURVE not implemented") + + +def _map_cosine_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("COSINECURVE not implemented") + + +def _map_sine_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("SINECURVE not implemented") + + +def _map_viennese_bend(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("VIENNESEBEND not implemented") + + +def map_alignment_cant_segment( + file: ifcopenshell.file, design_parameters: entity_instance +) -> Sequence[entity_instance]: + """ + Creates IfcCurveSegment entities for the represention of the supplied IfcAlignmentCantSegment business logic entity instance. + A pair of entities is returned because a single business logic segment of type HELMERTCURVE maps to two representaiton entities. + + The IfcCurveSegment.Transition transition code is set to DISCONTINUOUS. + """ + expected_type = "IfcAlignmentCantSegment" + if not design_parameters.is_a(expected_type): + raise TypeError(f"Expected to see type '{expected_type}', instead received '{design_parameters.is_a()}'.") + + match design_parameters.PredefinedType: + case "CONSTANTCANT": + result = _map_constant_cant(file, design_parameters) + case "LINEARTRANSITION": + result = _map_linear_transition(file, design_parameters) + case "HELMERTCURVE": + result = _map_helmert_curve(file, design_parameters) + case "BLOSSCURVE": + result = _map_bloss_curve(file, design_parameters) + case "COSINECURVE": + result = _map_cosine_curve(file, design_parameters) + case "SINECURVE": + result = _map_sine_curve(file, design_parameters) + case "VIENNESEBEND": + result = _map_viennese_bend(file, design_parameters) + case _: + raise TypeError("Unexpected predefined type") + + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_horizontal_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_horizontal_segment.py new file mode 100644 index 0000000000..bb86ea6bbb --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_horizontal_segment.py @@ -0,0 +1,439 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +from ifcopenshell import entity_instance +import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper +from typing import Sequence +import math + + +def _get_curve_factor(design_parameters: entity_instance) -> float: + start_radius = design_parameters.StartRadiusOfCurvature + end_radius = design_parameters.EndRadiusOfCurvature + length = design_parameters.SegmentLength + + f = (0.0 if end_radius == 0.0 else length / end_radius) - (0.0 if start_radius == 0.0 else length / start_radius) + return f + + +def _map_line(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + parent_curve = file.create_entity( + type="IfcLine", + Pnt=file.create_entity( + type="IfcCartesianPoint", + Coordinates=(0.0, 0.0), + ), + Dir=file.create_entity( + type="IfcVector", + Orientation=file.create_entity( + type="IfcDirection", + DirectionRatios=(1.0, 0.0), + ), + Magnitude=1.0, + ), + ) + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection( + (math.cos(start_direction), math.sin(start_direction)), + ), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_circular_arc(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + parent_curve = file.createIfcCircle( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + Radius=math.fabs(start_radius), + ) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.createIfcAxis2Placement2D( + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length * (start_radius / math.fabs(start_radius))), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_clothoid(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + end_radius = design_parameters.EndRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + f = _get_curve_factor(design_parameters) + A = (length / math.sqrt(math.fabs(f))) * (f / math.fabs(f)) + parent_curve = file.createIfcClothoid( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + ClothoidConstant=A, + ) + + if (math.fabs(start_radius) < math.fabs(end_radius) and start_radius != 0.0) or end_radius == 0.0: + offset = -length - (length * start_radius / (end_radius - start_radius) if end_radius != 0.0 else 0.0) + else: + offset = length * end_radius / (start_radius - end_radius) if start_radius != 0.0 else 0.0 + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(offset), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_cubic(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + end_radius = design_parameters.EndRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + offset = 0.0 + A0 = 0.0 # constant term + A1 = 0.0 # linear term + A2 = 0.0 # quadratic term + A3 = 0.0 # cubic term + + if end_radius != 0.0 and start_radius != 0.0 and end_radius != start_radius: + f = (start_radius - end_radius) / end_radius # note, this "f" is different that _get_curve_factor computes + A3 = f / (6.0 * start_radius * length) + offset = length / f + elif end_radius != 0.0: + A3 = 1.0 / (6.0 * end_radius * length) + offset = 0.0 + elif start_radius != 0.0: + A3 = -1.0 / (6.0 * start_radius * length) + offset = -length + + parent_curve = file.createIfcPolynomialCurve( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + CoefficientsX=(0.0, 1.0), + CoefficientsY=(A0, A1, A2, A3), + ) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(offset), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_helmert_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + end_radius = design_parameters.EndRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + f = _get_curve_factor(design_parameters) + + a0_1 = 0.0 * f + length / start_radius if start_radius != 0 else 0.0 # constant term, first half + a1_1 = 0.0 * f # linear term, first half + a2_1 = 2.0 * f # quadratic term, first half + + A0_1 = length * math.pow(math.fabs(a0_1), -1.0 / 1.0) * a0_1 / math.fabs(a0_1) if a0_1 != 0.0 else 0.0 + A1_1 = length * math.pow(math.fabs(a1_1), -1.0 / 2.0) * a1_1 / math.fabs(a1_1) if a1_1 != 0.0 else 0.0 + A2_1 = length * math.pow(math.fabs(a2_1), -1.0 / 3.0) * a2_1 / math.fabs(a2_1) if a2_1 != 0.0 else 0.0 + + x1, y1, angle1 = ifcopenshell_wrapper.helmert_curve_point(A0_1, A1_1, A2_1, length / 2) + + parent_curve1 = file.createIfcSecondOrderPolynomialSpiral( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0)) + ), + QuadraticTerm=A2_1, + LinearTerm=A1_1 if A1_1 != 0.0 else None, + ConstantTerm=A0_1 if A0_1 != 0.0 else None, + ) + + curve_segment1 = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length / 2), + ParentCurve=parent_curve1, + ) + + a0_2 = -1.0 * f + (length / start_radius if start_radius != 0.0 else 0.0) # constant term, second half + a1_2 = 4.0 * f # linear term, second half + a2_2 = -2.0 * f # quadratic term, second half + + A0_2 = length * math.pow(math.fabs(a0_2), -1.0 / 1.0) * (a0_2 / math.fabs(a0_2)) if a0_2 != 0.0 else 0.0 + A1_2 = length * math.pow(math.fabs(a1_2), -1.0 / 2.0) * (a1_2 / math.fabs(a1_2)) if a1_2 != 0.0 else 0.0 + A2_2 = length * math.pow(math.fabs(a2_2), -1.0 / 3.0) * (a2_2 / math.fabs(a2_2)) if a2_2 != 0.0 else 0.0 + + x2, y2, angle2 = ifcopenshell_wrapper.helmert_curve_point(A0_2, A1_2, A2_2, length / 2) + anglep = angle1 - angle2 + xp = x1 - x2 * math.cos(anglep) + y2 * math.sin(anglep) + yp = y1 - x2 * math.sin(anglep) - y2 * math.cos(anglep) + + parent_curve2 = file.createIfcSecondOrderPolynomialSpiral( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((xp, yp)), + RefDirection=file.createIfcDirection((math.cos(anglep), math.sin(anglep))), + ), + QuadraticTerm=A2_2, + LinearTerm=A1_2 if A1_2 != 0.0 else None, + ConstantTerm=A0_2 if A0_2 != 0.0 else None, + ) + + curve_segment2 = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=file.createIfcCartesianPoint((x1, y1)), + RefDirection=file.createIfcDirection((math.cos(angle1), math.sin(angle1))), + ), + SegmentStart=file.createIfcLengthMeasure(length / 2), + SegmentLength=file.createIfcLengthMeasure(length / 2), + ParentCurve=parent_curve2, + ) + + return curve_segment1, curve_segment2 + + +def _map_bloss_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + f = _get_curve_factor(design_parameters) + + a0 = length / start_radius if start_radius != 0.0 else 0.0 # constant term + a1 = 0.0 # linear term + a2 = 3.0 * f # quadratic term + a3 = -2.0 * f # cubic term + + A0 = length * math.pow(math.fabs(a0), -1.0 / 1.0) * (a0 / math.fabs(a0)) if a0 != 0.0 else 0.0 + A1 = length * math.pow(math.fabs(a1), -1.0 / 2.0) * (a1 / math.fabs(a1)) if a1 != 0.0 else 0.0 + A2 = length * math.pow(math.fabs(a2), -1.0 / 3.0) * (a2 / math.fabs(a2)) if a2 != 0.0 else 0.0 + A3 = length * math.pow(math.fabs(a3), -1.0 / 4.0) * (a3 / math.fabs(a3)) if a3 != 0.0 else 0.0 + + parent_curve = file.createIfcThirdOrderPolynomialSpiral( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0)) + ), + CubicTerm=A3, + QuadraticTerm=A2 if A2 != 0.0 else None, + LinearTerm=A1 if A1 != 0.0 else None, + ConstantTerm=A0 if A0 != 0.0 else None, + ) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_cosine_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + f = _get_curve_factor(design_parameters) + + a0 = 0.5 * f + (length / start_radius if start_radius != 0.0 else 0.0) + a1 = -0.5 * f + + A0 = length * math.pow(math.fabs(a0), -1.0 / 1.0) * (a0 / math.fabs(a0)) if a0 != 0.0 else 0.0 + A1 = length * math.pow(math.fabs(a1), -1.0 / 1.0) * (a1 / math.fabs(a1)) if a1 != 0.0 else 0.0 + + parent_curve = file.createIfcCosineSpiral( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + CosineTerm=A1, + ConstantTerm=(A0 if A0 != 0.0 else None), + ) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_sine_curve(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_point = design_parameters.StartPoint + start_direction = design_parameters.StartDirection + start_radius = design_parameters.StartRadiusOfCurvature + length = design_parameters.SegmentLength + + transition = "DISCONTINUOUS" + + f = _get_curve_factor(design_parameters) + a0 = length / start_radius if start_radius != 0.0 else 0.0 + a1 = f + a2 = -f / (2.0 * math.pi) + + A0 = length * math.pow(math.fabs(a0), -1.0 / 1.0) * (a0 / math.fabs(a0)) if a0 != 0.0 else 0.0 + A1 = length * math.pow(math.fabs(a1), -1.0 / 2.0) * (a1 / math.fabs(a1)) if a1 != 0.0 else 0.0 + A2 = length * math.pow(math.fabs(a2), -1.0 / 1.0) * (a2 / math.fabs(a2)) if a2 != 0.0 else 0.0 + + parent_curve = file.createIfcSineSpiral( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + SineTerm=A2, + LinearTerm=(A1 if A1 != 0.0 else None), + ConstantTerm=(A0 if A0 != 0.0 else None), + ) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=start_point, + RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction))), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_viennese_bend(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("VIENNESEBEND not implemented") + + +def map_alignment_horizontal_segment( + file: ifcopenshell.file, design_parameters: entity_instance +) -> Sequence[entity_instance]: + """ + Creates IfcCurveSegment entities for the represention of the supplied IfcAlignmentHorizontalSegment business logic entity instance. + A pair of entities is returned because a single business logic segment of type HELMERTCURVE maps to two representaiton entities. + + The IfcCurveSegment.Transition transition code is set to DISCONTINUOUS + """ + expected_type = "IfcAlignmentHorizontalSegment" + if not design_parameters.is_a(expected_type): + raise TypeError(f"Expected to see type '{expected_type}', instead received '{design_parameters.is_a()}'.") + + match design_parameters.PredefinedType: + case "LINE": + result = _map_line(file, design_parameters) + case "CIRCULARARC": + result = _map_circular_arc(file, design_parameters) + case "CLOTHOID": + result = _map_clothoid(file, design_parameters) + case "CUBIC": + result = _map_cubic(file, design_parameters) + case "HELMERTCURVE": + result = _map_helmert_curve(file, design_parameters) + case "BLOSSCURVE": + result = _map_bloss_curve(file, design_parameters) + case "COSINECURVE": + result = _map_cosine_curve(file, design_parameters) + case "SINECURVE": + result = _map_sine_curve(file, design_parameters) + case "VIENNESEBEND": + result = _map_viennese_bend(file, design_parameters) + case _: + raise TypeError("Unexpected predefined type") + + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segment.py new file mode 100644 index 0000000000..d7b05d5544 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segment.py @@ -0,0 +1,47 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api +from ifcopenshell import entity_instance +from typing import Sequence + + +def map_alignment_segment(file: ifcopenshell.file, segment: entity_instance) -> Sequence[entity_instance]: + """ + Creates IfcCurveSegment entities for the represention of the supplied IfcAlignmentSegment business logic entity instance. + A pair of entities is returned because a single business logic segment of type HELMERTCURVE maps to two representaiton entities. + + The IfcCurveSegment.Transition transition code is set to DISCONTINUOUS, except for the transition between helmert curve segments. + + This function will evaluate the IfcAlignmentSegment.DesignParameters type and call the correct lower level mapping function. + """ + expected_type = "IfcAlignmentSegment" + if not segment.is_a(expected_type): + raise TypeError(f"Expected to see type '{expected_type}', instead received '{segment.is_a()}'.") + + if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"): + return ifcopenshell.api.alignment.map_alignment_horizontal_segment(file, segment.DesignParameters) + elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"): + return ifcopenshell.api.alignment.map_alignment_vertical_segment(file, segment.DesignParameters) + elif segment.DesignParameters.is_a("IfcAlignmentCantSegment"): + return ifcopenshell.api.alignment.map_alignment_cant_segment(file, segment.DesignParameters) + else: + raise TypeError("Unexpected type for segment.DesignParameters") + + return (None, None) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segments.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segments.py new file mode 100644 index 0000000000..d9012d59ef --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_segments.py @@ -0,0 +1,62 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance +from typing import Sequence + + +def map_alignment_segments( + file: ifcopenshell.file, alignment: entity_instance, composite_curve: entity_instance +) -> None: + """ + Creates IfcCurveSegment entities for the supplied alignment business logic entity instance and assigns them to the composite curve. + End-Start points of adjacent segments are evaluated and the IfcCurveSegment.Transition is set. + + This function does not create an IfcShapeRepresentation. Use create_geometric_representation to create all the representations + for an alignment. This function only populates the composite curve with IfcCurveSegment entities. + + :param alignment: The business logic alignment, expected to be IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant + :param composite_curve: The IfcCompositeCurve (or subclass) which will receive the IfcCurveSegment + :return: None + """ + expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"] + if not alignment.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{alignment.is_a()}" + ) + + if alignment.is_a("IfcAlignmentHorizontal") and not composite_curve.is_a("IfcCompositeCurve"): + raise TypeError(f"Expected to see IfcCompositeCurve, instead received '{composite_curve.is_a()}'.") + elif alignment.is_a("IfcAlignmentVertical") and not composite_curve.is_a("IfcGradientCurve"): + raise TypeError(f"Expected to see IfcGradientCurve, instead received '{composite_curve.is_a()}'.") + elif alignment.is_a("IfcAlignmentCant") and not composite_curve.is_a("IfcSegmentedReferenceCurve"): + raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{composite_curve.is_a()}'.") + + settings = ifcopenshell.geom.settings() + + composite_curve.SelfIntersect = False + + for rel_nests in alignment.IsNestedBy: + for layout in rel_nests.RelatedObjects: + if layout.is_a("IfcLinearElement"): + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, layout) + for mapped_segment in mapped_segments: + if mapped_segment: + ifcopenshell.api.alignment.add_segment_to_curve(file, mapped_segment, composite_curve) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_vertical_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_vertical_segment.py new file mode 100644 index 0000000000..3e80f3c6f3 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/map_alignment_vertical_segment.py @@ -0,0 +1,225 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +from ifcopenshell import ifcopenshell_wrapper +from ifcopenshell import entity_instance +from typing import Sequence +import math + + +def _polynomial_length(A: float, B: float, C: float, L: float) -> float: + # closed form solultion for length of parabolic curve. + # see https://www.integral-table.com, equation #37 + # Parabolic curve equation: y = A + Bx + Cx^2 + # y' = B + 2Cx + # Length of a curve = Integral[0,L]( (y')^2 + 1) dx) + # y'^2 = 4C^2x^2 + 4BCx + B^2 + # Substituting, Length of a curve = Integral[0,L]( (4C^2)x^2 + (4BC)x + (B^2 + 1)) dx) + # for eq. #37 cited above, a = 4C^2, b = 4BC, c = B^2 + 1 + a = 4.0 * C * C + b = 4.0 * B * C + c = B * B + 1 + + v1 = lambda a, b, c, x: (b + 2.0 * a * x) / (4.0 * a) + v2 = lambda a, b, c, x: math.sqrt(a * x * x + b * x + c) + v3 = lambda a, b, c, x: (4.0 * a * c - b * b) / (8.0 * math.pow(a, 1.5)) + v4 = lambda a, b, c, x: math.log(math.fabs(2.0 * a * x + b + 2.0 * math.sqrt(a * (a * x * x + b * x + c)))) + + fn = lambda a, b, c, x: v1(a, b, c, x) * v2(a, b, c, x) + v3(a, b, c, x) * v4(a, b, c, x) + + curve_length = fn(a, b, c, L) - fn( + a, b, c, 0 + ) # remember when evaluating an integral, it must be evaluated at end points (L and 0) + return curve_length + + +def _map_constant_gradient(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_distance_along = design_parameters.StartDistAlong + horizontal_length = design_parameters.HorizontalLength + start_height = design_parameters.StartHeight + start_gradient = design_parameters.StartGradient + end_gradient = design_parameters.EndGradient + radius_of_curvature = design_parameters.RadiusOfCurvature + transition = "DISCONTINUOUS" + + parent_curve = file.create_entity( + type="IfcLine", + Pnt=file.create_entity( + type="IfcCartesianPoint", + Coordinates=(0.0, 0.0), + ), + Dir=file.create_entity( + type="IfcVector", + Orientation=file.create_entity( + type="IfcDirection", + DirectionRatios=(1.0, 0.0), + ), + Magnitude=1.0, + ), + ) + + dx = math.cos(math.atan(start_gradient)) + dy = math.sin(math.atan(start_gradient)) + curve_segment_length = horizontal_length / dx + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=file.create_entity(type="IfcCartesianPoint", Coordinates=(start_distance_along, start_height)), + RefDirection=file.createIfcDirection((dx, dy)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(curve_segment_length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_parabolic_arc(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_distance_along = design_parameters.StartDistAlong + horizontal_length = design_parameters.HorizontalLength + start_height = design_parameters.StartHeight + start_gradient = design_parameters.StartGradient + end_gradient = design_parameters.EndGradient + radius_of_curvature = design_parameters.RadiusOfCurvature + transition = "DISCONTINUOUS" + + A = start_height + B = start_gradient + C = (end_gradient - start_gradient) / (2.0 * horizontal_length) + + parent_curve = file.create_entity( + type="IfcPolynomialCurve", + Position=file.create_entity( + type="IfcAxis2Placement2D", + Location=file.create_entity(type="IfcCartesianPoint", Coordinates=(0.0, 0.0)), + RefDirection=file.createIfcDirection( + (1.0, 0.0), + ), + ), + CoefficientsX=(0.0, 1.0), + CoefficientsY=(A, B, C), + ) + + dx = math.cos(math.atan(start_gradient)) + dy = math.sin(math.atan(start_gradient)) + curve_segment_length = _polynomial_length(A, B, C, horizontal_length) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.create_entity( + type="IfcAxis2Placement2D", + Location=file.create_entity(type="IfcCartesianPoint", Coordinates=(start_distance_along, start_height)), + RefDirection=file.createIfcDirection((dx, dy)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(curve_segment_length), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_circular_arc(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + start_distance_along = design_parameters.StartDistAlong + horizontal_length = design_parameters.HorizontalLength + start_height = design_parameters.StartHeight + start_gradient = design_parameters.StartGradient + end_gradient = design_parameters.EndGradient + radius_of_curvature = design_parameters.RadiusOfCurvature + transition = "DISCONTINUOUS" + + start_angle = math.atan(start_gradient) + end_angle = math.atan(end_gradient) + dx = math.cos(start_angle) + dy = math.sin(start_angle) + if start_angle < end_angle: + radius = horizontal_length / (math.sin(end_angle) - math.sin(start_angle)) + x = -radius * math.sin(start_angle) + y = radius * math.cos(start_angle) + start_angle += 3.0 * math.pi / 2.0 + end_angle += 3.0 * math.pi / 2.0 + else: + radius = horizontal_length / (math.sin(start_angle) - math.sin(end_angle)) + x = radius * math.sin(start_angle) + y = -radius * math.cos(start_angle) + start_angle += math.pi / 2.0 + end_angle += math.pi / 2.0 + + parent_curve = file.createIfcCircle( + Position=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((x, y)), + RefDirection=file.createIfcDirection((1.0, 0.0)), + ), + Radius=radius, + ) + + segment_curve_length = radius * math.fabs(end_angle - start_angle) + + curve_segment = file.create_entity( + type="IfcCurveSegment", + Transition=transition, + Placement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((start_distance_along, start_height)), + RefDirection=file.createIfcDirection( + (dx, dy), + ), + ), + SegmentStart=file.createIfcLengthMeasure(radius * start_angle), + SegmentLength=file.createIfcLengthMeasure(radius * (end_angle - start_angle)), + ParentCurve=parent_curve, + ) + return (curve_segment, None) + + +def _map_clothoid(file: ifcopenshell.file, design_parameters: entity_instance) -> Sequence[entity_instance]: + raise NotImplementedError("mapping for IfcVerticalSegment.CLOTHOID not implemented") + + +def map_alignment_vertical_segment( + file: ifcopenshell.file, design_parameters: entity_instance +) -> Sequence[entity_instance]: + """ + Creates IfcCurveSegment entities for the represention of the supplied IfcAlignmentVerticalSegment business logic entity instance. + A pair of entities is returned for consistency with map_alignment_horizontal_segment and map_alignment_cant_segment. + + """ + expected_type = "IfcAlignmentVerticalSegment" + if not design_parameters.is_a(expected_type): + raise TypeError(f"Expected to see type '{expected_type}', instead received '{design_parameters.is_a()}'.") + + match design_parameters.PredefinedType: + case "CONSTANTGRADIENT": + result = _map_constant_gradient(file, design_parameters) + + case "PARABOLICARC": + result = _map_parabolic_arc(file, design_parameters) + + case "CIRCULARARC": + result = _map_circular_arc(file, design_parameters) + + case "CLOTHOID": + result = _map_clothoid(file, design_parameters) + + case _: + raise TypeError("Unexpected predefined type") + + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/name_segments.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/name_segments.py new file mode 100644 index 0000000000..41d6ff968f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/name_segments.py @@ -0,0 +1,42 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +from ifcopenshell import entity_instance +from typing import Sequence + + +def name_segments(prefix: str, alignment: entity_instance) -> None: + """ + Sets the segment name like ("H1" for horizontal, "V1" for vertical, "C1" for cant) + + :param prefix: The naming prefix + :param alignment: The alignment whose segments are to be named. This should be a IfcAlignmentHorizontal, IfcAlignmentVertical or IfcAlignmentCant + """ + expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"] + if not alignment.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{v.is_a()}" + ) + + i = 1 + for rel in alignment.IsNestedBy: + for segment in rel.RelatedObjects: + if segment.is_a("IfcAlignmentSegment"): + segment.Name = f"{prefix}{i}" + i += 1 diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_last_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_last_segment.py new file mode 100644 index 0000000000..3f6d3d8c87 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_last_segment.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.geom +import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper +import numpy as np +from ifcopenshell import entity_instance +import ifcopenshell.util +import ifcopenshell.util.element + + +def remove_last_segment(file: ifcopenshell.file, entity: entity_instance) -> entity_instance: + """ + Removes the last segment from the end of entity. + + :param entity: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant or IfcCompositeCurve + :return: The segment + """ + expected_types = [ + "IfcAlignmentHorizontal", + "IfcAlignmentVertical", + "IfcAlignmentCant", + "IfcCompositeCurve", + "IfcGradientCurve", + "IfcSegmentedReferenceCurve", + ] + if not entity.is_a() in expected_types: + raise TypeError( + f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{entity.is_a()}" + ) + + if entity.is_a("IfcCompositeCurve"): + last_segment = entity.Segments[-1] + entity.Segments = tuple(set(entity.Segments) - {last_segment}) + entity.Segments[-1].Transition = "DISCONTINUOUS" + return last_segment + else: + components = ifcopenshell.util.element.get_components(entity) + last_segment = components[-1] + ifcopenshell.api.nest.unassign_object(file, (last_segment,)) + return last_segment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_zero_length_segment.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_zero_length_segment.py new file mode 100644 index 0000000000..00fa307e49 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/remove_zero_length_segment.py @@ -0,0 +1,35 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell import entity_instance +import ifcopenshell.api.alignment.remove_last_segment + + +def remove_zero_length_segment(file: ifcopenshell.file, entity: entity_instance) -> entity_instance: + """ + Removes the zero length segment from the end of entity. + + :param entity: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant or IfcCompositeCurve + :return: The zero length segment + """ + if not ifcopenshell.api.alignment.has_zero_length_segment(entity): + return None + + return ifcopenshell.api.alignment.remove_last_segment(file, entity) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_curve_segment_transition_code.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_curve_segment_transition_code.py new file mode 100644 index 0000000000..2eae483eb2 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_curve_segment_transition_code.py @@ -0,0 +1,77 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api +from ifcopenshell import ifcopenshell_wrapper +import ifcopenshell.geom +from ifcopenshell import entity_instance +from typing import Sequence +import numpy as np +import math + + +def update_curve_segment_transition_code(prev_segment: entity_instance, segment: entity_instance) -> None: + """ + Updates IfcCurveSegment.Transition of prev_segment based on a comparison of + the position, ref. direction, and curvature at the end of the prev_segment and the start of segment. + """ + expected_type = "IfcCurveSegment" + if not prev_segment.is_a(expected_type): + raise TypeError(f"Expected to see '{expected_type}', instead received '{prev_segment.is_a()}'.") + + if not segment.is_a(expected_type): + raise TypeError(f"Expected to see '{expected_type}', instead received '{segment.is_a()}'.") + + if len(prev_segment.UsingCurves) != 1: + raise TypeError("prev_segment must belong to exactly one curve") + + if len(segment.UsingCurves) != 1: + raise TypeError("segment must belong to exactly one curve") + + if prev_segment.UsingCurves[0] != segment.UsingCurves[0]: + raise TypeError("Both segments must belong to the same curve") + + settings = ifcopenshell.geom.settings() + settings.set("COMPUTE_CURVATURE", True) + + prev_segment_fn = ifcopenshell_wrapper.map_shape(settings, prev_segment.wrapped_data) + prev_segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, prev_segment_fn) + e = prev_segment_evaluator.evaluate(prev_segment_fn.end()) + end = np.array(e) + + # must add the new segment to the container before mapping it, otherwise the segment doesn't + # have enough context to know if it is for horizontal, vertical, cant + + segment_fn = ifcopenshell_wrapper.map_shape(settings, segment.wrapped_data) + segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) + s = segment_evaluator.evaluate(segment_fn.start()) + start = np.array(s) + + same_position = True if np.allclose(end[:3], start[:3]) else False + same_gradient = True if np.allclose(end[:0], start[:0]) else False + same_curvature = True if np.allclose(end[3:], start[3:]) else False + + if same_position: + prev_segment.Transition = "CONTINUOUS" + if same_gradient: + prev_segment.Transition = "CONTSAMEGRADIENT" + if same_curvature: + prev_segment.Transition = "CONTSAMEGRADIENTSAMECURVATURE" + else: + prev_segment.Transition = "DISCONTINUOUS" diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py new file mode 100644 index 0000000000..0a07c8a328 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py @@ -0,0 +1,134 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 ifcopenshell +import ifcopenshell.api.alignment +import math +from typing import Sequence + +import numpy as np + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.template +from ifcopenshell import entity_instance +from ifcopenshell import ifcopenshell_wrapper +import ifcopenshell.util +import ifcopenshell.util.stationing + + +def evaluate_representation(shape_rep: entity_instance, dist_along: float) -> np.ndarray: + """ + Calculate the 4x4 geometric transform at a point on an alignment segment + + :param shape_rep: The representation shape (composite curve, gradient curve, or segmented reference curve) to evaluate + :param dist_along: The distance along this representation at the point of interest (point to be calculated) + """ + supported_rep_types = ["IFCCOMPOSITECURVE", "IFCGRADIENTCURVE", "IFCSEGMENTEDREFERENCECURVE"] + shape_rep_type = shape_rep.is_a().upper() + if not shape_rep_type in supported_rep_types: + raise NotImplementedError( + f"Expected entity type to be one of {[_ for _ in supported_rep_types]}, got '{shape_rep_type}" + ) + + # TODO: confirm point is not beyond limits of alignment + + s = ifcopenshell.geom.settings() + function_item = ifcopenshell_wrapper.map_shape(s, shape_rep.wrapped_data) + evaluator = ifcopenshell_wrapper.function_item_evaluator(s, function_item) + + trans_matrix = evaluator.evaluate(dist_along) + + return np.array(trans_matrix, dtype=np.float64).T + + +def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray: + """ + Calculate the 4x4 geometric transform at a point on an alignment segment + + :param segment: The segment containing the point that we would like to + :param dist_along: The distance along this segment at the point of interest (point to be calculated) + """ + supported_segment_types = ["IFCCURVESEGMENT"] + segment_type = segment.is_a().upper() + if not segment_type in supported_segment_types: + raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}") + if dist_along > segment.SegmentLength: + raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).") + + s = ifcopenshell.geom.settings() + function_item = ifcopenshell_wrapper.map_shape(s, segment.wrapped_data) + evaluator = ifcopenshell_wrapper.function_item_evaluator(s, function_item) + + trans_matrix = evaluator.evaluate(dist_along) + + return np.array(trans_matrix, dtype=np.float64).T + + +def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0) -> np.ndarray: + """ + Generate vertices along an alignment + + :param rep_curve: The alignment's representation curve to use to generate vertices. + :param distance_interval: The distance between points along the alignment at which to generate the points + """ + if rep_curve is None: + raise ValueError("Alignment representation not found.") + + supported_rep_types = ["IFCCOMPOSITECURVE", "IFCGRADIENTCURVE", "IFCSEGMENTEDREFERENCECURVE"] + shape_rep_type = rep_curve.is_a().upper() + if not shape_rep_type in supported_rep_types: + raise NotImplementedError( + f"Expected entity type to be one of {[_ for _ in supported_rep_types]}, got '{shape_rep_type}" + ) + + s = ifcopenshell.geom.settings() + s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps + s.set("piecewise-step-size", distance_interval) + shape = ifcopenshell.geom.create_shape(s, rep_curve) + vertices = shape.verts + if len(vertices) == 0: + msg = f"[ERROR] No vertices generated by ifcopenshell.geom.create_shape()." + raise ValueError(msg) + return np.array(vertices).reshape((-1, 3)) + + +def print_alignment(alignment, indent=0): + """ + Debugging function to print alignment decomposition + """ + print(" " * indent, alignment) + + for rel in alignment.IsNestedBy: + for child in rel.RelatedObjects: + print_alignment(child, indent + 2) + + for agg in alignment.IsDecomposedBy: + for child in agg.RelatedObjects: + print_alignment(child, indent + 2) + + +def print_composite_curve(curve): + """ + Debugging function to print composite curve segments + """ + print(str(curve)[0:100]) + + for segment in curve.Segments: + print(" " * 2, segment) diff --git a/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py index 3cbab7f38d..ff0e2b11b7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py +++ b/src/ifcopenshell-python/ifcopenshell/api/attribute/edit_attributes.py @@ -39,11 +39,8 @@ def edit_attributes(file: ifcopenshell.file, product: ifcopenshell.entity_instan :param product: The product you want to edit. This may be any rooted IFC entity. - :type product: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py index d77776f04a..b9a2630442 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py @@ -17,17 +17,20 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.util.unit +import numpy as np +import numpy.typing as npt from typing import Optional +from ifcopenshell.util.shape_builder import SequenceOfVectors, V, ifc_safe_vector_type def assign_connection_geometry( file: ifcopenshell.file, rel_space_boundary: ifcopenshell.entity_instance, - outer_boundary: list[tuple[float, float]], + outer_boundary: SequenceOfVectors, location: tuple[float, float, float], axis: tuple[float, float, float], ref_direction: tuple[float, float, float], - inner_boundaries: Optional[list[list[tuple[float, float]]]] = None, + inner_boundaries: Optional[SequenceOfVectors] = None, unit_scale: Optional[float] = None, ) -> None: """Create and assign a connection geometry to a space boundary relationship @@ -40,35 +43,28 @@ def assign_connection_geometry( :param rel_space_boundary: The space boundary relationship to assign the connection geometry to. - :type rel_space_boundary: ifcopenshell.entity_instance :param outer_boundary: A list of 2D points representing an open polyline. The last point will connect to the first point. Each point is represented by an interable of 2 floats. The coordinates of the points are relative to the positional matrix arguments. - :type outer_boundary: list[tuple[float, float]] :param inner_boundaries: A list of zero or more inner boundaries to use for the plane. Each boundary is represented by an open polyline, as defined by the outer_boundary argument. - :type inner_boundaries: list[list[tuple[float, float]]], optional :param location: The local origin of the connection geometry, defined as an XYZ coordinate relative to the placement of the space that is being bounded. - :type location: tuple[float, float, float] :param axis: The local X axis of the connection geometry, defined as an XYZ vector relative to the placement of the space that is being bounded. - :type axis: tuple[float, float, float] :param ref_direction: The local Z axis of the connection geometry, defined as an XYZ vector relative to the placement of the space that is being bounded. The Y vector is automatically derived using the right hand rule. - :type ref_direction: tuple[float, float, float] :param unit_scale: The unit scale as calculated by ifcopenshell.util.unit.calculate_unit_scale. If not provided, it will be automatically calculated for you. :type unit_scale: float, optional :return: None - :rtype: None Example: @@ -83,19 +79,26 @@ def assign_connection_geometry( usecase = Usecase() usecase.file = file usecase.rel_space_boundary = rel_space_boundary - usecase.outer_boundary = outer_boundary - usecase.inner_boundaries = inner_boundaries or () - usecase.location = location - usecase.axis = axis - usecase.ref_direction = ref_direction - usecase.unit_scale = unit_scale + usecase.outer_boundary = V(outer_boundary) + usecase.inner_boundaries = V(inner_boundaries or []) + usecase.location = V(location) + usecase.axis = V(axis) + usecase.ref_direction = V(ref_direction) + usecase.unit_scale = unit_scale if unit_scale is not None else ifcopenshell.util.unit.calculate_unit_scale(file) return usecase.execute() class Usecase: + file: ifcopenshell.file + rel_space_boundary: ifcopenshell.entity_instance + outer_boundary: npt.NDArray + inner_boundaries: npt.NDArray + location: npt.NDArray + axis: npt.NDArray + ref_direction: npt.NDArray + unit_scale: float + def execute(self): - if self.unit_scale is None: - self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) outer_boundary = self.create_polyline(self.outer_boundary) inner_boundaries = tuple(self.create_polyline(boundary) for boundary in self.inner_boundaries) plane = self.create_plane(self.location, self.axis, self.ref_direction) @@ -103,18 +106,23 @@ class Usecase: connection_geometry = self.file.createIfcConnectionSurfaceGeometry(curve_bounded_plane) self.rel_space_boundary.ConnectionGeometry = connection_geometry - def create_point(self, point): - return self.file.createIfcCartesianPoint(point / self.unit_scale) + def create_point(self, point: npt.NDArray) -> ifcopenshell.entity_instance: + return self.file.create_enitty("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale)) - def close_polyline(self, points): + def close_polyline( + self, points: tuple[ifcopenshell.entity_instance, ...] + ) -> tuple[ifcopenshell.entity_instance, ...]: return points + (points[0],) - def create_polyline(self, points): - if points[0] == points[-1]: + def create_polyline(self, points: npt.NDArray) -> ifcopenshell.entity_instance: + if np.allclose(points[0], points[-1]): points = points[0 : len(points) - 1] - return self.file.createIfcPolyline(self.close_polyline(tuple(self.create_point(point) for point in points))) + ifc_points = tuple(self.create_point(point) for point in points) + return self.file.createIfcPolyline(self.close_polyline(ifc_points)) - def create_plane(self, location, axis, ref_direction): + def create_plane( + self, location: npt.NDArray, axis: npt.NDArray, ref_direction: npt.NDArray + ) -> ifcopenshell.entity_instance: return self.file.createIfcPlane( self.file.createIfcAxis2Placement3D( self.create_point(location), diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/copy_boundary.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/copy_boundary.py index af2bc72cb2..a2e74b5a11 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/copy_boundary.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/copy_boundary.py @@ -23,9 +23,7 @@ def copy_boundary(file: ifcopenshell.file, boundary: ifcopenshell.entity_instanc """Copies a space boundary :param boundary: The IfcRelSpaceBoundary you want to copy. - :type boundary: ifcopenshell.entity_instance :return: Duplicate of the IfcRelSpaceBoundary - :rtype: ifcopenshell.entity_instance Example: @@ -36,9 +34,7 @@ def copy_boundary(file: ifcopenshell.file, boundary: ifcopenshell.entity_instanc # And now we have two boundary_copy = ifcopenshell.api.boundary.copy_boundary(model, boundary=boundary) """ - settings = {"boundary": boundary} - - result = ifcopenshell.util.element.copy(file, settings["boundary"]) + result = ifcopenshell.util.element.copy(file, boundary) if result.ConnectionGeometry: result.ConnectionGeometry = ifcopenshell.util.element.copy_deep(file, result.ConnectionGeometry) return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/remove_boundary.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/remove_boundary.py index 13dfb7ab6b..4aadabcbce 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/remove_boundary.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/remove_boundary.py @@ -27,9 +27,7 @@ def remove_boundary(file: ifcopenshell.file, boundary: ifcopenshell.entity_insta boundary and its connection geometry is removed. :param boundary: The IfcRelSpaceBoundary you want to remove. - :type boundary: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -40,13 +38,11 @@ def remove_boundary(file: ifcopenshell.file, boundary: ifcopenshell.entity_insta # Let's remove it! ifcopenshell.api.boundary.remove_boundary(model, boundary=boundary) """ - settings = {"boundary": boundary} - - geometry = settings["boundary"].ConnectionGeometry + geometry = boundary.ConnectionGeometry if geometry: - settings["boundary"].ConnectionGeometry = None + boundary.ConnectionGeometry = None ifcopenshell.util.element.remove_deep2(file, geometry) - history = settings["boundary"].OwnerHistory - file.remove(settings["boundary"]) + history = boundary.OwnerHistory + file.remove(boundary) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/add_classification.py b/src/ifcopenshell-python/ifcopenshell/api/classification/add_classification.py index af886a0014..2932a5bb93 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/add_classification.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/add_classification.py @@ -20,7 +20,7 @@ import ifcopenshell import ifcopenshell.guid import ifcopenshell.util.schema import ifcopenshell.util.date -from typing import Union +from typing import Union, Any def add_classification( @@ -61,9 +61,7 @@ def add_classification( classification library. The latter approach is preferred if you are using a commonly known system such as Uniclass, as this will ensure all metadata is added correctly. - :type classification: str,ifcopenshell.entity_instance :return: The added IfcClassification element - :rtype: ifcopenshell.entity_instance Example: @@ -81,28 +79,28 @@ def add_classification( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "classification": classification, - } - return usecase.execute() + return usecase.execute(classification) class Usecase: - def execute(self): - if isinstance(self.settings["classification"], str): - classification = self.file.createIfcClassification(Name=self.settings["classification"]) + file: ifcopenshell.file + + def execute(self, classification: Union[str, ifcopenshell.entity_instance]) -> ifcopenshell.entity_instance: + self.classification = classification + if isinstance(self.classification, str): + classification = self.file.create_entity("IfcClassification", Name=self.classification) self.relate_to_project(classification) return classification return self.add_from_library() - def add_from_library(self): + def add_from_library(self) -> ifcopenshell.entity_instance: edition_date = None - if self.settings["classification"].EditionDate: - edition_date = ifcopenshell.util.date.ifc2datetime(self.settings["classification"].EditionDate) - self.settings["classification"].EditionDate = None + if self.classification.EditionDate: + edition_date = ifcopenshell.util.date.ifc2datetime(self.classification.EditionDate) + self.classification.EditionDate = None migrator = ifcopenshell.util.schema.Migrator() - result = migrator.migrate(self.settings["classification"], self.file) + result = migrator.migrate(self.classification, self.file) # TODO: should auto date migration be part of the migrator? if self.file.schema == "IFC2X3" and edition_date: @@ -118,7 +116,7 @@ class Usecase: return result - def relate_to_project(self, classification): + def relate_to_project(self, classification: ifcopenshell.entity_instance) -> None: self.file.create_entity( "IfcRelAssociatesClassification", GlobalId=ifcopenshell.guid.new(), diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py index 1e0ae329bf..817d15b5cf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/add_reference.py @@ -21,7 +21,7 @@ import ifcopenshell.api.owner import ifcopenshell.guid import ifcopenshell.util.element import ifcopenshell.util.schema -from typing import Optional, Union +from typing import Optional, Union, Any def add_reference( @@ -65,23 +65,18 @@ def add_reference( :param product: The list of IFC objects, properties, or resources you want to associate the classification reference to. - :type product: list[ifcopenshell.entity_instance] :param reference: The classification reference entity taken from an IFC classification library. If you supply this parameter, you will use option 2. - :type reference: ifcopenshell.entity_instance, optional :param identification: If you choose option 1 and do not specify a reference, you may manually specify an identification code. The code is typically a short identifier and may have punctuation to separate the levels of hierarchy in the classificaion (e.g. Pr_12_23_34). - :type identification: str, optional :param name: If you choose option 1 and do not specify a reference, you may manually specify a name. The name is typically human readable. - :type name: str, optional :param classification: The IfcClassification entity in your IFC model (not the library, if you are doing option 2) that the reference is part of. - :type classification: ifcopenshell.entity_instance :param is_lightweight: If you are doing option 2, choose whether or not to only add that particular reference (lighweight) or also add all of its parent references in the classification hierarchy (not @@ -91,13 +86,11 @@ def add_reference( references merely help describe the "tree" of classifications, but is generally unnecessary. Using lightweight classifications are recommended and is the default. - :type is_lightweight: bool, optional :raises TypeError: If file is IFC2X3 and `products` has non-IfcRoot elements. :return: The newly added IfcClassificationReference or `None` if `products` was empty list. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -136,6 +129,9 @@ def add_reference( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): if not self.settings["products"]: return diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/edit_classification.py b/src/ifcopenshell-python/ifcopenshell/api/classification/edit_classification.py index d6c8ef8bc4..b2091c0eca 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/edit_classification.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/edit_classification.py @@ -28,11 +28,8 @@ def edit_classification( IfcClassification, consult the IFC documentation. :param classification: The IfcClassification entity you want to edit - :type classification: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -43,7 +40,5 @@ def edit_classification( ifcopenshell.api.classification.edit_classification(model, classification=classification, attributes={"Name": "Foo"}) """ - settings = {"classification": classification, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["classification"], name, value) + for name, value in attributes.items(): + setattr(classification, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/edit_reference.py b/src/ifcopenshell-python/ifcopenshell/api/classification/edit_reference.py index 80d978e7d3..f5a85d40f9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/edit_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/edit_reference.py @@ -28,11 +28,8 @@ def edit_reference( IfcClassificationReference, consult the IFC documentation. :param reference: The IfcClassificationReference entity you want to edit - :type reference: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -43,7 +40,5 @@ def edit_reference( ifcopenshell.api.classification.edit_reference(model, reference=reference, attributes={"Name": "Foo"}) """ - settings = {"reference": reference, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["reference"], name, value) + for name, value in attributes.items(): + setattr(reference, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/remove_classification.py b/src/ifcopenshell-python/ifcopenshell/api/classification/remove_classification.py index 90e6abbbe7..38382eda91 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/remove_classification.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/remove_classification.py @@ -28,9 +28,7 @@ def remove_classification(file: ifcopenshell.file, classification: ifcopenshell. removed from a project. :param classification: The IfcClassification entity you want to remove - :type classification: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -42,16 +40,17 @@ def remove_classification(file: ifcopenshell.file, classification: ifcopenshell. """ usecase = Usecase() usecase.file = file - usecase.settings = {"classification": classification} - return usecase.execute() + return usecase.execute(classification) class Usecase: - def execute(self): - references = self.get_references(self.settings["classification"]) + file: ifcopenshell.file + + def execute(self, classification: ifcopenshell.entity_instance) -> None: + references = self.get_references(classification) for reference in references: self.file.remove(reference) - self.file.remove(self.settings["classification"]) + self.file.remove(classification) for rel in self.file.by_type("IfcRelAssociatesClassification"): if not rel.RelatingClassification: history = rel.OwnerHistory diff --git a/src/ifcopenshell-python/ifcopenshell/api/classification/remove_reference.py b/src/ifcopenshell-python/ifcopenshell/api/classification/remove_reference.py index 0bdd347bfa..55dfd11b08 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/classification/remove_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/classification/remove_reference.py @@ -33,15 +33,12 @@ def remove_reference( :param reference: The IfcClassificationReference entity of the relationship you want to remove. - :type reference: ifcopenshell.entity_instance :param product: The list fo object entities of the relationship you want to remove. - :type product: list[ifcopenshell.entity_instance] :raises TypeError: If file is IFC2X3 and `products` has non-IfcRoot elements. :return: None - :rtype: None Example: @@ -56,20 +53,18 @@ def remove_reference( ifcopenshell.api.classification.remove_reference(model, reference=reference, products=[wall_type]) """ - settings = {"reference": reference, "products": products} - is_ifc2x3 = file.schema == "IFC2X3" - products = set(settings["products"]) - referenced = ifcopenshell.util.element.get_referenced_elements(settings["reference"]) - products -= products.difference(referenced) + products_set = set(products) + referenced = ifcopenshell.util.element.get_referenced_elements(reference) + products_set -= products_set.difference(referenced) # all products are already unassigned from a reference - if not products: + if not products_set: return rooted_products: set[ifcopenshell.entity_instance] = set() non_rooted_products: set[ifcopenshell.entity_instance] = set() - for product in settings["products"]: + for product in products: if product.is_a("IfcRoot"): rooted_products.add(product) else: @@ -86,7 +81,7 @@ def remove_reference( reference_rels = { rel for rel in reference_rels - if rel.is_a("IfcRelAssociatesClassification") and rel.RelatingClassification == settings["reference"] + if rel.is_a("IfcRelAssociatesClassification") and rel.RelatingClassification == reference } for rel in reference_rels: @@ -108,7 +103,7 @@ def remove_reference( rels = getattr(product, "HasExternalReference", []) reference_rels.update(rels) - reference_rels = {rel for rel in reference_rels if rel.RelatingReference == settings["reference"]} + reference_rels = {rel for rel in reference_rels if rel.RelatingReference == reference} for rel in reference_rels: related_objects = set(rel.RelatedResourceObjects) - non_rooted_products if related_objects: @@ -117,6 +112,6 @@ def remove_reference( file.remove(rel) # TODO: we only handle lightweight classifications here - referenced_elements = ifcopenshell.util.element.get_referenced_elements(settings["reference"]) + referenced_elements = ifcopenshell.util.element.get_referenced_elements(reference) if not referenced_elements: - file.remove(settings["reference"]) + file.remove(reference) diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric.py index f1fcf20a0e..001c86a540 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric.py @@ -28,9 +28,7 @@ def add_metric(file: ifcopenshell.file, objective: ifcopenshell.entity_instance) to meet the objective of the constraint. :param objective: The IfcObjective that this metric is a benchmark of. - :type objective: ifcopenshell.entity_instance :return: The newly created IfcMetric entity - :rtype: ifcopenshell.entity_instance Example: @@ -40,10 +38,6 @@ def add_metric(file: ifcopenshell.file, objective: ifcopenshell.entity_instance) metric = ifcopenshell.api.constraint.add_metric(model, objective=objective) """ - settings = { - "objective": objective, - } - metric = file.create_entity( "IfcMetric", **{ @@ -52,8 +46,9 @@ def add_metric(file: ifcopenshell.file, objective: ifcopenshell.entity_instance) "Benchmark": "EQUALTO", }, ) - if settings["objective"]: - benchmark_values = list(settings["objective"].BenchmarkValues or []) + if objective: + benchmark_values: list[ifcopenshell.entity_instance] + benchmark_values = list(objective.BenchmarkValues or []) benchmark_values.append(metric) - settings["objective"].BenchmarkValues = benchmark_values + objective.BenchmarkValues = benchmark_values return metric diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric_reference.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric_reference.py index fdcdf50f6f..63ecfeb3c0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_metric_reference.py @@ -26,16 +26,14 @@ def add_metric_reference( Adds a chain of references to a metric. The reference path is a string of the form "attribute.attribute.attribute" Used to reference a value of an attribute of an instance through a metric objective entity. """ - settings = {"metric": metric, "reference_path": reference_path} - references_created = [] - if settings["reference_path"]: - attributes = settings["reference_path"].split(".") + if reference_path: + attributes = reference_path.split(".") for i in range(len(attributes)): if i == 0: reference = file.create_entity("IfcReference") reference.AttributeIdentifier = attributes[i] - settings["metric"].ReferencePath = reference + metric.ReferencePath = reference references_created.append(reference) else: reference = file.create_entity("IfcReference") diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_objective.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_objective.py index 064648f1b7..4c434a9edf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/add_objective.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/add_objective.py @@ -29,7 +29,6 @@ def add_objective(file: ifcopenshell.file) -> ifcopenshell.entity_instance: quantities. See ifcopenshell.api.constraint.add_metric for more information. :return: The newly created IfcObjective entity - :rtype: ifcopenshell.entity_instance Example: @@ -42,8 +41,6 @@ def add_objective(file: ifcopenshell.file) -> ifcopenshell.entity_instance: # Note: the objective right now is purely qualitative and for # information purposes. You may wish to add quantiative metrics. """ - settings = {} - return file.create_entity( "IfcObjective", **{"Name": "Unnamed", "ConstraintGrade": "NOTDEFINED", "ObjectiveQualifier": "NOTDEFINED"} ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/assign_constraint.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/assign_constraint.py index 74bf3d9a04..f4daad0abb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/assign_constraint.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/assign_constraint.py @@ -39,36 +39,29 @@ def assign_constraint( :param products: The list of products the constraint applies to. This is anything which can have properties or quantities. - :type products: list[ifcopenshell.entity_instance] :param constraint: The IfcObjective constraint - :type constraint: ifcopenshell.entity_instance :return: The new or updated IfcRelAssociatesConstraint relationship or `None` if `products` was an empty list. - :rtype: ifcopenshell.entity_instance """ usecase = Usecase() usecase.file = file - usecase.settings = { - "products": products, - "constraint": constraint, - } - return usecase.execute() + return usecase.execute(products, constraint) class Usecase: - def execute(self): - products = set(self.settings["products"]) + file: ifcopenshell.file + + def execute(self, products: list[ifcopenshell.entity_instance], constraint: ifcopenshell.entity_instance): if not products: return + products_set = set(products) - self.constraint = self.settings["constraint"] - - rels = self.get_constraint_rels() + rels = self.get_constraint_rels(constraint) related_objects = set() for rel in rels: related_objects.update(rel.RelatedObjects) - products_to_assign = products - related_objects + products_to_assign = products_set - related_objects if not products_to_assign: return rels[0] @@ -85,14 +78,14 @@ class Usecase: **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(self.file), - "RelatingConstraint": self.constraint, + "RelatingConstraint": constraint, "RelatedObjects": list(products_to_assign), } ) - def get_constraint_rels(self) -> list[ifcopenshell.entity_instance]: + def get_constraint_rels(self, constraint: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: rels = [] - for rel in self.file.get_inverse(self.constraint): + for rel in self.file.get_inverse(constraint): if rel.is_a("IfcRelAssociatesConstraint"): rels.append(rel) return rels diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_metric.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_metric.py index 89a0cbef7d..3967846759 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_metric.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_metric.py @@ -26,11 +26,8 @@ def edit_metric(file: ifcopenshell.file, metric: ifcopenshell.entity_instance, a IfcMetric, consult the IFC documentation. :param metric: The IfcMetric you want to edit. - :type metric: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_metric(file: ifcopenshell.file, metric: ifcopenshell.entity_instance, a ifcopenshell.api.constraint.edit_metric(model, metric=metric, attributes={"ConstraintGrade": "HARD"}) """ - settings = {"metric": metric, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["metric"], name, value) + for name, value in attributes.items(): + setattr(metric, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_objective.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_objective.py index ce4e76ba9e..3cd75356a7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_objective.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/edit_objective.py @@ -28,11 +28,8 @@ def edit_objective( IfcObjective, consult the IFC documentation. :param objective: The IfcObjective you want to edit. - :type objective: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_objective( ifcopenshell.api.constraint.edit_objective(model, objective=objective, attributes={"ConstraintGrade": "HARD"}) """ - settings = {"objective": objective, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["objective"], name, value) + for name, value in attributes.items(): + setattr(objective, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_constraint.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_constraint.py index 381a9e191e..c98f08ade7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_constraint.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_constraint.py @@ -41,9 +41,7 @@ def remove_constraint(file: ifcopenshell.file, constraint: ifcopenshell.entity_i ifcopenshell.api.constraint.remove_constraint(model, constraint=objective) """ - settings = {"constraint": constraint} - - file.remove(settings["constraint"]) + file.remove(constraint) for rel in file.by_type("IfcRelAssociatesConstraint"): if not rel.RelatingConstraint: history = rel.OwnerHistory diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_metric.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_metric.py index f700a2c150..76668f714d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_metric.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/remove_metric.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.util.element def remove_metric(file: ifcopenshell.file, metric: ifcopenshell.entity_instance) -> None: @@ -41,17 +42,18 @@ def remove_metric(file: ifcopenshell.file, metric: ifcopenshell.entity_instance) """ usecase = Usecase() usecase.file = file - usecase.settings = {"metric": metric} - return usecase.execute() + return usecase.execute(metric) class Usecase: - def execute(self): - if self.settings["metric"].ReferencePath: - reference = self.settings["metric"].ReferencePath + file: ifcopenshell.file + + def execute(self, metric: ifcopenshell.entity_instance) -> None: + if metric.ReferencePath: + reference = metric.ReferencePath self.delete_reference(reference) - self.file.remove(self.settings["metric"]) + self.file.remove(metric) for rel in self.file.by_type("IfcRelAssociatesConstraint"): if not rel.RelatingConstraint: history = rel.OwnerHistory @@ -62,7 +64,7 @@ class Usecase: if not resource_rel.RelatingConstraint: self.file.remove(resource_rel) - def delete_reference(self, reference): + def delete_reference(self, reference: ifcopenshell.entity_instance) -> None: if reference.InnerReference: self.delete_reference(reference.InnerReference) self.file.remove(reference) diff --git a/src/ifcopenshell-python/ifcopenshell/api/constraint/unassign_constraint.py b/src/ifcopenshell-python/ifcopenshell/api/constraint/unassign_constraint.py index cff9716809..d9e84cc826 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/constraint/unassign_constraint.py +++ b/src/ifcopenshell-python/ifcopenshell/api/constraint/unassign_constraint.py @@ -32,41 +32,35 @@ def unassign_constraint( other products. :param products: The list of products the constraint applies to. - :type products: list[ifcopenshell.entity_instance] :param constraint: The IfcObjective constraint - :type constraint: ifcopenshell.entity_instance :return: None - :rtype: None """ usecase = Usecase() usecase.file = file - usecase.settings = { - "products": products, - "constraint": constraint, - } - return usecase.execute() + return usecase.execute(products, constraint) class Usecase: - def execute(self): - products = set(self.settings["products"]) - if not products: - return + file: ifcopenshell.file - self.constraint = self.settings["constraint"] - rels = self.get_constraint_rels() + def execute(self, products_: list[ifcopenshell.entity_instance], constraint: ifcopenshell.entity_instance): + if not products_: + return + products_set = set(products_) + + rels = self.get_constraint_rels(constraint) related_objects = set() for rel in rels: related_objects.update(rel.RelatedObjects) - if not related_objects.intersection(products): + if not related_objects.intersection(products_set): return for rel in rels: related_objects = set(rel.RelatedObjects) - if not related_objects.intersection(products): + if not related_objects.intersection(products_set): continue - related_objects -= products + related_objects -= products_set if related_objects: rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(self.file, **{"element": rel}) @@ -77,9 +71,9 @@ class Usecase: if history: ifcopenshell.util.element.remove_deep2(self.file, history) - def get_constraint_rels(self) -> list[ifcopenshell.entity_instance]: + def get_constraint_rels(self, cosntraint: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: rels = [] - for rel in self.file.get_inverse(self.constraint): + for rel in self.file.get_inverse(cosntraint): if rel.is_a("IfcRelAssociatesConstraint"): rels.append(rel) return rels diff --git a/src/ifcopenshell-python/ifcopenshell/api/context/edit_context.py b/src/ifcopenshell-python/ifcopenshell/api/context/edit_context.py index ba52e83a9e..1a7d567c58 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/context/edit_context.py +++ b/src/ifcopenshell-python/ifcopenshell/api/context/edit_context.py @@ -27,11 +27,8 @@ def edit_context(file: ifcopenshell.file, context: ifcopenshell.entity_instance, IfcGeometricRepresentationContext, consult the IFC documentation. :param context: The IfcGeometricRepresentationContext entity you want to edit - :type context: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -47,7 +44,5 @@ def edit_context(file: ifcopenshell.file, context: ifcopenshell.entity_instance, ifcopenshell.api.context.edit_context(model, context=body, attributes={"ContextIdentifier": "Body"}) """ - settings = {"context": context, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["context"], name, value) + for name, value in attributes.items(): + setattr(context, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py index 2d6defeaf3..7fee232456 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py +++ b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py @@ -29,9 +29,7 @@ def remove_context(file: ifcopenshell.file, context: ifcopenshell.entity_instanc removed. If a context is removed, then any subcontexts are also removed. :param context: The IfcGeometricRepresentationContext entity to remove - :type context: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -46,22 +44,20 @@ def remove_context(file: ifcopenshell.file, context: ifcopenshell.entity_instanc # Let's just get rid of it completely ifcopenshell.api.context.remove_context(model, context=body) """ - settings = {"context": context} - - for subcontext in settings["context"].HasSubContexts: + for subcontext in context.HasSubContexts: ifcopenshell.api.context.remove_context(file, context=subcontext) - if getattr(settings["context"], "ParentContext", None): - new = settings["context"].ParentContext - for inverse in file.get_inverse(settings["context"]): + if getattr(context, "ParentContext", None): + new = context.ParentContext + for inverse in file.get_inverse(context): if inverse.is_a("IfcCoordinateOperation"): inverse.SourceCRS = inverse.TargetCRS ifcopenshell.util.element.remove_deep(file, inverse) else: - ifcopenshell.util.element.replace_attribute(inverse, settings["context"], new) - file.remove(settings["context"]) + ifcopenshell.util.element.replace_attribute(inverse, context, new) + file.remove(context) else: - representations_in_context = settings["context"].RepresentationsInContext - file.remove(settings["context"]) + representations_in_context = context.RepresentationsInContext + file.remove(context) for element in representations_in_context: ifcopenshell.api.geometry.remove_representation(file, representation=element) diff --git a/src/ifcopenshell-python/ifcopenshell/api/control/assign_control.py b/src/ifcopenshell-python/ifcopenshell/api/control/assign_control.py index 303cfa6726..2a3ad405f2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/control/assign_control.py +++ b/src/ifcopenshell-python/ifcopenshell/api/control/assign_control.py @@ -42,12 +42,9 @@ def assign_control( :param relating_control: The IfcControl entity that is creating the control or constraint - :type relating_control: ifcopenshell.entity_instance :param related_object: The IfcObjectDefinition that is being controlled - :type related_object: ifcopenshell.entity_instance :return: The newly created IfcRelAssignsToControl. If relationship already existed before and wasn't changed then returns None. - :rtype: ifcopenshell.entity_instance, None Example: @@ -72,25 +69,20 @@ def assign_control( ifcopenshell.api.control.assign_control(model, relating_control=cost_item, related_object=wall) """ - settings = { - "relating_control": relating_control, - "related_object": related_object, - } - - if settings["related_object"].HasAssignments: - for assignment in settings["related_object"].HasAssignments: - if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl == settings["relating_control"]: + if related_object.HasAssignments: + for assignment in related_object.HasAssignments: + if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl == relating_control: return controls = None - if settings["relating_control"].Controls: - controls = settings["relating_control"].Controls[0] + if relating_control.Controls: + controls = relating_control.Controls[0] if controls: - if settings["related_object"] in controls.RelatedObjects: + if related_object in controls.RelatedObjects: return related_objects = set(controls.RelatedObjects) - related_objects.add(settings["related_object"]) + related_objects.add(related_object) controls.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, **{"element": controls}) else: @@ -99,8 +91,8 @@ def assign_control( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingControl": settings["relating_control"], + "RelatedObjects": [related_object], + "RelatingControl": relating_control, }, ) return controls diff --git a/src/ifcopenshell-python/ifcopenshell/api/control/unassign_control.py b/src/ifcopenshell-python/ifcopenshell/api/control/unassign_control.py index 77530316d8..44cc4fad6e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/control/unassign_control.py +++ b/src/ifcopenshell-python/ifcopenshell/api/control/unassign_control.py @@ -31,12 +31,9 @@ def unassign_control( :param relating_control: The IfcControl entity that is creating the control or constraint - :type relating_control: ifcopenshell.entity_instance :param related_object: The IfcObjectDefinition that is being controlled - :type related_object: ifcopenshell.entity_instance :return: If the control still is related to other objects, the IfcRelAssignsToControl is returned, otherwise None. - :rtype: ifcopenshell.entity_instance, None Example: @@ -54,14 +51,8 @@ def unassign_control( ifcopenshell.api.control.unassign_control(model, relating_control=cost_item, related_object=wall) """ - - settings = { - "relating_control": relating_control, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToControl") or rel.RelatingControl != settings["relating_control"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToControl") or rel.RelatingControl != relating_control: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -70,7 +61,7 @@ def unassign_control( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item_quantity.py index c2a020cac8..58f3ed343a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_item_quantity.py @@ -54,12 +54,9 @@ def add_cost_item_quantity( using another API call. :param cost_item: The IfcCostItem to add the quantity to - :type cost_item: ifcopenshell.entity_instance :param ifc_class: The type of quantity to add - :type ifc_class: str, optional :return: The newly created quantity entity, chosen from the ifc_class parameter - :rtype: ifcopenshell.entity_instance Example: @@ -76,20 +73,18 @@ def add_cost_item_quantity( ifcopenshell.api.cost.add_cost_item_quantity(model, cost_item=item, ifc_class="IfcQuantityCount") """ - settings = {"cost_item": cost_item, "ifc_class": ifc_class} - - quantity = file.create_entity(settings["ifc_class"], Name="Unnamed") + quantity = file.create_entity(ifc_class, Name="Unnamed") # 3 IfcPhysicalSimpleQuantity Value # This is a bold assumption # https://forums.buildingsmart.org/t/how-does-a-cost-item-know-that-it-is-counting-a-controlled-product/3564 - if settings["ifc_class"] == "IfcQuantityCount": + if ifc_class == "IfcQuantityCount": count = 0 - for rel in settings["cost_item"].Controls: + for rel in cost_item.Controls: count += len(rel.RelatedObjects) quantity[3] = count else: quantity[3] = 0.0 - quantities = list(settings["cost_item"].CostQuantities or []) + quantities = list(cost_item.CostQuantities or []) quantities.append(quantity) - settings["cost_item"].CostQuantities = quantities + cost_item.CostQuantities = quantities return quantity diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_schedule.py index 2231c8c07f..d85534bb83 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_schedule.py @@ -39,13 +39,10 @@ def add_cost_schedule( managing any cost items. :param name: The name of the cost schedule. - :type name: str, optional :param predefined_type: The predefined type of the cost schedule, chosen from a valid type in the IFC documentation for IfcCostScheduleTypeEnum - :type predefined_type: str, optional :return: The newly created IfcCostSchedule entity - :rtype: ifcopenshell.entity_instance Example: @@ -55,13 +52,11 @@ def add_cost_schedule( # Now that we have a cost schedule, we may add cost items to it item = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=schedule) """ - settings = {"name": name, "predefined_type": predefined_type} - cost_schedule = ifcopenshell.api.root.create_entity( file, ifc_class="IfcCostSchedule", - predefined_type=settings["predefined_type"], - name=settings["name"], + predefined_type=predefined_type, + name=name, ) if file.schema == "IFC2X3": cost_schedule.UpdateDate = createIfcDateAndTime(file, datetime.now()) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_value.py index ebee87ee44..972849ca96 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/add_cost_value.py @@ -46,9 +46,7 @@ def add_cost_value(file: ifcopenshell.file, parent: ifcopenshell.entity_instance :param parent: A parent IfcCostItem, if specifying a price directly to a cost item, or a top-level price component. Alternatively, this can be set to a IfcCostValue, if specifying price subcomponents. - :type parent: ifcopenshell.entity_instance :return: The newly created IfcCostValue - :rtype: ifcopenshell.entity_instance Example: @@ -91,19 +89,17 @@ def add_cost_value(file: ifcopenshell.file, parent: ifcopenshell.entity_instance ifcopenshell.api.cost.edit_cost_value(model, cost_value=subvalue2, attributes={"AppliedValue": 3.0}) """ - settings = {"parent": parent} - value = file.create_entity("IfcCostValue") - if settings["parent"].is_a("IfcCostItem"): - values = list(settings["parent"].CostValues or []) + if parent.is_a("IfcCostItem"): + values = list(parent.CostValues or []) values.append(value) - settings["parent"].CostValues = values - elif settings["parent"].is_a("IfcConstructionResource"): - values = list(settings["parent"].BaseCosts or []) + parent.CostValues = values + elif parent.is_a("IfcConstructionResource"): + values = list(parent.BaseCosts or []) values.append(value) - settings["parent"].BaseCosts = values - elif settings["parent"].is_a("IfcCostValue"): - values = list(settings["parent"].Components or []) + parent.BaseCosts = values + elif parent.is_a("IfcCostValue"): + values = list(parent.Components or []) values.append(value) - settings["parent"].Components = values + parent.Components = values return value diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_value.py index 2eeb02f89d..d012c02c5b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_value.py @@ -36,11 +36,8 @@ def assign_cost_value( rates as a "template" to quickly populate your rates from. :param cost_item: The IfcCostItem that you want to copy the values to - :type cost_item: ifcopenshell.entity_instance :param cost_rate: The IfcCostItem that you want to copy the values from - :type cost_rate: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -61,16 +58,14 @@ def assign_cost_value( # Now the cost item has the same rate as the one from the schedule of rate's item ifcopenshell.api.cost.assign_cost_value(model, cost_item=item, cost_rate=rate) """ - settings = {"cost_item": cost_item, "cost_rate": cost_rate} - - if settings["cost_item"].CostValues: + if cost_item.CostValues: [ ifcopenshell.api.cost.remove_cost_value( file, - parent=settings["cost_item"], + parent=cost_item, cost_value=cost_value, ) - for cost_value in settings["cost_item"].CostValues + for cost_value in cost_item.CostValues ] # This is an assumption, and not part of the official IFC documentation - settings["cost_item"].CostValues = settings["cost_rate"].CostValues + cost_item.CostValues = cost_rate.CostValues diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py index e1eb12db85..86e149ebd3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py @@ -83,13 +83,11 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop # (42 * 200) + 50000 = 58400 is our calculated cost ifcopenshell.api.cost.calculate_cost_item_resource_value(model, cost_item=item) """ - settings = {"cost_item": cost_item} - - for cost_value in settings["cost_item"].CostValues or []: - ifcopenshell.api.cost.remove_cost_value(file, parent=settings["cost_item"], cost_value=cost_value) + for cost_value in cost_item.CostValues or []: + ifcopenshell.api.cost.remove_cost_value(file, parent=cost_item, cost_value=cost_value) resources = [] - for rel in settings["cost_item"].Controls or []: + for rel in cost_item.Controls or []: for related_object in rel.RelatedObjects: if related_object.is_a("IfcConstructionResource"): resources.append(related_object) @@ -112,6 +110,6 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop if unit and "day" in unit: quantity = quantity / 8 # Assume 8 hour working day - TODO implement resource calendar formula = "{}*{}".format(cost, quantity) - cost_value = ifcopenshell.api.cost.add_cost_value(file, parent=settings["cost_item"]) + cost_value = ifcopenshell.api.cost.add_cost_value(file, parent=cost_item) cost_value.Name = resource.Name ifcopenshell.api.cost.edit_cost_value_formula(file, cost_value=cost_value, formula=formula) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py index ac6b563960..fcf298ff7c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item.py @@ -25,8 +25,6 @@ from typing import Union def copy_cost_item( file: ifcopenshell.file, cost_item: ifcopenshell.entity_instance ) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: - # TODO: currently it never returns list of duplicated cost items - # though it is stated in the docs """Copies all cost items and related relationships The following relationships are also duplicated: @@ -36,9 +34,7 @@ def copy_cost_item( * The copy will have duplicated nested cost items :param cost_item: The cost item to be duplicated - :type cost_item: ifcopenshell.entity_instance :return: The duplicated cost item or the list of duplicated cost items if the latter has children - :rtype: ifcopenshell.entity_instance or list[ifcopenshell.entity_instance] Example: .. code:: python @@ -53,22 +49,29 @@ def copy_cost_item( """ usecase = Usecase() usecase.file = file - usecase.settings = {"cost_item": cost_item} - return usecase.execute() + return usecase.execute(cost_item) class Usecase: - def execute(self): - self.new_cost_items = [] - return self.duplicate_cost_item(self.settings["cost_item"]) + file: ifcopenshell.file + new_cost_items: list[ifcopenshell.entity_instance] - def duplicate_cost_item(self, cost_item): + def execute( + self, cost_item: ifcopenshell.entity_instance + ) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: + self.new_cost_items = [] + self.duplicate_cost_item(cost_item) + return self.new_cost_items[0] if len(self.new_cost_items) == 1 else self.new_cost_items + + def duplicate_cost_item(self, cost_item: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: new_cost_item = ifcopenshell.util.element.copy_deep(self.file, cost_item) self.new_cost_items.append(new_cost_item) self.copy_indirect_attributes(cost_item, new_cost_item) return new_cost_item - def copy_indirect_attributes(self, from_element, to_element): + def copy_indirect_attributes( + self, from_element: ifcopenshell.entity_instance, to_element: ifcopenshell.entity_instance + ) -> None: for inverse in self.file.get_inverse(from_element): if inverse.is_a("IfcRelDefinesByProperties"): inverse = ifcopenshell.util.element.copy(self.file, inverse) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item_values.py b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item_values.py index 6903f41bfe..e62b105c50 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item_values.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/copy_cost_item_values.py @@ -30,11 +30,8 @@ def copy_cost_item_values( parametrically linked, so if one value changes, the other will not. :param source: The IfcCostItem to copy cost values from - :type source: ifcopenshell.entity_instance :param destination: The IfcCostItem to copy cost values from - :type destination: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -53,11 +50,9 @@ def copy_cost_item_values( # Let's copy the value from one item to another ifcopenshell.api.cost.copy_cost_item_values(model, source=item1, destination=item2) """ - settings = {"source": source, "destination": destination} - - for cost_value in settings["destination"].CostValues or []: + for cost_value in destination.CostValues or []: ifcopenshell.api.cost.remove_cost_item_value(file, cost_value=cost_value) copied_cost_values = [] - for cost_value in settings["source"].CostValues or []: + for cost_value in source.CostValues or []: copied_cost_values.append(ifcopenshell.util.element.copy_deep(file, cost_value)) - settings["destination"].CostValues = copied_cost_values + destination.CostValues = copied_cost_values diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item.py index 2417c9ac65..813e306bc8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item.py @@ -28,11 +28,8 @@ def edit_cost_item( IfcCostItem, consult the IFC documentation. :param cost_item: The IfcCostItem entity you want to edit - :type cost_item: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_cost_item( item = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=schedule) ifcopenshell.api.cost.edit_cost_item(model, cost_item=item, attributes={"Name": "Foo"}) """ - settings = {"cost_item": cost_item, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["cost_item"], name, value) + for name, value in attributes.items(): + setattr(cost_item, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item_quantity.py index cb265318ac..697d028a37 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_item_quantity.py @@ -28,11 +28,8 @@ def edit_cost_item_quantity( IfcPhysicalQuantity, consult the IFC documentation. :param physical_quantity: The IfcPhysicalQuantity entity you want to edit - :type physical_quantity: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -50,7 +47,5 @@ def edit_cost_item_quantity( ifcopenshell.api.cost.edit_cost_item_quantity(model, physical_quantity=quantity, "attributes": {"VolumeValue": 3.0}) """ - settings = {"physical_quantity": physical_quantity, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["physical_quantity"], name, value) + for name, value in attributes.items(): + setattr(physical_quantity, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_schedule.py index 1c6f42b123..d55e93330e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_schedule.py @@ -28,11 +28,8 @@ def edit_cost_schedule( IfcCostSchedule, consult the IFC documentation. :param cost_schedule: The IfcCostSchedule entity you want to edit - :type cost_schedule: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,8 +39,5 @@ def edit_cost_schedule( ifcopenshell.api.cost.edit_cost_schedule(model, cost_schedule=schedule, attributes={"Name": "Foo"}) """ - - settings = {"cost_schedule": cost_schedule, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["cost_schedule"], name, value) + for name, value in attributes.items(): + setattr(cost_schedule, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py index f3e4e01acd..ebc09ed34c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py @@ -31,11 +31,8 @@ def edit_cost_value( IfcCostValue, consult the IFC documentation. :param cost_value: The IfcCostValue entity you want to edit - :type cost_value: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -49,20 +46,18 @@ def edit_cost_value( ifcopenshell.api.cost.edit_cost_value(model, cost_value=value, attributes={"AppliedValue": 42.0}) """ - settings = {"cost_value": cost_value, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if name == "AppliedValue" and value is not None: # TODO: support all applied value select types value = file.createIfcMonetaryMeasure(value) elif name == "UnitBasis": - old_unit_basis = settings["cost_value"].UnitBasis + old_unit_basis = cost_value.UnitBasis if value: value_component = file.create_entity( ifcopenshell.util.unit.get_unit_measure_class(value["UnitComponent"].UnitType), value["ValueComponent"], ) value = file.create_entity("IfcMeasureWithUnit", value_component, value["UnitComponent"]) - if old_unit_basis and len(file.get_inverse(old_unit_basis)) == 0: + if old_unit_basis and file.get_total_inverses(old_unit_basis) == 0: ifcopenshell.util.element.remove_deep(file, old_unit_basis) - setattr(settings["cost_value"], name, value) + setattr(cost_value, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py index bb9b1bd18d..b5acaaf1cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py @@ -29,9 +29,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins retained. :param cost_item: The IfcCostItem entity you want to remove - :type cost_item: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -41,15 +39,13 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins item = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=schedule) ifcopenshell.api.cost.remove_cost_item(model, cost_item=item) """ - settings = {"cost_item": cost_item} - # TODO: do a deep purge - for inverse in file.get_inverse(settings["cost_item"]): + for inverse in file.get_inverse(cost_item): if inverse.is_a("IfcRelNests"): - if inverse.RelatingObject == settings["cost_item"]: + if inverse.RelatingObject == cost_item: for related_object in inverse.RelatedObjects: ifcopenshell.api.cost.remove_cost_item(file, cost_item=related_object) - elif inverse.RelatedObjects == (settings["cost_item"],): + elif inverse.RelatedObjects == (cost_item,): history = inverse.OwnerHistory file.remove(inverse) if history: @@ -59,7 +55,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["cost_item"].OwnerHistory - file.remove(settings["cost_item"]) + history = cost_item.OwnerHistory + file.remove(cost_item) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item_quantity.py index c9ef89455e..80f330ce0b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item_quantity.py @@ -28,11 +28,8 @@ def remove_cost_item_quantity( removed. :param cost_item: The IfcCostItem that the quantity is assigned to - :type cost_item: ifcopenshell.entity_instance :param physical_quantity: The IfcPhysicalQuantity to remove - :type physical_quantity: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -46,11 +43,9 @@ def remove_cost_item_quantity( ifcopenshell.api.cost.remove_cost_item(model, cost_item=item, physical_quantity=quantity) """ - settings = {"cost_item": cost_item, "physical_quantity": physical_quantity} - - if len(file.get_inverse(settings["physical_quantity"])) == 1: - file.remove(settings["physical_quantity"]) + if file.get_total_inverses(physical_quantity) == 1: + file.remove(physical_quantity) return - quantities = list(settings["cost_item"].CostQuantities or []) - quantities.remove(settings["physical_quantity"]) - settings["cost_item"].CostQuantities = quantities + quantities = list(cost_item.CostQuantities or []) + quantities.remove(physical_quantity) + cost_item.CostQuantities = quantities diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py index f5591ee9b1..0e05708f7f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py @@ -40,17 +40,15 @@ def remove_cost_schedule(file: ifcopenshell.file, cost_schedule: ifcopenshell.en item = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=schedule) ifcopenshell.api.cost.remove_cost_schedule(model, cost_schedule=schedule) """ - settings = {"cost_schedule": cost_schedule} - # TODO: do a deep purge - for inverse in file.get_inverse(settings["cost_schedule"]): + for inverse in file.get_inverse(cost_schedule): if inverse.is_a("IfcRelAssignsToControl"): [ ifcopenshell.api.cost.remove_cost_item(file, cost_item=related_object) for related_object in inverse.RelatedObjects if related_object.is_a("IfcCostItem") ] - history = settings["cost_schedule"].OwnerHistory - file.remove(settings["cost_schedule"]) + history = cost_schedule.OwnerHistory + file.remove(cost_schedule) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_value.py index 2c7745d7f9..b578cd7134 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_value.py @@ -28,11 +28,8 @@ def remove_cost_value( :param parent: The IfcCostItem, IfcConstructionResource, or IfcCostValue that the IfcCostValue is assigned to. - :type parent: ifcopenshell.entity_instance :param cost_value: The IfcCostValue that you want to remove - :type parent: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -48,20 +45,18 @@ def remove_cost_value( ifcopenshell.api.cost.remove_cost_value(model, parent=item, cost_value=value) """ - settings = {"parent": parent, "cost_value": cost_value} - - if len(file.get_inverse(settings["cost_value"])) == 1: - file.remove(settings["cost_value"]) + if file.get_total_inverses(cost_value) == 1: + file.remove(cost_value) # TODO deep purge - elif settings["parent"].is_a("IfcCostItem"): - values = list(settings["parent"].CostValues) - values.remove(settings["cost_value"]) - settings["parent"].CostValues = values if values else None - elif settings["parent"].is_a("IfcConstructionResource"): - values = list(settings["parent"].BaseCosts) - values.remove(settings["cost_value"]) - settings["parent"].BaseCosts = values if values else None - elif settings["parent"].is_a("IfcCostValue"): - components = list(settings["parent"].Components) - components.remove(settings["cost_value"]) - settings["parent"].Components = components if components else None + elif parent.is_a("IfcCostItem"): + values = list(parent.CostValues) + values.remove(cost_value) + parent.CostValues = values if values else None + elif parent.is_a("IfcConstructionResource"): + values = list(parent.BaseCosts) + values.remove(cost_value) + parent.BaseCosts = values if values else None + elif parent.is_a("IfcCostValue"): + components = list(parent.Components) + components.remove(cost_value) + parent.Components = components if components else None diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/unassign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/unassign_cost_item_quantity.py index bb815647fd..de180d65ef 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/unassign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/unassign_cost_item_quantity.py @@ -30,12 +30,9 @@ def unassign_cost_item_quantity( have any impact on the cost item. :param cost_item: The IfcCostItem to remove quantities from - :type cost_item: ifcopenshell.entity_instance :param products: A list of IfcProducts that may have parametrically connected quantities to the cost item - :type products: list[ifcopenshell.entity_instance] :return: None - :rtype: None Example: @@ -69,38 +66,39 @@ def unassign_cost_item_quantity( """ usecase = Usecase() usecase.file = file - usecase.settings = {"cost_item": cost_item, "products": products or []} - return usecase.execute() + return usecase.execute(cost_item, products or []) class Usecase: - def execute(self): - self.quantities = set(self.settings["cost_item"].CostQuantities or []) - for quantity in self.settings["cost_item"].CostQuantities or []: + file: ifcopenshell.file + + def execute(self, cost_item: ifcopenshell.entity_instance, products: list[ifcopenshell.entity_instance]) -> None: + quantities = set(cost_item.CostQuantities or []) + for quantity in cost_item.CostQuantities or []: for inverse in self.file.get_inverse(quantity): if not inverse.is_a("IfcElementQuantity"): continue for rel in inverse.DefinesOccurrence or []: for related_object in rel.RelatedObjects: - if related_object in self.settings["products"]: - self.quantities.remove(quantity) - self.settings["cost_item"].CostQuantities = list(self.quantities) - for product in self.settings["products"]: + if related_object in products: + quantities.remove(quantity) + cost_item.CostQuantities = list(quantities) + for product in products: ifcopenshell.api.control.unassign_control( self.file, related_object=product, - relating_control=self.settings["cost_item"], + relating_control=cost_item, ) - self.update_cost_item_count() + self.update_cost_item_count(cost_item) - def update_cost_item_count(self): + def update_cost_item_count(self, cost_item: ifcopenshell.entity_instance) -> None: # This is a bold assumption # https://forums.buildingsmart.org/t/how-does-a-cost-item-know-that-it-is-counting-a-controlled-product/3564 - if len(self.settings["cost_item"].CostQuantities) == 1: - quantity = self.settings["cost_item"].CostQuantities[0] + if len(cost_item.CostQuantities) == 1: + quantity = cost_item.CostQuantities[0] if quantity.is_a("IfcQuantityCount"): count = 0 - for rel in self.settings["cost_item"].Controls: + for rel in cost_item.Controls: count += len(rel.RelatedObjects) if count: quantity[3] = count diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py index 0fd2361c8d..ae56b0b935 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py @@ -38,9 +38,7 @@ def add_reference(file: ifcopenshell.file, information: ifcopenshell.entity_inst :param information: The IfcDocumentInformation that the reference will be created for - :type information: ifcopenshell.entity_instance :return: The newly created IfcDocumentReference entity - :rtype: ifcopenshell.entity_instance Example: @@ -63,13 +61,11 @@ def add_reference(file: ifcopenshell.file, information: ifcopenshell.entity_inst ifcopenshell.api.document.edit_reference(model, reference=reference2, attributes={"Identification": "2.1.15"}) """ - settings = {"information": information} - if file.schema == "IFC2X3": reference = file.create_entity("IfcDocumentReference", ItemReference="X") - if settings["information"]: - references = list(settings["information"].DocumentReferences or []) + if information: + references = list(information.DocumentReferences or []) references.append(reference) - settings["information"].DocumentReferences = references + information.DocumentReferences = references return reference - return file.create_entity("IfcDocumentReference", ReferencedDocument=settings["information"], Identification="X") + return file.create_entity("IfcDocumentReference", ReferencedDocument=information, Identification="X") diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py b/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py index 5627cc1c84..a86a178e86 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py @@ -41,15 +41,12 @@ def assign_document( :param product: The list of objects to associate the document to. This could be almost any sensible object in IFC. - :type product: list[ifcopenshell.entity_instance] :param document: The IfcDocumentReference to associate to, or alternatively an IfcDocumentInformation, though this is not recommended. - :type document: ifcopenshell.entity_instance :return: The IfcRelAssociatesDocument relationship or `None` if `products` was an empty list or all products were already assigned to the `document`. - :rtype: ifcopenshell.entity_instance Example: @@ -65,43 +62,41 @@ def assign_document( # Let's imagine storey represents an IfcBuildingStorey for the ground floor ifcopenshell.api.document.assign_document(model, products=[storey], document=reference) """ - settings = { - "products": products, - "document": document, - } # TODO: do we need to support non-ifcroot elements like we do in classification.add_reference? # NOTE: reuses code from `library.assign_reference` - referenced_elements = ifcopenshell.util.element.get_referenced_elements(settings["document"]) - products: set[ifcopenshell.entity_instance] = set(settings["products"]) - products = products - referenced_elements + referenced_elements = ifcopenshell.util.element.get_referenced_elements(document) + products_set: set[ifcopenshell.entity_instance] = set(products) + products_set = products_set - referenced_elements - if not products: + if not products_set: return if file.schema == "IFC2X3": rel = next( - (r for r in file.by_type("IfcRelAssociatesDocument") if r.RelatingDocument == settings["document"]), + (r for r in file.by_type("IfcRelAssociatesDocument") if r.RelatingDocument == document), None, ) else: - ifc_class = settings["document"].is_a() + ifc_class = document.is_a() if ifc_class == "IfcDocumentReference": - rel = next(iter(settings["document"].DocumentRefForObjects), None) + rel = next(iter(document.DocumentRefForObjects), None) elif ifc_class == "IfcDocumentInformation": - rel = next(iter(settings["document"].DocumentInfoForObjects), None) + rel = next(iter(document.DocumentInfoForObjects), None) + else: + assert False, f"Unexpected document type: {ifc_class}" if not rel: return file.create_entity( "IfcRelAssociatesDocument", GlobalId=ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), - RelatedObjects=list(products), - RelatingDocument=settings["document"], + RelatedObjects=list(products_set), + RelatingDocument=document, ) - related_objects = set(rel.RelatedObjects) | products + related_objects = set(rel.RelatedObjects) | products_set rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, element=rel) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/edit_information.py b/src/ifcopenshell-python/ifcopenshell/api/document/edit_information.py index 09d697f7db..56c1d40df4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/edit_information.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/edit_information.py @@ -30,11 +30,8 @@ def edit_information( IfcDocumentInformation, consult the IFC documentation. :param reference: The IfcDocumentInformation entity you want to edit - :type reference: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -46,7 +43,5 @@ def edit_information( attributes={"Identification": "A-GA-6100", "Name": "Overall Plan", "Location": "A-GA-6100 - Overall Plan.pdf"}) """ - settings = {"information": information, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["information"], name, value) + for name, value in attributes.items(): + setattr(information, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/edit_reference.py b/src/ifcopenshell-python/ifcopenshell/api/document/edit_reference.py index 5a00827305..51ad195bf3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/edit_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/edit_reference.py @@ -30,11 +30,8 @@ def edit_reference( IfcDocumentReference, consult the IFC documentation. :param reference: The IfcDocumentReference entity you want to edit - :type reference: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -49,7 +46,5 @@ def edit_reference( ifcopenshell.api.document.edit_reference(model, reference=reference, attributes={"Identification": "2.1.15"}) """ - settings = {"reference": reference, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["reference"], name, value) + for name, value in attributes.items(): + setattr(reference, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/unassign_document.py b/src/ifcopenshell-python/ifcopenshell/api/document/unassign_document.py index 6de39062f9..a81ae71973 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/unassign_document.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/unassign_document.py @@ -30,12 +30,9 @@ def unassign_document( :param product: The list of objects that the document reference or information is related to. - :type product: list[ifcopenshell.entity_instance] :param document: The IfcDocumentReference (typically) or in rare cases the IfcDocumentInformation that is associated with the product - :type document: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -54,27 +51,21 @@ def unassign_document( # Now let's change our mind and remove the association ifcopenshell.api.document.unassign_document(model, products=[storey], document=reference) """ - settings = { - "products": products, - "document": document, - } # TODO: do we need to support non-ifcroot elements like we do in classification.add_reference? # NOTE: reuses code from `library.un assign_reference` reference_rels: set[ifcopenshell.entity_instance] = set() - products = set(settings["products"]) - for product in products: + products_set = set(products) + for product in products_set: reference_rels.update(product.HasAssociations) reference_rels = { - rel - for rel in reference_rels - if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument == settings["document"] + rel for rel in reference_rels if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument == document } for rel in reference_rels: - related_objects = set(rel.RelatedObjects) - products + related_objects = set(rel.RelatedObjects) - products_set if related_objects: rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py index fcd739ffad..87f786d807 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py @@ -46,12 +46,9 @@ def assign_product( in 3D. :param relating_product: The IfcProduct the object is related to - :type relating_product: ifcopenshell.entity_instance :param related_object: The object (typically IfcAnnotation) that the product is related to - :type related_object: ifcopenshell.entity_instance :return: The created IfcRelAssignsToProduct relationship - :rtype: ifcopenshell.entity_instance Example: @@ -62,42 +59,36 @@ def assign_product( ifcopenshell.api.drawing.assign_product(model, relating_product=furniture, related_object=annotation) """ - settings = { - "relating_product": relating_product, - "related_object": related_object, - } - - is_grid_axis = settings["relating_product"].is_a("IfcGridAxis") + is_grid_axis = relating_product.is_a("IfcGridAxis") if is_grid_axis: - if settings["related_object"].HasAssignments: - for rel in settings["related_object"].HasAssignments: - if rel.is_a("IfcRelAssignsToProduct") and rel.Name == settings["relating_product"].AxisTag: + if related_object.HasAssignments: + for rel in related_object.HasAssignments: + if rel.is_a("IfcRelAssignsToProduct") and rel.Name == relating_product.AxisTag: return - elif settings["related_object"].HasAssignments: - for rel in settings["related_object"].HasAssignments: - if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct == settings["relating_product"]: + elif related_object.HasAssignments: + for rel in related_object.HasAssignments: + if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct == relating_product: return referenced_by = None if is_grid_axis: - axis = settings["relating_product"] + axis = relating_product grid = None for attribute in ("PartOfW", "PartOfV", "PartOfU"): if getattr(axis, attribute, None): grid = getattr(axis, attribute)[0] - settings["relating_product"] = grid for rel in grid.ReferencedBy: if rel.Name == axis.AxisTag: referenced_by = rel break - elif settings["relating_product"].ReferencedBy: - referenced_by = settings["relating_product"].ReferencedBy[0] + elif relating_product.ReferencedBy: + referenced_by = relating_product.ReferencedBy[0] if referenced_by: related_objects = list(referenced_by.RelatedObjects) - related_objects.append(settings["related_object"]) + related_objects.append(related_object) referenced_by.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": referenced_by}) else: @@ -106,8 +97,8 @@ def assign_product( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingProduct": settings["relating_product"], + "RelatedObjects": [related_object], + "RelatingProduct": relating_product, }, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/edit_text_literal.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/edit_text_literal.py index 1f7356e989..c083960e59 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/edit_text_literal.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/edit_text_literal.py @@ -28,11 +28,8 @@ def edit_text_literal( IfcTextLiteral, consult the IFC documentation. :param reference: The IfcTextLiteral entity you want to edit - :type reference: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_text_literal( ifcopenshell.api.drawing.edit_text_literal(model, text_literal=text, attributes={"Literal": "MY ANNOTATION"}) """ - settings = {"text_literal": text_literal, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["text_literal"], name, value) + for name, value in attributes.items(): + setattr(text_literal, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/unassign_product.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/unassign_product.py index 03812040cc..f95a98a1fe 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/unassign_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/unassign_product.py @@ -34,12 +34,9 @@ def unassign_product( object later or leave the annotation as a "dumb" annotation. :param relating_product: The IfcProduct the object is related to - :type relating_product: ifcopenshell.entity_instance :param related_object: The object (typically IfcAnnotation) that the product is related to - :type related_object: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -54,13 +51,8 @@ def unassign_product( ifcopenshell.api.drawing.unassign_product(model, relating_product=furniture, related_object=annotation) """ - settings = { - "relating_product": relating_product, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToProduct") or rel.RelatingProduct != settings["relating_product"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToProduct") or rel.RelatingProduct != relating_product: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -69,6 +61,6 @@ def unassign_product( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, element=rel) diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/add_filling.py b/src/ifcopenshell-python/ifcopenshell/api/feature/add_filling.py index 7f59d65ec8..bb6035cd79 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/add_filling.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/add_filling.py @@ -33,11 +33,8 @@ def add_filling( filled. :param opening: The IfcOpeningElement to fill with the element. - :type opening: ifcopenshell.entity_instance :param element: The IfcElement to be inserted into the opening. - :type element: ifcopenshell.entity_instance :return: The new IfcRelFillsElement relationship - :rtype: ifcopenshell.entity_instance Example: @@ -102,12 +99,10 @@ def add_filling( # The door will now fill the opening. ifcopenshell.api.feature.add_filling(model, opening=opening, element=door) """ - settings = {"opening": opening, "element": element} - - fills_voids = settings["element"].FillsVoids + fills_voids = element.FillsVoids if fills_voids: - if fills_voids[0].RelatingOpeningElement == settings["opening"]: + if fills_voids[0].RelatingOpeningElement == opening: return fills_voids[0] history = fills_voids[0].OwnerHistory file.remove(fills_voids[0]) @@ -117,6 +112,6 @@ def add_filling( return file.create_entity( "IfcRelFillsElement", GlobalId=ifcopenshell.guid.new(), - RelatingOpeningElement=settings["opening"], - RelatedBuildingElement=settings["element"], + RelatingOpeningElement=opening, + RelatedBuildingElement=element, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_filling.py b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_filling.py index f6931b4f9d..b009b1e0c4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_filling.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_filling.py @@ -28,9 +28,7 @@ def remove_filling(file: ifcopenshell.file, element: ifcopenshell.entity_instanc fills the opening. :param element: The element filling an opening. - :type element: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -52,10 +50,8 @@ def remove_filling(file: ifcopenshell.file, element: ifcopenshell.entity_instanc # Not anymore! ifcopenshell.api.feature.remove_filling(model, element=door) """ - settings = {"element": element} - for rel in file.by_type("IfcRelFillsElement"): - if rel.RelatedBuildingElement == settings["element"]: + if rel.RelatedBuildingElement == element: history = rel.OwnerHistory file.remove(rel) if history: diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 8a253eee73..0f17323634 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -26,40 +26,30 @@ geometry extrusions). from .. import wrap_usecases from .add_axis_representation import add_axis_representation from .add_boolean import add_boolean - -try: - from .add_door_representation import add_door_representation -except ModuleNotFoundError as e: - print(f"Note: API not available due to missing dependencies: geometry.add_door_representation - {e}") +from .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation from .add_profile_representation import add_profile_representation - -try: - from .add_railing_representation import add_railing_representation -except ModuleNotFoundError as e: - print(f"Note: API not available due to missing dependencies: geometry.add_railing_representation - {e}") +from .add_railing_representation import add_railing_representation try: from .add_representation import add_representation -except ModuleNotFoundError as e: - print(f"Note: API not available due to missing dependencies: geometry.add_representation - {e}") +except ModuleNotFoundError: + pass # Silently fail. This is Blender / Bonsai specific and on its way out. from .add_shape_aspect import add_shape_aspect from .add_slab_representation import add_slab_representation from .add_wall_representation import add_wall_representation - -try: - from .add_window_representation import add_window_representation -except ModuleNotFoundError as e: - print(f"Note: API not available due to missing dependencies: geometry.add_window_representation - {e}") +from .add_window_representation import add_window_representation from .assign_representation import assign_representation from .connect_element import connect_element from .connect_path import connect_path +from .connect_wall import connect_wall from .create_2pt_wall import create_2pt_wall from .disconnect_element import disconnect_element from .disconnect_path import disconnect_path from .edit_object_placement import edit_object_placement from .map_representation import map_representation +from .regenerate_wall_representation import regenerate_wall_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation from .unassign_representation import unassign_representation @@ -83,11 +73,13 @@ __all__ = [ "assign_representation", "connect_element", "connect_path", + "connect_wall", "create_2pt_wall", "disconnect_element", "disconnect_path", "edit_object_placement", "map_representation", + "regenerate_wall_representation", "remove_boolean", "remove_representation", "unassign_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_axis_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_axis_representation.py index 1883374b97..02bd057cc6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_axis_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_axis_representation.py @@ -17,7 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.util.unit -from typing import Union +from typing import Union, Any COORD = Union[tuple[float, float], tuple[float, float, float]] @@ -56,13 +56,10 @@ def add_axis_representation( :param context: The IfcGeometricRepresentationContext that the representation is part of. This must be either a Model/Axis/GRAPH_VIEW (3D) or Plan/Axis/GRAPH_VIEW (2D). - :type context: ifcopenshell.entity_instance :param axis: The axis, as a list of two coordinates, the coordinates being either a list of 2 or 3 float coordinates depending on whether the axis is 2D or 3D. - :type axis: list[list[float]] :return: The newly created IfcShapeRepresentation entity - :rtype: ifcopenshell.entity_instance Example: @@ -82,6 +79,9 @@ def add_axis_representation( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) is_2d = len(self.settings["axis"][0]) == 2 diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py index 6b4fd7e510..fc60a25bbc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py @@ -622,7 +622,7 @@ class Usecase: window_lining_size = V(overall_width, lining_depth, window_lining_height) window_position = V(0, 0, overall_height - window_lining_height) frame_size = V(door_opening_width, frame_depth, frame_height) - window_lining_items, frame_items, glass_items = create_ifc_window( + current_window_items = create_ifc_window( builder, window_lining_size, window_lining_thickness, @@ -633,6 +633,9 @@ class Usecase: glass_thickness, window_position, ) + window_lining_items = current_window_items["Lining"] + frame_items = current_window_items["Framing"] + glass_items = current_window_items["Glazing"] lining_offset_items = lining_items + door_items + window_lining_items + frame_items + glass_items builder.translate(lining_offset_items, (0, lining_offset, 0)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_footprint_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_footprint_representation.py index 6b48845e4e..9d5fe9f20b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_footprint_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_footprint_representation.py @@ -26,14 +26,9 @@ def add_footprint_representation( # A list of IFC curves to include in the curve set curves: list[ifcopenshell.entity_instance], ) -> ifcopenshell.entity_instance: - settings = { - "context": context, - "curves": curves, - } - return file.createIfcShapeRepresentation( - settings["context"], - settings["context"].ContextIdentifier, + context, + context.ContextIdentifier, "GeometricCurveSet", - [file.createIfcGeometricCurveSet(settings["curves"])], + [file.createIfcGeometricCurveSet(curves)], ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_mesh_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_mesh_representation.py index efe6aa5c89..940315621a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_mesh_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_mesh_representation.py @@ -17,7 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.util.unit -from typing import Optional +from typing import Optional, Any COORD_3D = tuple[float, float, float] @@ -60,6 +60,9 @@ def add_mesh_representation( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): if self.settings["unit_scale"] is None: self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_profile_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_profile_representation.py index 17cdec58c8..9458131046 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_profile_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_profile_representation.py @@ -17,160 +17,207 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.geom +import ifcopenshell.util.element +import ifcopenshell.util.shape import ifcopenshell.util.unit from ifcopenshell.util.data import Clipping -from typing import Any, Union, Optional, Literal +from typing import Any, Union, Optional, Literal, get_args VECTOR_3D = tuple[float, float, float] +CardinalPointNumeric = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] +CardinalPointString = Literal[ + "bottom left", + "bottom centre", + "bottom right", + "mid-depth left", + "mid-depth centre", + "mid-depth right", + "top left", + "top centre", + "top right", + "geometric centroid", + "bottom in line with the geometric centroid", + "left in line with the geometric centroid", + "right in line with the geometric centroid", + "top in line with the geometric centroid", + "shear centre", + "bottom in line with the shear centre", + "left in line with the shear centre", + "right in line with the shear centre", + "top in line with the shear centre", +] +CARDINAL_POINT_VALUES: tuple[CardinalPointString, ...] = get_args(CardinalPointString) +CardinalPoint = Union[CardinalPointNumeric, CardinalPointString] def add_profile_representation( file: ifcopenshell.file, - # IfcGeometricRepresentationContext context: ifcopenshell.entity_instance, profile: ifcopenshell.entity_instance, - # in meters depth: float = 1.0, - cardinal_point: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, - # A list of planes that define clipping half space solids - # Planes are defined either by Clipping objects - # or by dictionaries of arguments for `Clipping.parse` + # TODO: None makes more sense as default value? + cardinal_point: Union[CardinalPoint, None] = 5, clippings: Optional[list[Union[Clipping, dict[str, Any]]]] = None, placement_zx_axes: tuple[Union[VECTOR_3D, None], Union[VECTOR_3D, None]] = (None, None), ) -> ifcopenshell.entity_instance: + """Add profile representation. + + :param context: The IfcGeometricRepresentationContext for the representation, + only Model/Body/MODEL_VIEW type of representations are currently supported. + :param profile: The IfcProfileDef to extrude. + :param depth: The depth of the extrusion in meters. + :param cardinal_point: The cardinal point of the profile. + :param clippings: A list of planes that define clipping half space solids. + Planes are defined either by Clipping objects + or by dictionaries of arguments for `Clipping.parse`. + :param placement_zx_axes: A tuple of two vectors that define the placement of the profile. + The first vector is the Z axis, the second vector is the X axis. + :return: IfcShapeRepresentation. + """ usecase = Usecase() usecase.file = file - usecase.settings = { - "context": context, - "profile": profile, - "depth": depth, - "cardinal_point": cardinal_point, - "clippings": clippings if clippings is not None else [], - "placement_zx_axes": placement_zx_axes, - } - return usecase.execute() + clippings = clippings if clippings is not None else [] + return usecase.execute(context, profile, depth, cardinal_point, clippings, placement_zx_axes) class Usecase: - def execute(self): - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - self.settings["clippings"] = [Clipping.parse(c) for c in self.settings["clippings"]] - return self.file.createIfcShapeRepresentation( - self.settings["context"], - self.settings["context"].ContextIdentifier, - "Clipping" if self.settings["clippings"] else "SweptSolid", + file: ifcopenshell.file + clippings: list[Clipping] + + def execute( + self, + context: ifcopenshell.entity_instance, + profile: ifcopenshell.entity_instance, + depth: float, + cardinal_point: Union[CardinalPoint, None], + clippings: list[Union[Clipping, dict[str, Any]]], + placement_zx_axes: tuple[Union[VECTOR_3D, None], Union[VECTOR_3D, None]], + ) -> ifcopenshell.entity_instance: + if isinstance(cardinal_point, int): + cardinal_point = CARDINAL_POINT_VALUES[cardinal_point - 1] + + self.cardinal_point = cardinal_point + self.profile = profile + self.clippings = [Clipping.parse(c) for c in clippings] + self.depth = depth + self.placement_zx_axes = placement_zx_axes + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) + return self.file.create_entity( + "IfcShapeRepresentation", + context, + context.ContextIdentifier, + "Clipping" if self.clippings else "SweptSolid", [self.create_item()], ) - def create_item(self): + def create_item(self) -> ifcopenshell.entity_instance: point = self.get_point() placement = self.file.createIfcAxis2Placement3D( point, - self.file.createIfcDirection(self.settings["placement_zx_axes"][0] or (0.0, 0.0, 1.0)), - self.file.createIfcDirection(self.settings["placement_zx_axes"][1] or (1.0, 0.0, 0.0)), + self.file.create_entity("IfcDirection", self.placement_zx_axes[0] or (0.0, 0.0, 1.0)), + self.file.create_entity("IfcDirection", self.placement_zx_axes[1] or (1.0, 0.0, 0.0)), ) - extrusion = self.file.createIfcExtrudedAreaSolid( - self.settings["profile"], + extrusion = self.file.create_entity( + "IfcExtrudedAreaSolid", + self.profile, placement, self.file.createIfcDirection((0.0, 0.0, 1.0)), - self.convert_si_to_unit(self.settings["depth"]), + self.convert_si_to_unit(self.depth), ) - if self.settings["clippings"]: + if self.clippings: return self.apply_clippings(extrusion) return extrusion - def apply_clippings(self, first_operand): - while self.settings["clippings"]: - clipping = self.settings["clippings"].pop() + def apply_clippings(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + while self.clippings: + clipping = self.clippings.pop() if isinstance(clipping, ifcopenshell.entity_instance): new = ifcopenshell.util.element.copy(self.file, clipping) new.FirstOperand = first_operand first_operand = new else: # Clipping - first_operand = clipping.apply(self.file, first_operand, self.settings["unit_scale"]) + first_operand = clipping.apply(self.file, first_operand, self.unit_scale) return first_operand - def convert_si_to_unit(self, co): - return co / self.settings["unit_scale"] + def convert_si_to_unit(self, co: float) -> float: + return co / self.unit_scale - def get_point(self): - if not self.settings["cardinal_point"]: + def get_point(self) -> ifcopenshell.entity_instance: + if not self.cardinal_point: return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) - elif self.settings["cardinal_point"] == 1: + elif self.cardinal_point == "bottom left": return self.file.createIfcCartesianPoint((-self.get_x() / 2, self.get_y() / 2, 0.0)) - elif self.settings["cardinal_point"] == 2: + elif self.cardinal_point == "bottom centre": return self.file.createIfcCartesianPoint((0.0, self.get_y() / 2, 0.0)) - elif self.settings["cardinal_point"] == 3: + elif self.cardinal_point == "bottom right": return self.file.createIfcCartesianPoint((self.get_x() / 2, self.get_y() / 2, 0.0)) - elif self.settings["cardinal_point"] == 4: + elif self.cardinal_point == "mid-depth left": return self.file.createIfcCartesianPoint((-self.get_x() / 2, 0.0, 0.0)) - elif self.settings["cardinal_point"] == 5: + elif self.cardinal_point == "mid-depth centre": return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) - elif self.settings["cardinal_point"] == 6: + elif self.cardinal_point == "mid-depth right": return self.file.createIfcCartesianPoint((self.get_x() / 2, 0.0, 0.0)) - elif self.settings["cardinal_point"] == 7: + elif self.cardinal_point == "top left": return self.file.createIfcCartesianPoint((-self.get_x() / 2, -self.get_y() / 2, 0.0)) - elif self.settings["cardinal_point"] == 8: + elif self.cardinal_point == "top centre": return self.file.createIfcCartesianPoint((0.0, -self.get_y() / 2, 0.0)) - elif self.settings["cardinal_point"] == 9: + elif self.cardinal_point == "top right": return self.file.createIfcCartesianPoint((self.get_x() / 2, -self.get_y() / 2, 0.0)) # TODO other cardinal points return self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) - def get_x(self): - if self.settings["profile"].is_a("IfcAsymmetricIShapeProfileDef"): - return self.settings["profile"].OverallWidth - elif self.settings["profile"].is_a("IfcCShapeProfileDef"): - return self.settings["profile"].Width - elif self.settings["profile"].is_a("IfcCircleProfileDef"): - return self.settings["profile"].Radius * 2 - elif self.settings["profile"].is_a("IfcEllipseProfileDef"): - return self.settings["profile"].SemiAxis1 * 2 - elif self.settings["profile"].is_a("IfcIShapeProfileDef"): - return self.settings["profile"].OverallWidth - elif self.settings["profile"].is_a("IfcLShapeProfileDef"): - return self.settings["profile"].Width - elif self.settings["profile"].is_a("IfcRectangleProfileDef"): - return self.settings["profile"].XDim - elif self.settings["profile"].is_a("IfcTShapeProfileDef"): - return self.settings["profile"].FlangeWidth - elif self.settings["profile"].is_a("IfcUShapeProfileDef"): - return self.settings["profile"].FlangeWidth - elif self.settings["profile"].is_a("IfcZShapeProfileDef"): - return (self.settings["profile"].FlangeWidth * 2) - self.settings["profile"].WebThickness + def get_x(self) -> float: + if self.profile.is_a("IfcAsymmetricIShapeProfileDef"): + return self.profile.OverallWidth + elif self.profile.is_a("IfcCShapeProfileDef"): + return self.profile.Width + elif self.profile.is_a("IfcCircleProfileDef"): + return self.profile.Radius * 2 + elif self.profile.is_a("IfcEllipseProfileDef"): + return self.profile.SemiAxis1 * 2 + elif self.profile.is_a("IfcIShapeProfileDef"): + return self.profile.OverallWidth + elif self.profile.is_a("IfcLShapeProfileDef"): + return self.profile.Width + elif self.profile.is_a("IfcRectangleProfileDef"): + return self.profile.XDim + elif self.profile.is_a("IfcTShapeProfileDef"): + return self.profile.FlangeWidth + elif self.profile.is_a("IfcUShapeProfileDef"): + return self.profile.FlangeWidth + elif self.profile.is_a("IfcZShapeProfileDef"): + return (self.profile.FlangeWidth * 2) - self.profile.WebThickness else: settings = ifcopenshell.geom.settings() settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, self.settings["profile"]) - x = [shape.verts[i] for i in range(0, len(shape.verts), 3)] - return self.convert_si_to_unit(max(x) - min(x)) + shape = ifcopenshell.geom.create_shape(settings, self.profile) + return self.convert_si_to_unit(ifcopenshell.util.shape.get_x(shape)) return 0.0 - def get_y(self): - if self.settings["profile"].is_a("IfcAsymmetricIShapeProfileDef"): - return self.settings["profile"].OverallDepth - elif self.settings["profile"].is_a("IfcCShapeProfileDef"): - return self.settings["profile"].Depth - elif self.settings["profile"].is_a("IfcCircleProfileDef"): - return self.settings["profile"].Radius * 2 - elif self.settings["profile"].is_a("IfcEllipseProfileDef"): - return self.settings["profile"].SemiAxis2 * 2 - elif self.settings["profile"].is_a("IfcIShapeProfileDef"): - return self.settings["profile"].OverallDepth - elif self.settings["profile"].is_a("IfcLShapeProfileDef"): - return self.settings["profile"].Depth - elif self.settings["profile"].is_a("IfcRectangleProfileDef"): - return self.settings["profile"].YDim - elif self.settings["profile"].is_a("IfcTShapeProfileDef"): - return self.settings["profile"].Depth - elif self.settings["profile"].is_a("IfcUShapeProfileDef"): - return self.settings["profile"].Depth - elif self.settings["profile"].is_a("IfcZShapeProfileDef"): - return self.settings["profile"].Depth + def get_y(self) -> float: + if self.profile.is_a("IfcAsymmetricIShapeProfileDef"): + return self.profile.OverallDepth + elif self.profile.is_a("IfcCShapeProfileDef"): + return self.profile.Depth + elif self.profile.is_a("IfcCircleProfileDef"): + return self.profile.Radius * 2 + elif self.profile.is_a("IfcEllipseProfileDef"): + return self.profile.SemiAxis2 * 2 + elif self.profile.is_a("IfcIShapeProfileDef"): + return self.profile.OverallDepth + elif self.profile.is_a("IfcLShapeProfileDef"): + return self.profile.Depth + elif self.profile.is_a("IfcRectangleProfileDef"): + return self.profile.YDim + elif self.profile.is_a("IfcTShapeProfileDef"): + return self.profile.Depth + elif self.profile.is_a("IfcUShapeProfileDef"): + return self.profile.Depth + elif self.profile.is_a("IfcZShapeProfileDef"): + return self.profile.Depth else: settings = ifcopenshell.geom.settings() settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, self.settings["profile"]) - y = [shape.verts[i + 1] for i in range(0, len(shape.verts), 3)] - return self.convert_si_to_unit(max(y) - min(y)) + shape = ifcopenshell.geom.create_shape(settings, self.profile) + return self.convert_si_to_unit(ifcopenshell.util.shape.get_y(shape)) return 0.0 diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py index 0e5940f3d4..8244c3e434 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py @@ -111,10 +111,12 @@ class Usecase: settings: dict[str, Any] ifc_vertices: list[ifcopenshell.entity_instance] coordinate_offset: Union[npt.NDArray[np.float64], None] + geometry: Union[bpy.types.Mesh, bpy.types.Curve] def execute(self) -> Union[ifcopenshell.entity_instance, None]: self.is_manifold = None self.coordinate_offset = self.settings["coordinate_offset"] + self.geometry = self.settings["geometry"] if ( isinstance(self.settings["geometry"], bpy.types.Mesh) and self.settings["geometry"] == self.settings["blender_object"].data @@ -204,6 +206,7 @@ class Usecase: elif self.settings["context"].ContextIdentifier == "Reference": if self.settings["context"].TargetView == "GRAPH_VIEW": return self.create_structural_reference_representation() + return self.create_variable_representation() elif self.settings["context"].ContextIdentifier == "Profile": return self.create_curve3d_representation() elif self.settings["context"].ContextIdentifier == "SurveyPoints": @@ -635,9 +638,7 @@ class Usecase: dim = (lambda v: v.xy) if is_2d else (lambda v: v.xyz) results = [] for spline in curve_object_data.splines: - points = spline.bezier_points[:] + spline.points[:] - if spline.use_cyclic_u: - points.append(points[0]) + points = self.get_spline_points(spline) ifc_points = [self.create_cartesian_point(*dim(point.co)) for point in points] results.append(self.file.createIfcPolyline(ifc_points)) return results @@ -980,12 +981,12 @@ class Usecase: ) def create_structural_reference_representation(self) -> ifcopenshell.entity_instance: - if len(self.settings["geometry"].vertices) == 1: + if isinstance(self.geometry, bpy.types.Mesh) and len(self.geometry.vertices) == 1: return self.file.createIfcTopologyRepresentation( self.settings["context"], self.settings["context"].ContextIdentifier, "Vertex", - [self.create_vertex_point(self.settings["geometry"].vertices[0].co)], + [self.create_vertex_point(self.geometry.vertices[0].co)], ) return self.file.createIfcTopologyRepresentation( self.settings["context"], @@ -997,11 +998,22 @@ class Usecase: def create_vertex_point(self, point: Vector) -> ifcopenshell.entity_instance: return self.file.createIfcVertexPoint(self.create_cartesian_point(point.x, point.y, point.z)) + def get_spline_points( + self, spline: bpy.types.Spline + ) -> list[Union[bpy.types.SplinePoint, bpy.types.BezierSplinePoint]]: + points = spline.bezier_points[:] + spline.points[:] + if spline.use_cyclic_u: + points.append(points[0]) + return points + def create_edge(self) -> Union[ifcopenshell.entity_instance, None]: - if hasattr(self.settings["geometry"], "splines"): - points = self.get_spline_points(self.settings["geometry"].splines[0]) + geometry = self.geometry + if isinstance(geometry, bpy.types.Curve): + points = self.get_spline_points(geometry.splines[0]) + elif isinstance(geometry, bpy.types.Mesh): + points = geometry.vertices else: - points = self.settings["geometry"].vertices + assert False, type(geometry) if not points: return return self.file.createIfcEdge(self.create_vertex_point(points[0].co), self.create_vertex_point(points[1].co)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 05ed08edf4..c0d78872f4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -27,10 +27,11 @@ def add_slab_representation( file: ifcopenshell.file, context: ifcopenshell.entity_instance, depth: float = 0.2, + # TODO: document remaining args. direction_sense: str = "POSITIVE", offset: float = 0.0, x_angle: float = 0.0, - clippings: Optional[list[Union[Clipping, dict[str, Any]]]] = None, + clippings: Optional[list[Union[Clipping, ifcopenshell.entity_instance]]] = None, polyline: Optional[list[tuple[float, float]]] = None, ) -> ifcopenshell.entity_instance: """ @@ -55,85 +56,106 @@ def add_slab_representation( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "context": context, - "depth": depth, - "direction_sense": direction_sense, - "offset": offset, - "x_angle": x_angle, - "clippings": clippings if clippings is not None else [], - "polyline": polyline, - } - return usecase.execute() + return usecase.execute( + context, + depth, + direction_sense, + offset, + x_angle, + clippings if clippings is not None else [], + polyline, + ) class Usecase: file: ifcopenshell.file - settings: dict[str, Any] - def execute(self): - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - return self.file.createIfcShapeRepresentation( - self.settings["context"], - self.settings["context"].ContextIdentifier, - "Clipping" if self.settings["clippings"] else "SweptSolid", + def execute( + self, + context: ifcopenshell.entity_instance, + depth: float, + direction_sense: str, + offset: float, + x_angle: float, + clippings: list[Union[Clipping, ifcopenshell.entity_instance]], + polyline: Optional[list[tuple[float, float]]], + ) -> ifcopenshell.entity_instance: + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) + self.clippings = clippings + self.depth = depth + self.direction_sense = direction_sense + self.offset = offset + self.x_angle = x_angle + self.polyline = polyline + return self.file.create_entity( + "IfcShapeRepresentation", + context, + context.ContextIdentifier, + "Clipping" if self.clippings else "SweptSolid", [self.create_item()], ) - def create_item(self): + def create_item(self) -> ifcopenshell.entity_instance: size = self.convert_si_to_unit(1) points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0)) - if self.settings["polyline"]: + if self.polyline: points = [ - (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * (1 / cos(self.settings["x_angle"])))) - for p in self.settings["polyline"] + (self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle)))) + for p in self.polyline ] if self.file.schema == "IFC2X3": curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) else: curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList2D(points)) - if self.settings["x_angle"]: - extrusion_direction = self.file.createIfcDirection( - (0.0, sin(self.settings["x_angle"]), cos(self.settings["x_angle"])) - ) - if self.settings["direction_sense"] == "NEGATIVE": - extrusion_direction = self.file.createIfcDirection( - (0.0, -sin(self.settings["x_angle"]), -cos(self.settings["x_angle"])) - ) - else: - extrusion_direction = self.file.createIfcDirection((0.0, 0.0, 1.0)) - if self.settings["direction_sense"] == "NEGATIVE": - extrusion_direction = self.file.createIfcDirection((0.0, 0.0, -1.0)) + if self.x_angle: + direction_ratios = (0.0, sin(self.x_angle), cos(self.x_angle)) + else: + direction_ratios = (0.0, 0.0, 1.0) + + offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative + extrusion_direction = self.file.createIfcDirection(direction_ratios) + if self.direction_sense == "NEGATIVE": + direction_ratios = tuple((-n for n in direction_ratios)) + extrusion_direction = self.file.createIfcDirection(direction_ratios) + + perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle)) + perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle)) position = None # default position for IFC2X3 where .Position is not optional - if self.file.schema == "IFC2X3" or self.settings["offset"] != 0: + if self.file.schema == "IFC2X3" or self.offset != 0: + position_vector = ( + offset_direction[0] * perpendicular_offset, + offset_direction[1] * perpendicular_offset, + offset_direction[2] * perpendicular_offset, + ) position = self.file.createIfcAxis2Placement3D( - self.file.createIfcCartesianPoint((0.0, 0.0, self.convert_si_to_unit(self.settings["offset"]))), + self.file.createIfcCartesianPoint(position_vector), self.file.createIfcDirection((0.0, 0.0, 1.0)), self.file.createIfcDirection((1.0, 0.0, 0.0)), ) - extrusion = self.file.createIfcExtrudedAreaSolid( + extrusion = self.file.create_entity( + "IfcExtrudedAreaSolid", self.file.createIfcArbitraryClosedProfileDef("AREA", None, curve), position, extrusion_direction, - self.convert_si_to_unit(self.settings["depth"]) * 1 / cos(self.settings["x_angle"]), + perpendicular_depth, ) - if self.settings["clippings"]: + if self.clippings: return self.apply_clippings(extrusion) return extrusion - def apply_clippings(self, first_operand): - while self.settings["clippings"]: - clipping = self.settings["clippings"].pop() + def apply_clippings(self, first_operand: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + while self.clippings: + clipping = self.clippings.pop() if isinstance(clipping, ifcopenshell.entity_instance): new = ifcopenshell.util.element.copy(self.file, clipping) new.FirstOperand = first_operand first_operand = new else: # Clipping - first_operand = clipping.apply(self.file, first_operand, self.settings["unit_scale"]) + first_operand = clipping.apply(self.file, first_operand, self.unit_scale) return first_operand - def convert_si_to_unit(self, co): - return co / self.settings["unit_scale"] + def convert_si_to_unit(self, co: float) -> float: + return co / self.unit_scale diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index 36691a044b..28e0112be9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -18,7 +18,7 @@ import ifcopenshell.util.element import ifcopenshell.util.unit -from math import sin, cos +from math import sin, cos, pi from typing import Optional, Union, Any from ifcopenshell.util.data import Clipping @@ -112,7 +112,7 @@ class Usecase: self.file.createIfcDirection((1.0, 0.0, 0.0)), ), extrusion_direction, - self.convert_si_to_unit(self.settings["height"]) * (1 / cos(self.settings["x_angle"])), + self.convert_si_to_unit(self.settings["height"]) * abs((1 / cos(self.settings["x_angle"]))), ) if self.settings["booleans"]: extrusion = self.apply_booleans(extrusion) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py index b6ba723695..4477694ca5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py @@ -181,7 +181,7 @@ def create_ifc_window( glass_thickness: float, position: np.ndarray, x_offsets: Optional[list[float]] = None, -) -> tuple[list[ifcopenshell.entity_instance], list[ifcopenshell.entity_instance], list[ifcopenshell.entity_instance]]: +) -> dict[str, list[ifcopenshell.entity_instance]]: """`lining_thickness` and `x_offsets` are expected to be defined as a list, similarly to `create_ifc_window_frame_simple` `thickness` argument""" lining_items: list[ifcopenshell.entity_instance] = [] @@ -232,7 +232,7 @@ def create_ifc_window( output_items = (lining_items, frame_extruded_items, [glass]) builder.translate(chain(*output_items), position) - return output_items + return {"Lining": lining_items, "Framing": frame_extruded_items, "Glazing": [glass]} # we use dataclass as we need default values for arguments @@ -363,6 +363,7 @@ def add_window_representation( partition_type: WINDOW_TYPE = "SINGLE_PANEL", lining_properties: Optional[Union[WindowLiningProperties, dict[str, Any]]] = None, panel_properties: Optional[list[Union[WindowPanelProperties, dict[str, Any]]]] = None, + part_of_product: Optional[ifcopenshell.entity_instance] = None, unit_scale: Optional[float] = None, ) -> ifcopenshell.entity_instance: """units in usecase_settings expected to be in ifc project units @@ -415,6 +416,7 @@ def add_window_representation( "partition_type": partition_type, "lining_properties": lining_properties, "panel_properties": panel_properties, + "part_of_product": part_of_product, } ) @@ -424,6 +426,9 @@ def add_window_representation( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): builder = ShapeBuilder(self.file) np_X, np_Y, np_Z = 0, 1, 2 @@ -437,9 +442,12 @@ class Usecase: panel_schema: list[list[int]] = self.settings["panel_schema"] panels: list[dict[str, Any]] = self.settings["panel_properties"] - accumulated_height = [0] * len(panel_schema[0]) + accumulated_height: list[float] = [0] * len(panel_schema[0]) built_panels: list[int] = [] window_items: list[ifcopenshell.entity_instance] = [] + lining_items: list[ifcopenshell.entity_instance] = [] + framing_items: list[ifcopenshell.entity_instance] = [] + glazing_items: list[ifcopenshell.entity_instance] = [] lining_props: dict[str, Any] = self.settings["lining_properties"] lining_thickness: float = lining_props["LiningThickness"] @@ -728,13 +736,39 @@ class Usecase: x_offsets, ) built_panels.append(panel_i) - window_items.extend(chain(*current_window_items)) + window_items.extend(chain(*current_window_items.values())) + lining_items.extend(current_window_items["Lining"]) + framing_items.extend(current_window_items["Framing"]) + glazing_items.extend(current_window_items["Glazing"]) accumulated_height[column_i] += panel_height accumulated_width += panel_width builder.translate(window_items, (0, lining_offset, 0)) # wall offset representation = builder.get_representation(self.settings["context"], window_items) + if self.settings["part_of_product"]: + ifcopenshell.api.geometry.add_shape_aspect( + self.file, + "Lining", + items=lining_items, + representation=representation, + part_of_product=self.settings["part_of_product"], + ) + ifcopenshell.api.geometry.add_shape_aspect( + self.file, + "Framing", + items=framing_items, + representation=representation, + part_of_product=self.settings["part_of_product"], + ) + ifcopenshell.api.geometry.add_shape_aspect( + self.file, + "Glazing", + items=glazing_items, + representation=representation, + part_of_product=self.settings["part_of_product"], + ) + return representation @overload diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_element.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_element.py index 89c0c1be19..f737b54c77 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_element.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_element.py @@ -29,20 +29,14 @@ def connect_element( related_element: ifcopenshell.entity_instance, description: Optional[str] = None, ) -> ifcopenshell.entity_instance: - settings = { - "relating_element": relating_element, - "related_element": related_element, - "description": description, - } - incompatible_connections = [] - for rel in settings["relating_element"].ConnectedFrom: - if rel.is_a() == "IfcRelConnectsElements" and rel.RelatingElement == settings["related_element"]: + for rel in relating_element.ConnectedFrom: + if rel.is_a() == "IfcRelConnectsElements" and rel.RelatingElement == related_element: incompatible_connections.append(rel) - for rel in settings["related_element"].ConnectedTo: - if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == settings["relating_element"]: + for rel in related_element.ConnectedTo: + if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == relating_element: incompatible_connections.append(rel) if incompatible_connections: @@ -52,15 +46,15 @@ def connect_element( if history: ifcopenshell.util.element.remove_deep2(file, history) - for rel in settings["relating_element"].ConnectedTo: - if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == settings["related_element"]: - rel.Description = settings["description"] + for rel in relating_element.ConnectedTo: + if rel.is_a() == "IfcRelConnectsElements" and rel.RelatedElement == related_element: + rel.Description = description return rel return file.createIfcRelConnectsElements( ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), - Description=settings["description"], - RelatingElement=settings["relating_element"], - RelatedElement=settings["related_element"], + Description=description, + RelatingElement=relating_element, + RelatedElement=related_element, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py index 571c5212ba..81fa276a2c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py @@ -31,53 +31,33 @@ def connect_path( related_connection: str = "NOTDEFINED", description: Optional[str] = None, ) -> ifcopenshell.entity_instance: - settings = { - "relating_element": relating_element, - "related_element": related_element, - "relating_connection": relating_connection, - "related_connection": related_connection, - "description": description, - } - - incompatible_connections = [] - for rel in settings["relating_element"].ConnectedTo: + incompatible_connections: list[ifcopenshell.entity_instance] = [] + for rel in relating_element.ConnectedTo: if not rel.is_a("IfcRelConnectsPathElements"): continue - if rel.RelatedElement == settings["related_element"]: + if rel.RelatedElement == related_element: incompatible_connections.append(rel) - elif ( - rel.RelatingConnectionType in ["ATSTART", "ATEND"] - and rel.RelatingConnectionType == settings["relating_connection"] - ): + elif rel.RelatingConnectionType in ["ATSTART", "ATEND"] and rel.RelatingConnectionType == relating_connection: incompatible_connections.append(rel) - for rel in settings["relating_element"].ConnectedFrom: + for rel in relating_element.ConnectedFrom: if not rel.is_a("IfcRelConnectsPathElements"): continue - if ( - rel.RelatedConnectionType in ["ATSTART", "ATEND"] - and rel.RelatedConnectionType == settings["relating_connection"] - ): + if rel.RelatedConnectionType in ["ATSTART", "ATEND"] and rel.RelatedConnectionType == relating_connection: incompatible_connections.append(rel) - for rel in settings["related_element"].ConnectedFrom: + for rel in related_element.ConnectedFrom: if not rel.is_a("IfcRelConnectsPathElements"): continue - if ( - rel.RelatedConnectionType in ["ATSTART", "ATEND"] - and rel.RelatedConnectionType == settings["related_connection"] - ): + if rel.RelatedConnectionType in ["ATSTART", "ATEND"] and rel.RelatedConnectionType == related_connection: incompatible_connections.append(rel) - for rel in settings["related_element"].ConnectedTo: + for rel in related_element.ConnectedTo: if not rel.is_a("IfcRelConnectsPathElements"): continue - if rel.RelatedElement == settings["relating_element"]: + if rel.RelatedElement == relating_element: incompatible_connections.append(rel) - elif ( - rel.RelatingConnectionType in ["ATSTART", "ATEND"] - and rel.RelatingConnectionType == settings["related_connection"] - ): + elif rel.RelatingConnectionType in ["ATSTART", "ATEND"] and rel.RelatingConnectionType == related_connection: incompatible_connections.append(rel) if incompatible_connections: @@ -87,14 +67,15 @@ def connect_path( if history: ifcopenshell.util.element.remove_deep2(file, history) - return file.createIfcRelConnectsPathElements( + return file.create_entity( + "IfcRelConnectsPathElements", ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), - Description=settings["description"], - RelatingElement=settings["relating_element"], - RelatedElement=settings["related_element"], - RelatingConnectionType=settings["relating_connection"], - RelatedConnectionType=settings["related_connection"], + Description=description, + RelatingElement=relating_element, + RelatedElement=related_element, + RelatingConnectionType=relating_connection, + RelatedConnectionType=related_connection, RelatingPriorities=[], RelatedPriorities=[], ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_wall.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_wall.py new file mode 100644 index 0000000000..a84d70fff8 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_wall.py @@ -0,0 +1,63 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Dion Moult +# +# 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 numpy as np +import ifcopenshell +import ifcopenshell.api.owner +import ifcopenshell.guid +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation +from typing import Optional + + +def connect_wall( + file: ifcopenshell.file, + wall1: ifcopenshell.entity_instance, + wall2: ifcopenshell.entity_instance, + is_atpath: bool = False, +) -> Optional[ifcopenshell.entity_instance]: + matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement)) + matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement) + axis1 = ifcopenshell.util.representation.get_reference_line(wall1) + axis2 = ifcopenshell.util.representation.get_reference_line(wall2) + axis2[0] = (matrix1i @ matrix2 @ np.concatenate((axis2[0], (0, 1))))[:2] + axis2[1] = (matrix1i @ matrix2 @ np.concatenate((axis2[1], (0, 1))))[:2] + midx = (axis1[0][0] + axis1[1][0]) / 2 + starty = axis2[0][1] + endy = axis2[1][1] + y = axis1[0][1] + + if (x := ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y)) is None: + return + + wall1_end = "ATEND" if x > midx else "ATSTART" + if is_atpath: + wall2_end = "ATPATH" + elif abs(y - starty) < abs(y - endy): + wall2_end = "ATSTART" + else: + wall2_end = "ATEND" + + return ifcopenshell.api.geometry.connect_path( + file, + relating_element=wall1, + related_element=wall2, + relating_connection=wall1_end, + related_connection=wall2_end, + ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py index f5a52f13a4..78e95c32c8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py @@ -34,6 +34,27 @@ def edit_object_placement( is_si: bool = True, should_transform_children: bool = False, ) -> ifcopenshell.entity_instance: + """Changes the object placement matrix of an element + + The placement matrix is a 4x4 matrix describing the location and + orientation of an element in 3D. See + https://docs.ifcopenshell.org/ifcopenshell-python/geometry_creation.html#object-placements + for more details. + + This only supports local placements. Grid and linear placements are not + supported. + + :param matrix: A 4x4 matrix in numpy. If left blank, it is the identity + matrix (equivalent to ``np.eye(4)``). + :param is_si: If True, the matrix is given in SI units. If false, in + project units. + :param should_transform_children: A child element is a nested element, + opening, filling, etc. If true, child elements will move along with the + parent. If false, child elements will stay where they are. Because most + placements in IFC are relative, this means that if a child moves, we + actually don't change their placement. + :return: The new or updated IfcLocalPlacement entity + """ usecase = Usecase() usecase.file = file usecase.settings = { @@ -134,7 +155,7 @@ class Usecase: elif obj.is_a("IfcFeatureElement"): # Feature elements affect the geometry of their parent, and # so logically should always move with the parent. However, - # subchildren shouldn't move. + # subchildren (fillings) shouldn't move. placement2 = obj.ObjectPlacement for referenced_placement2 in placement2.ReferencedByPlacements: matrix2 = ifcopenshell.util.placement.get_local_placement(referenced_placement2) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/map_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/map_representation.py index b559a66d5f..e868beea66 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/map_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/map_representation.py @@ -17,7 +17,6 @@ # along with IfcOpenShell. If not, see . import ifcopenshell -from typing import Any def map_representation( @@ -25,15 +24,14 @@ def map_representation( ) -> ifcopenshell.entity_instance: usecase = Usecase() usecase.file = file - usecase.settings = {"representation": representation} - return usecase.execute() + return usecase.execute(representation) class Usecase: file: ifcopenshell.file - settings: dict[str, Any] - def execute(self) -> ifcopenshell.entity_instance: + def execute(self, representation: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + self.representation = representation mapping_source = self.get_mapping_source() zero = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) @@ -46,15 +44,15 @@ class Usecase: return self.file.create_entity( "IfcShapeRepresentation", **{ - "ContextOfItems": self.settings["representation"].ContextOfItems, - "RepresentationIdentifier": self.settings["representation"].RepresentationIdentifier, + "ContextOfItems": representation.ContextOfItems, + "RepresentationIdentifier": representation.RepresentationIdentifier, "RepresentationType": "MappedRepresentation", "Items": [mapped_item], } ) def get_mapping_source(self) -> ifcopenshell.entity_instance: - for inverse in self.file.get_inverse(self.settings["representation"]): + for inverse in self.file.get_inverse(self.representation): if inverse.is_a("IfcRepresentationMap"): return inverse zero = self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) @@ -62,5 +60,5 @@ class Usecase: z_axis = self.file.createIfcDirection((0.0, 0.0, 1.0)) mapping_origin = self.file.createIfcAxis2Placement3D(zero, z_axis, x_axis) return self.file.createIfcRepresentationMap( - MappingOrigin=mapping_origin, MappedRepresentation=self.settings["representation"] + MappingOrigin=mapping_origin, MappedRepresentation=self.representation ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py new file mode 100644 index 0000000000..ae2a885d36 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py @@ -0,0 +1,638 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Dion Moult +# +# 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 json +import numpy as np +import ifcopenshell +import ifcopenshell.api.geometry +import ifcopenshell.util.unit +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.shape_builder +from collections import namedtuple +from math import sin, cos +from typing import Optional + +# https://stackoverflow.com/a/9184560/9627415 +# Possible optimisation to linalg.norm? + +PrioritisedLayer = namedtuple("PrioritisedLayer", "priority thickness") + + +def regenerate_wall_representation( + file: ifcopenshell.file, + wall: ifcopenshell.entity_instance, + length: float = 1.0, + height: float = 1.0, + angle: Optional[float] = None, +) -> ifcopenshell.entity_instance: + """ + Regenerate the body representation of a wall taking into account connections. + + IFC defines how a standard (case) wall should behave that has a material + layer set and connections to other walls using IfcRelConnectsPathElements. + This function will regenerate the body geometry of a wall taking into + account the notches, butts, mitres, etc in the wall due to connections with + other walls. + + A standard wall has a 2D axis line as well as parameters defined in terms + of layer thicknesses and priorities. The body geometry is defined as a 2D + XY profile which is extruded in the +Z direction. For this function to + work, a wall must have these defined and the project must have an axis and + body representation context. + + For non-sloped walls, a 2D profile is generated and extruded in the +Z + direction. The profile may be a composite profile, if the wall is split due + to wall joins along the path of the wall that protrude all the way through + the wall. + + For sloped walls, a basic rectangular 2D profile is extruded, and then + additional extrusions are generated for each connection that boolean + difference the base extrusion. + + This will also update the axis line representation (e.g. trim the axis line + to any connections). + + The wall's object placement will also be updated such that the placement is + equivalent to the axis line's start point (which therefore becomes (0.0, + 0.0)). This is a logical, consistent, and useful placement coordinate + (especially for apps that can pivot using this point). + + All this functionality relies on the Plan/Axis/GRAPH_VIEW representation + context. It will be created if it does not exist. + + :param wall: The IfcWall for the representation, + only Model/Body/MODEL_VIEW type of representations are currently supported. + :param length: If the wall doesn't have an axis length, this is the default + length in SI units. + :param height: If the wall doesn't already have a height, this is the + default height in SI units. + :param angle: If the wall doesn't already have a slope, this is the default + angle in radians. Left as none or 0 defines no slope. + :return: The newly generated body IfcShapeRepresentation + """ + return Regenerator(file).regenerate(wall, length=length, height=height, angle=angle) + + +class Regenerator: + def __init__(self, file): + self.file = file + self.body = ifcopenshell.util.representation.get_context(file, "Model", "Body", "MODEL_VIEW") + self.axis = ifcopenshell.util.representation.get_context(file, "Plan", "Axis", "GRAPH_VIEW") + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + self.is_angled = False + + if not self.axis: + if not (plan := ifcopenshell.util.representation.get_context(file, "Plan")): + plan = ifcopenshell.api.context.add_context(file, context_type="Plan") + self.axis = ifcopenshell.api.context.add_context( + file, context_type="Plan", context_identifier="Axis", target_view="GRAPH_VIEW", parent=plan + ) + + def regenerate(self, wall, length=1.0, height=1.0, angle=None): + print("-" * 100) + print(wall) + self.fallback_length = length / self.unit_scale + self.fallback_height = height / self.unit_scale + self.fallback_angle = angle + layers = self.get_layers(wall) + if not layers: + return + reference = ifcopenshell.util.representation.get_reference_line(wall, self.fallback_length) + self.reference_p1, self.reference_p2 = reference + self.wall_vectors = self.get_wall_vectors(wall) + axes = self.get_axes(wall, reference, layers, self.wall_vectors["a"]) + self.miny = axes[0][0][1] + self.maxy = axes[-1][0][1] + self.end_point = None + self.start_points = [] + self.start_vector = np.array((0.0, 0.0, 1.0)) + self.start_offset = 0.0 + self.atpath_points = [] + self.split_points = [] + self.maxpath_points = [] + self.minpath_points = [] + self.end_points = [] + self.end_vector = np.array((0.0, 0.0, 1.0)) + self.end_offset = 0.0 + manual_booleans = self.get_manual_booleans(wall) + + for rel in wall.ConnectedTo: + if rel.is_a("IfcRelConnectsPathElements"): + wall2 = rel.RelatedElement + layers1 = self.combine_layers(layers.copy(), rel.RelatingPriorities) + layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatedPriorities) + if not layers1 or not layers2: + continue + self.join(wall, wall2, layers1, layers2, rel.RelatingConnectionType, rel.RelatedConnectionType) + + for rel in wall.ConnectedFrom: + if rel.is_a("IfcRelConnectsPathElements"): + wall2 = rel.RelatingElement + layers1 = self.combine_layers(layers.copy(), rel.RelatedPriorities) + layers2 = self.combine_layers(self.get_layers(wall2), rel.RelatingPriorities) + if not layers1 or not layers2: + continue + self.join(wall, wall2, layers1, layers2, rel.RelatedConnectionType, rel.RelatingConnectionType) + + if not self.start_points: + minx = axes[0][0][0] + self.start_points = [ + np.array((minx, axes[0][0][1])), + np.array((minx, axes[-1][0][1])), + ] + if not self.end_points: + maxx = axes[0][1][0] + self.end_points = [ + np.array((maxx, axes[0][0][1])), + np.array((maxx, axes[-1][0][1])), + ] + print("FINISHED") + print(self.start_points) + print(self.end_points) + + if self.start_points[0][1] > self.start_points[-1][1]: # Canonicalise to the +Y direction + self.start_points.reverse() + if self.end_points[0][1] > self.end_points[-1][1]: # Canonicalise to the +Y direction + self.end_points.reverse() + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + + # Don't offset wall if there are manual booleans, because that'll also shift operands + offset = None if manual_booleans else self.reference_p1 * -1 + + if self.is_angled: + start_points = [p.copy() for p in self.start_points] + end_points = [p.copy() for p in self.end_points] + if self.end_offset > 0: + for point in end_points: + point[0] += self.end_offset + if self.start_offset < 0: + for point in start_points: + point[0] += self.start_offset + points = [] + points.extend(start_points) + end_points.reverse() + points.extend(end_points) + item = builder.extrude( + builder.polyline(points, closed=True, position_offset=offset), + magnitude=self.wall_vectors["d"], + extrusion_vector=self.wall_vectors["z"], + ) + + operands = [] + if not np.allclose(self.start_vector, np.array((0.0, 0.0, 1.0))): + points = self.start_points.copy() + while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): + points.pop(0) + while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): + points.pop() + newx = min([p[0] for p in points]) - abs(self.start_offset) + p1 = points[-1].copy() + p1[0] = newx + p2 = p1.copy() + p2[1] = points[0][1] + points.extend((p1, p2)) + magnitude = np.linalg.norm(self.start_vector * (self.wall_vectors["h"] / self.start_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True, position_offset=offset), + magnitude=magnitude, + extrusion_vector=self.start_vector, + ) + ) + + if not np.allclose(self.end_vector, np.array((0.0, 0.0, 1.0))): + points = self.end_points.copy() + while ifcopenshell.util.shape_builder.is_x(points[0][1], points[1][1]): + points.pop(0) + while ifcopenshell.util.shape_builder.is_x(points[-1][1], points[-2][1]): + points.pop() + + newx = max([p[0] for p in points]) + abs(self.end_offset) + p1 = points[-1].copy() + p1[0] = newx + p2 = p1.copy() + p2[1] = points[0][1] + points.extend((p1, p2)) + magnitude = np.linalg.norm(self.end_vector * (self.wall_vectors["h"] / self.end_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True, position_offset=offset), + magnitude=magnitude, + extrusion_vector=self.end_vector, + ) + ) + + for atpath_vector, points in self.atpath_points: + if len(points) <= 2: + continue + magnitude = np.linalg.norm(atpath_vector * (self.wall_vectors["h"] / atpath_vector[2])) + operands.append( + builder.extrude( + builder.polyline(points, closed=True, position_offset=offset), + magnitude=magnitude, + extrusion_vector=atpath_vector, + ) + ) + + if operands: + item = ifcopenshell.api.geometry.add_boolean(self.file, first_item=item, second_items=operands)[-1] + else: + # A wall footprint may be multiple profiles if the wall is split into two due to an ATPATH connection + profiles = [] + minx = max([p[0] for p in self.start_points]) + maxx = min([p[0] for p in self.end_points]) + split_points = [] + for points in sorted(self.split_points, key=lambda x: x[0][0]): # Sort islands in the +X direction + if any([p[0] > maxx or p[0] < minx for p in points]): # Can't have anything outside our start/end + continue + split_points.append(points) + start_points = [p.copy() for p in self.start_points] + end_points = [p.copy() for p in self.end_points] + split_points.insert(0, start_points) + split_points.append(end_points) + split_points = iter(split_points) + + while True: + # Draw each profile as clockwise starting from (minx, miny) + start_split = next(split_points, None) + if not start_split: + break + end_split = next(split_points, None) + if not end_split: + break + maxy_minx = start_split[-1][0] + maxy_maxx = end_split[-1][0] + miny_minx = start_split[0][0] + miny_maxx = end_split[0][0] + # Do more defensive checks here? + points = start_split + + remaining_path_points = [] + for maxpath_points in self.maxpath_points: + if maxpath_points[0][0] > maxy_minx and maxpath_points[-1][0] < maxy_maxx: + points.extend(maxpath_points) + else: + remaining_path_points.append(maxpath_points) + self.maxpath_points = remaining_path_points + + points.extend(end_split[::-1]) + + remaining_path_points = [] + for minpath_points in self.minpath_points: + if minpath_points[0][0] < miny_maxx and minpath_points[-1][0] > miny_minx: + points.extend(minpath_points) + else: + remaining_path_points.append(minpath_points) + self.minpath_points = remaining_path_points + + profiles.append(builder.profile(builder.polyline(points, closed=True, position_offset=offset))) + + for points in self.maxpath_points + self.minpath_points: + profiles.append(builder.profile(builder.polyline(points, closed=True, position_offset=offset))) + + if len(profiles) > 1: + profile = self.file.createIfcCompositeProfileDef("AREA", Profiles=profiles) + else: + profile = profiles[0] + + item = builder.extrude(profile, magnitude=self.wall_vectors["d"], extrusion_vector=self.wall_vectors["z"]) + for boolean in self.get_manual_booleans(wall): + boolean.FirstOperand = item + item = boolean + + body_rep = builder.get_representation(self.body, items=[item]) + if old_rep := ifcopenshell.util.representation.get_representation(wall, self.body): + ifcopenshell.util.element.replace_element(old_rep, body_rep) + ifcopenshell.util.element.remove_deep2(self.file, old_rep) + else: + ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=body_rep) + + item = builder.polyline([self.reference_p1, self.reference_p2], position_offset=offset) + axis_rep = builder.get_representation(self.axis, items=[item]) + if old_rep := ifcopenshell.util.representation.get_representation(wall, self.axis): + ifcopenshell.util.element.replace_element(old_rep, axis_rep) + ifcopenshell.util.element.remove_deep2(self.file, old_rep) + else: + ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=axis_rep) + + if not np.allclose(self.reference_p1, np.array((0.0, 0.0))) and not manual_booleans: + children = [] + for referenced_placement in wall.ObjectPlacement.ReferencedByPlacements: + matrix = ifcopenshell.util.placement.get_local_placement(referenced_placement) + children.append((matrix, referenced_placement.PlacesObject)) + + matrix = ifcopenshell.util.placement.get_local_placement(wall.ObjectPlacement) + matrix[:, 3] = matrix @ np.concatenate((self.reference_p1, (0, 1))) + ifcopenshell.api.geometry.edit_object_placement( + self.file, product=wall, matrix=matrix, is_si=False, should_transform_children=True + ) + + # Restore children to their previous location + for matrix, elements in children: + for element in elements: + ifcopenshell.api.geometry.edit_object_placement( + self.file, product=element, matrix=matrix, is_si=False, should_transform_children=True + ) + + return body_rep + + def join(self, wall1, wall2, layers1, layers2, connection1, connection2): + if connection1 == "NOTDEFINED" or connection2 == "NOTDEFINED": + return + if connection1 == "ATPATH" and connection2 == "ATPATH": + return + print("joining", wall1, layers1, connection1) + print("to", wall2, layers2, connection2) + + reference1 = ifcopenshell.util.representation.get_reference_line(wall1, self.fallback_length) + reference2 = ifcopenshell.util.representation.get_reference_line(wall2, self.fallback_length) + wall_vectors2 = self.get_wall_vectors(wall2) + axes1 = self.get_axes(wall1, reference1, layers1, self.wall_vectors["a"]) + axes2 = self.get_axes(wall2, reference2, layers2, wall_vectors2["a"]) + matrix1i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(wall1.ObjectPlacement)) + matrix2 = ifcopenshell.util.placement.get_local_placement(wall2.ObjectPlacement) + print(axes1) + print(axes2) + + # Convert wall2 data to wall1 local coordinates + for axis in axes2: + axis[0] = (matrix1i @ matrix2 @ np.concatenate((axis[0], (0, 1))))[:2] + axis[1] = (matrix1i @ matrix2 @ np.concatenate((axis[1], (0, 1))))[:2] + reference2[0] = (matrix1i @ matrix2 @ np.concatenate((reference2[0], (0, 1))))[:2] + reference2[1] = (matrix1i @ matrix2 @ np.concatenate((reference2[1], (0, 1))))[:2] + wall_vectors2["z"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["z"], 0.0))[:3] + wall_vectors2["y"] = (matrix1i @ matrix2 @ np.append(wall_vectors2["y"], 0.0))[:3] + + axis2 = axes2[0] # Take an arbitrary axis of wall2 + if ifcopenshell.util.shape_builder.is_x(axis2[0][1], axis2[1][1]): + return # Parallel + + # Sort axes from interior to exterior + if connection1 == "ATEND": + if axes2[0][0][0] > axes2[-1][0][0]: # We process layers in a +X direction + axes2 = list(reversed(axes2)) + layers2 = list(reversed(layers2)) + elif connection1 == "ATSTART": + if axes2[-1][0][0] > axes2[0][0][0]: # We process layers in a -X direction + axes2 = list(reversed(axes2)) + layers2 = list(reversed(layers2)) + + axis2 = axes2[0] # Take an arbitrary axis of wall2 + if connection2 == "ATSTART": + axis2 = [axis2[1], axis2[0]] # Flip direction so the axis "points" in the direction of join + if axis2[0][1] < axis2[1][1]: # Pointing +Y + if axes1[-1][0][1] < axes1[0][0][1]: # We process layers1 in a +Y direction + axes1 = list(reversed(axes1)) + layers1 = list(reversed(layers1)) + else: # Pointing -Y + if axes1[0][0][1] < axes1[-1][0][1]: # We process layers1 in a -Y direction + axes1 = list(reversed(axes1)) + layers1 = list(reversed(layers1)) + + print("modified") + print(axes1) + print(axes2) + if connection1 == "ATPATH": + first_axis2 = axes2[0] + last_axis2 = axes2[-1] + first_y = axes1[0][0][1] + last_y = axes1[-1][0][1] + p0 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*first_axis2, y=first_y), first_y)) + pN = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=first_y), first_y)) + + # Generate CurveOnRelating/RelatedElement + points = [p0] + axes2 = iter(axes2) + axis2 = next(axes2) + for layer2 in layers2: + ys = iter([a[0][1] for a in axes1]) + y = next(ys) + for layer1 in layers1: + if layer2.priority <= layer1.priority: + break + y = next(ys) + p1 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y)) + axis2 = next(axes2) + p2 = np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y), y)) + if points and np.allclose(points[-1], p1): + points[-1] = p2 # Just slide along previous point + else: + points.extend((p1, p2)) + + # The curve must end at pN + if not np.allclose(points[-1], pN): + points.append(pN) + + # Categorise our points into a segment that either splits or cuts the wall + split_ys = {first_y, last_y} + segment = [] + atpath_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.atpath_points.append((atpath_vector, points)) + for point in points: + segment.append(point) + if len(segment) == 1: # Not enough points to categorise the segment + continue + elif {segment[0][1], segment[-1][1]} == split_ys: # This segment splits the wall + if segment[0][1] > segment[-1][1]: # Go in the +Y direction + segment.reverse() + self.split_points.append(segment) + segment = [] + elif segment[0][1] == segment[-1][1]: # This segment cuts some of the wall + if segment[0][1] == self.maxy: # Go in the +X direction + if segment[0][0] > segment[-1][0]: + segment.reverse() + self.maxpath_points.append(segment) + elif segment[0][1] == self.miny: # Go in the -X direction + if segment[-1][0] > segment[0][0]: + segment.reverse() + self.minpath_points.append(segment) + segment = [] + elif connection2 == "ATPATH": + points = [] + ys = iter([a[0][1] for a in axes1]) + y = next(ys) + for layer1 in layers1: + axes2_iter = iter(axes2) + axis2 = next(axes2_iter) + for layer2 in layers2: + if layer1.priority <= layer2.priority: + break + axis2 = next(axes2_iter) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + p1 = np.array((x, y)) + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + p2 = np.array((x, y)) + if points and np.allclose(points[-1], p1): + points.append(p2) + else: + points.extend((p1, p2)) + + if connection1 == "ATSTART": + self.start_points = points + self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] + self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + elif connection1 == "ATEND": + self.end_points = points + self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] + self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + else: # A connection at either end of both walls + last_y = axes1[-1][0][1] + ys = iter([a[0][1] for a in axes1]) + + last_axis2 = axes2[-1] + axes2 = iter(axes2) + axis2 = next(axes2) + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + points = [np.array((x, y))] + + layers1 = iter(layers1) + layers2 = iter(layers2) + layer1 = next(layers1, None) + layer2 = next(layers2, None) + + # This creates "mitering" behaviour which is an ambiguity by bSI. + while layer1 and layer2: + print("considering", layer1, layer2) + if layer1.priority > layer2.priority: + axis2 = next(axes2) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + layer2 = next(layers2, None) + elif layer2.priority > layer1.priority: + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*axis2, y=y) + layer1 = next(layers1, None) + else: + y = next(ys) + x = ifcopenshell.util.shape_builder.intersect_x_axis_2d(*next(axes2), y=y) + layer1 = next(layers1, None) + layer2 = next(layers2, None) + points.append(np.array((x, y))) + + print("points", points) + if points[-1][1] != last_y: + points.append( + np.array((ifcopenshell.util.shape_builder.intersect_x_axis_2d(*last_axis2, y=last_y), last_y)) + ) + + if connection1 == "ATSTART": + self.start_points = points + self.start_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.start_offset = (self.start_vector * (self.wall_vectors["h"] / self.start_vector[2]))[0] + self.reference_p1[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + elif connection1 == "ATEND": + self.end_points = points + self.end_vector = self.get_join_vector(self.wall_vectors["y"], wall_vectors2["y"]) + self.end_offset = (self.end_vector * (self.wall_vectors["h"] / self.end_vector[2]))[0] + self.reference_p2[0] = ifcopenshell.util.shape_builder.intersect_x_axis_2d( + *reference2, y=reference1[0][1] + ) + + def get_layers(self, wall) -> list: + material = ifcopenshell.util.element.get_material(wall, should_skip_usage=True) + if not material or not material.is_a("IfcMaterialLayerSet"): + return [] + return [PrioritisedLayer(getattr(l, "Priority", 0) or 0, l.LayerThickness) for l in material.MaterialLayers] + + def combine_layers(self, layers, override_priorities): + results = [] + if override_priorities: + for i, priority in enumerate(override_priorities[: len(layers)]): + layers[i][0] = priority + if not layers: + return [] + results = [layers.pop(0)] + for layer in layers: + if not layer.thickness: + continue + if layer.priority == results[-1].priority: + results[-1] = PrioritisedLayer(layer.priority, results[-1].thickness + layer.thickness) + else: + results.append(layer) + return results + + def get_wall_vectors(self, wall): + if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(body).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + z = np.array(item.ExtrudedDirection.DirectionRatios) + z /= np.linalg.norm(z) + y = np.cross(z, np.array((1.0, 0.0, 0.0))) + d = item.Depth + h = (z * d)[2] + a = ifcopenshell.util.shape_builder.np_angle_signed(np.array((0.0, 1.0)), z[1:]) + if not ifcopenshell.util.shape_builder.is_x(a, 0): + self.is_angled = True + return {"z": z, "y": y, "a": a, "d": d, "h": h} + elif self.fallback_angle: + a = self.fallback_angle + z = np.array([0.0, sin(a), cos(a)]) + y = np.cross(z, np.array((1.0, 0.0, 0.0))) + h = self.fallback_height + d = np.linalg.norm(z * (h / z[2])) + if not ifcopenshell.util.shape_builder.is_x(a, 0): + self.is_angled = True + return {"z": z, "y": y, "a": a, "d": d, "h": h} + return { + "z": np.array((0.0, 0.0, 1.0)), + "y": np.array((0.0, 1.0, 0.0)), + "a": 0.0, + "d": self.fallback_height, + "h": self.fallback_height, + } + + def get_join_vector(self, y1, y2): + result = np.cross(y1, y2) + if result[2] < 0: + return result * -1 + return result + + def get_axes(self, wall: ifcopenshell.entity_instance, reference, layers: list[PrioritisedLayer], angle: float): + axes = [[p.copy() for p in reference]] + # Apply usage to convert the Reference line into MlsBase + sense_factor = 1 + if (usage := ifcopenshell.util.element.get_material(wall)) and usage.is_a("IfcMaterialLayerSetUsage"): + for point in axes[0]: + point[1] += usage.OffsetFromReferenceLine + sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1 + + for layer in layers: + y_offset = (layer.thickness * sense_factor) / cos(angle) + axes.append([p.copy() + np.array((0.0, y_offset)) for p in axes[-1]]) + return axes + + def get_manual_booleans(self, element: ifcopenshell.entity_instance): + if pset := ifcopenshell.util.element.get_pset(element, "BBIM_Boolean"): + try: + return [self.file.by_id(boolean_id) for boolean_id in json.loads(pset["Data"])] + except: + return [] + return [] diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_representation.py index e8cd98aefc..0618b407ec 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/remove_representation.py @@ -37,8 +37,10 @@ def remove_representation( :param should_keep_named_profiles: If true, named profile defs will not be removed as they are assumed to be significant. """ + is_ifc2x3 = file.schema == "IFC2X3" styled_items = set() - presentation_layer_assignments = set() + presentation_layer_assignments_items: set[ifcopenshell.entity_instance] = set() + presentation_layer_assignments_reps: set[ifcopenshell.entity_instance] = set() textures = set() colours = set() named_profiles = set() @@ -46,16 +48,14 @@ def remove_representation( if subelement.is_a("IfcRepresentationItem"): [styled_items.add(s) for s in subelement.StyledByItem or []] # IFC2X3 is using LayerAssignments - for s in ( - subelement.LayerAssignment if hasattr(subelement, "LayerAssignment") else subelement.LayerAssignments - ): - presentation_layer_assignments.add(s) + for s in subelement.LayerAssignment if not is_ifc2x3 else subelement.LayerAssignments: + presentation_layer_assignments_items.add(s) # IfcTessellatedFaceSet inverses [textures.add(t) for t in getattr(subelement, "HasTextures", []) or []] [colours.add(t) for t in getattr(subelement, "HasColours", []) or []] elif subelement.is_a("IfcRepresentation"): for layer in subelement.LayerAssignments: - presentation_layer_assignments.add(layer) + presentation_layer_assignments_reps.add(layer) elif subelement.is_a("IfcProfileDef") and subelement.ProfileName: named_profiles.add(subelement) @@ -63,11 +63,16 @@ def remove_representation( if should_keep_named_profiles: do_not_delete += named_profiles + # Order matters - layer assignments may reference representation directly. + also_consider = list(presentation_layer_assignments_reps) + also_consider.extend(presentation_layer_assignments_items - presentation_layer_assignments_reps) + also_consider.extend(styled_items) + also_consider.extend(textures) ifcopenshell.util.element.remove_deep2( file, representation, - also_consider=list(styled_items | presentation_layer_assignments | colours), - do_not_delete=do_not_delete, + also_consider=also_consider, + do_not_delete=set(do_not_delete), ) for texture in textures: @@ -77,8 +82,10 @@ def remove_representation( to_delete = file.to_delete or set() for element in styled_items: - if not element.Item or element.Item in to_delete: + item = element.Item + if not item or item in to_delete: file.remove(element) + presentation_layer_assignments = presentation_layer_assignments_reps | presentation_layer_assignments_items for element in presentation_layer_assignments: if all(item in to_delete for item in element.AssignedItems): file.remove(element) diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py index a733c639cf..0d4da93bcb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/edit_georeferencing.py @@ -18,6 +18,7 @@ import ifcopenshell import ifcopenshell.api.pset +import ifcopenshell.util.element from typing import Optional, Any @@ -33,7 +34,7 @@ def edit_georeferencing( surveyor, and a third-party digital engineer with expertise in IFC to moderate. For more information, read the Bonsai documentation for Georeferencing: - https://docs.bonsaibim.org/guides/advanced/georeferencing.html + https://docs.bonsaibim.org/guides/authoring/georeferencing.html For more information about the attributes and data types of an IfcCoordinateOperation, consult the IFC documentation. @@ -46,6 +47,8 @@ def edit_georeferencing( :param coordinate_operation: The dictionary of attribute names and values you want to edit. + 'MapUnit' attribute in IFC2X3 should be presented as a full unit name (string), + in other IFC versions it's presented an IfcNamedUnit. :param projected_crs: The IfcProjectedCRS dictionary of attribute names and values you want to edit. diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/remove_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/remove_georeferencing.py index 63d46b840b..3cbcae5d60 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/remove_georeferencing.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/remove_georeferencing.py @@ -43,7 +43,7 @@ def remove_georeferencing(file: ifcopenshell.file) -> None: ifcopenshell.api.pset.remove_pset(file, project, file.by_id(pset["id"])) return for projected_crs in file.by_type("IfcProjectedCRS"): - if (unit := projected_crs.MapUnit) and len(file.get_inverse(unit)) == 1: + if (unit := projected_crs.MapUnit) and file.get_total_inverses(unit) == 1: projected_crs.MapUnit = None ifcopenshell.util.element.remove_deep2(file, unit) file.remove(projected_crs) diff --git a/src/ifcopenshell-python/ifcopenshell/api/grid/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/grid/__init__.py index 5cd7199e63..cc759ce7e0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/__init__.py @@ -23,10 +23,7 @@ A grid in IFC may contain two or more axes running in two or more directions. from .. import wrap_usecases -try: - from .create_axis_curve import create_axis_curve -except ModuleNotFoundError as e: - print(f"Note: API not available due to missing dependencies: grid.create_axis_curve - {e}") +from .create_axis_curve import create_axis_curve from .create_grid_axis import create_grid_axis from .remove_grid_axis import remove_grid_axis diff --git a/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py b/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py index 7e3840753b..4e692008e3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/create_axis_curve.py @@ -21,27 +21,27 @@ import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.unit import ifcopenshell.util.placement -from mathutils import Matrix # For now, we depend on Blender -import bpy.types +import numpy as np +from ifcopenshell.util.shape_builder import VectorType, V, ifc_safe_vector_type def create_axis_curve( - file: ifcopenshell.file, axis_curve: bpy.types.Object, grid_axis: ifcopenshell.entity_instance + file: ifcopenshell.file, + *, + p1: VectorType, + p2: VectorType, + grid_axis: ifcopenshell.entity_instance, + is_si: bool = True, ) -> None: """Adds curve geometry to a grid axis to represent the axis extents - This currently depends on the Blender geometry kernel to function. - An IFC grid will have a minimum of two axes (typically perpendicular). Each axis will then have a line which represents the extents of the axis. - :param axis_curve: The Blender object that contains a mesh data block with a - single edge. - :type axis_curve: bpy.types.Object + :param p1: The first point of the grid axis + :param p2: The second point of the grid axis :param grid_axis: The IfcGridAxis element to add geometry to. - :type grid_axis: ifcopenshell.entity_instance - :return: None - :rtype: None + :param is_si: If true, the points are in meters, not project units Example: @@ -54,48 +54,27 @@ def create_axis_curve( axis_1 = ifcopenshell.api.grid.create_grid_axis(model, axis_tag="1", uvw_axes="VAxes", grid=grid) - # Assume you have these Blender objects in your active Blender session - obj1 = bpy.data.objects.get("AxisA") - obj2 = bpy.data.objects.get("Axis1") - ifcopenshell.api.grid.create_axis_curve(model, axis_curve=obj1, grid_axis=axis_a) - ifcopenshell.api.grid.create_axis_curve(model, axis_curve=obj2, grid_axis=axis_1) + # By convention, alphabetic grids are horizontal, and numeric are vertical + ifcopenshell.api.grid.create_axis_curve( + model, p1=np.array((0., 0., 0.)), p2=np.array((10., 0., 0.)), grid_axis=axis_a) + ifcopenshell.api.grid.create_axis_curve( + model, p1=np.array((0., 0., 0.)), p2=np.array((0., 10., 0.)), grid_axis=axis_1) """ - usecase = Usecase() - usecase.file = file - usecase.settings = { - "axis_curve": axis_curve, # A Blender object - "grid_axis": grid_axis, - } - return usecase.execute() + existing_curve = grid_axis.AxisCurve + p1, p2 = V(p1), V(p2) + if is_si: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + p1 /= unit_scale + p2 /= unit_scale - -class Usecase: - def execute(self): - existing_curve = self.settings["grid_axis"].AxisCurve - - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - grid = [i for i in self.file.get_inverse(self.settings["grid_axis"]) if i.is_a("IfcGrid")][0] - grid_matrix_i = Matrix(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)) - grid_matrix_i.translation *= self.settings["unit_scale"] - grid_matrix_i = grid_matrix_i.inverted() - points = [ - grid_matrix_i @ (self.settings["axis_curve"].matrix_world @ v.co) - for v in self.settings["axis_curve"].data.vertices[0:2] - ] - self.settings["grid_axis"].AxisCurve = self.file.createIfcPolyline( - [ - self.create_cartesian_point(points[0][0], points[0][1]), - self.create_cartesian_point(points[1][0], points[1][1]), - ] + grid = [i for i in file.get_inverse(grid_axis) if i.is_a("IfcGrid")][0] + grid_matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)) + grid_axis.AxisCurve = file.createIfcPolyline( + ( + file.createIfcCartesianPoint(ifc_safe_vector_type(grid_matrix_i @ p1)), + file.createIfcCartesianPoint(ifc_safe_vector_type(grid_matrix_i @ p2)), ) + ) - if existing_curve: - ifcopenshell.util.element.remove_deep2(self.file, existing_curve) - - def create_cartesian_point(self, x, y): - x = self.convert_si_to_unit(x) - y = self.convert_si_to_unit(y) - return self.file.createIfcCartesianPoint((x, y)) - - def convert_si_to_unit(self, co): - return co / self.settings["unit_scale"] + if existing_curve: + ifcopenshell.util.element.remove_deep2(file, existing_curve) diff --git a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py index 8239ab9614..652fc89f56 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py @@ -23,9 +23,7 @@ def remove_grid_axis(file: ifcopenshell.file, axis: ifcopenshell.entity_instance """Removes a grid axis from a grid :param axis: The IfcGridAxis you want to remove. - :type axis: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -44,7 +42,7 @@ def remove_grid_axis(file: ifcopenshell.file, axis: ifcopenshell.entity_instance ifcopenshell.api.grid.remove_grid_axis(model, axis=axis_2) """ axis_curve = axis.AxisCurve - if len(file.get_inverse(axis_curve)) == 1: + if file.get_total_inverses(axis_curve) == 1: ifcopenshell.util.element.remove_deep(file, axis_curve) file.remove(axis_curve) file.remove(axis) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py index eb40127209..a2a877ef6e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/add_group.py @@ -35,12 +35,9 @@ def add_group( or structural load groups, which group together loads for structural analysis, or inventories, which are groups of assets. - :param Name: The name of the group. Defaults to "Unnamed" - :type Name: str, optional + :param name: The name of the group. Defaults to "Unnamed" :param description: The description of the purpose of the group. - :type description: str, optional :return: The newly created IfcGroup - :rtype: ifcopenshell.entity_instance Example: @@ -48,17 +45,11 @@ def add_group( ifcopenshell.api.group.add_group(model, name="Unit 1A") """ - settings = { - "name": name or "Unnamed", - "description": description, - } return file.create_entity( "IfcGroup", - **{ - "GlobalId": ifcopenshell.guid.new(), - "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "Name": settings["name"], - "Description": settings["description"], - } + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), + Name=name, + Description=description, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py index 64a4f571dc..4a57f148eb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/assign_group.py @@ -31,12 +31,9 @@ def assign_group( twice. :param products: A list of IfcProduct elements to assign to the group - :type products: list[ifcopenshell.entity_instance] :param group: The IfcGroup to assign the products to - :type group: ifcopenshell.entity_instance :return: The IfcRelAssignsToGroup relationship or `None` if `products` was empty list. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -46,29 +43,25 @@ def assign_group( ifcopenshell.api.group.assign_group(model, products=model.by_type("IfcFurniture"), group=group) """ - settings = { - "products": products, - "group": group, - } - - if not settings["products"]: + if not products: return - if not settings["group"].IsGroupedBy: + is_grouped_by: tuple[ifcopenshell.entity_instance, ...] + if not (is_grouped_by := group.IsGroupedBy): return file.create_entity( "IfcRelAssignsToGroup", **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": settings["products"], - "RelatingGroup": settings["group"], - } + "RelatedObjects": products, + "RelatingGroup": group, + }, ) - rel = settings["group"].IsGroupedBy[0] + rel = is_grouped_by[0] related_objects = set(rel.RelatedObjects) or set() - products = set(settings["products"]) - if products.issubset(related_objects): + products_set = set(products) + if products_set.issubset(related_objects): return rel - rel.RelatedObjects = list(related_objects | products) + rel.RelatedObjects = list(related_objects | products_set) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py index a404ef0f88..5a7a2b2018 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/edit_group.py @@ -26,11 +26,8 @@ def edit_group(file: ifcopenshell.file, group: ifcopenshell.entity_instance, att IfcGroup, consult the IFC documentation. :param group: The IfcGroup entity you want to edit - :type group: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -40,7 +37,5 @@ def edit_group(file: ifcopenshell.file, group: ifcopenshell.entity_instance, att ifcopenshell.api.group.edit_group(model, group=group, attributes={"Description": "All furniture and joinery included in the unit"}) """ - settings = {"group": group, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["group"], name, value) + for name, value in attributes.items(): + setattr(group, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/remove_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/remove_group.py index 3f3987f66a..34011eeca9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/remove_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/remove_group.py @@ -39,9 +39,7 @@ def remove_group(file: ifcopenshell.file, group: ifcopenshell.entity_instance) - group = ifcopenshell.api.group.add_group(model, name="Unit 1A") ifcopenshell.api.group.remove_group(model, group=group) """ - settings = {"group": group} - - for inverse_id in [i.id() for i in file.get_inverse(settings["group"])]: + for inverse_id in [i.id() for i in file.get_inverse(group)]: try: inverse = file.by_id(inverse_id) except: @@ -49,11 +47,11 @@ def remove_group(file: ifcopenshell.file, group: ifcopenshell.entity_instance) - if inverse.is_a("IfcRelDefinesByProperties"): ifcopenshell.api.pset.remove_pset( file, - product=settings["group"], + product=group, pset=inverse.RelatingPropertyDefinition, ) elif inverse.is_a("IfcRelAssignsToGroup"): - if inverse.RelatingGroup == settings["group"]: + if inverse.RelatingGroup == group: history = inverse.OwnerHistory file.remove(inverse) if history: @@ -63,7 +61,7 @@ def remove_group(file: ifcopenshell.file, group: ifcopenshell.entity_instance) - file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["group"].OwnerHistory - file.remove(settings["group"]) + history = group.OwnerHistory + file.remove(group) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/unassign_group.py b/src/ifcopenshell-python/ifcopenshell/api/group/unassign_group.py index ee31336eec..cac87106aa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/unassign_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/unassign_group.py @@ -46,17 +46,12 @@ def unassign_group( bad_furniture = furniture[0] ifcopenshell.api.group.unassign_group(model, products=[bad_furniture], group=group) """ - settings = { - "products": products, - "group": group, - } - - if not settings["group"].IsGroupedBy: + if not group.IsGroupedBy: return - rel = settings["group"].IsGroupedBy[0] + rel = group.IsGroupedBy[0] related_objects = set(rel.RelatedObjects) or set() - products = set(settings["products"]) - related_objects -= products + products_set = set(products) + related_objects -= products_set if related_objects: rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py b/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py index 61b3ec7657..468c193ff7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py +++ b/src/ifcopenshell-python/ifcopenshell/api/group/update_group_products.py @@ -45,24 +45,19 @@ def update_group_products( ifcopenshell.api.group.update_group_products(model, products=model.by_type("IfcFurniture"), group=group) """ - settings = { - "group": group, - "products": products, - } - - if not settings["group"].IsGroupedBy: + if not group.IsGroupedBy: return file.create_entity( "IfcRelAssignsToGroup", **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": settings["products"], - "RelatingGroup": settings["group"], + "RelatedObjects": products, + "RelatingGroup": group, } ) else: - rels = settings["group"].IsGroupedBy - objects = set(settings["products"]) + rels = group.IsGroupedBy + objects = set(products) for rel in rels: objects.update([g for g in rel.RelatedObjects if g.is_a("IfcGroup")]) to_purge = rels[1:] diff --git a/src/ifcopenshell-python/ifcopenshell/api/layer/assign_layer.py b/src/ifcopenshell-python/ifcopenshell/api/layer/assign_layer.py index fa57e526cc..57117b6625 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/layer/assign_layer.py +++ b/src/ifcopenshell-python/ifcopenshell/api/layer/assign_layer.py @@ -62,15 +62,10 @@ def assign_layer( # only one item) to the layer. ifcopenshell.api.layer.assign_layer(model, items=[representation.Items[0]], layer=layer) """ - settings = { - "items": items, - "layer": layer, - } - # support AssignedItems == None since layer might just got created - layer = settings["layer"] + assigned_items: set[ifcopenshell.entity_instance] assigned_items = set(layer.AssignedItems or []) - items = set(settings["items"]) - if items.issubset(assigned_items): + items_set = set(items) + if items_set.issubset(assigned_items): return - layer.AssignedItems = list(assigned_items | items) + layer.AssignedItems = list(assigned_items | items_set) diff --git a/src/ifcopenshell-python/ifcopenshell/api/layer/edit_layer.py b/src/ifcopenshell-python/ifcopenshell/api/layer/edit_layer.py index 6f55f5da1d..7203fabb0c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/layer/edit_layer.py +++ b/src/ifcopenshell-python/ifcopenshell/api/layer/edit_layer.py @@ -37,7 +37,5 @@ def edit_layer(file: ifcopenshell.file, layer: ifcopenshell.entity_instance, att ifcopenshell.api.layer.edit_layer(model, layer=layer, attributes={"Description": "All walls, based on the AIA standard."}) """ - settings = {"layer": layer, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["layer"], name, value) + for name, value in attributes.items(): + setattr(layer, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/layer/unassign_layer.py b/src/ifcopenshell-python/ifcopenshell/api/layer/unassign_layer.py index 97ae094a50..3eb0ed8fa1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/layer/unassign_layer.py +++ b/src/ifcopenshell-python/ifcopenshell/api/layer/unassign_layer.py @@ -61,17 +61,11 @@ def unassign_layer( # Let's undo it! ifcopenshell.api.layer.unassign_layer(model, items=[representation.Items[0]], layer=layer) """ - settings = { - "items": items, - "layer": layer, - } - - layer = settings["layer"] assigned_items = set(layer.AssignedItems) or set() - items = set(settings["items"]) - if not items.issubset(assigned_items): + items_set = set(items) + if not items_set.issubset(assigned_items): return - assigned_items = list(assigned_items - items) + assigned_items = list(assigned_items - items_set) # keep IFC valid in case if there are no items left if assigned_items: diff --git a/src/ifcopenshell-python/ifcopenshell/api/library/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/library/add_reference.py index 428e0b01d7..8e138954d5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/library/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/library/add_reference.py @@ -50,14 +50,10 @@ def add_reference(file: ifcopenshell.file, library: ifcopenshell.entity_instance ifcopenshell.api.library.edit_reference(model, reference=reference, attributes={"Identification": "http://example.org/digitaltwin#AHU01"}) """ - settings = { - "library": library, - } - if file.schema == "IFC2X3": reference = file.createIfcLibraryReference() - references = list(settings["library"].LibraryReference or []) + references = list(library.LibraryReference or []) references.append(reference) - settings["library"].LibraryReference = references + library.LibraryReference = references return reference - return file.createIfcLibraryReference(ReferencedLibrary=settings["library"]) + return file.createIfcLibraryReference(ReferencedLibrary=library) diff --git a/src/ifcopenshell-python/ifcopenshell/api/library/assign_reference.py b/src/ifcopenshell-python/ifcopenshell/api/library/assign_reference.py index 40b3a92f56..a9b6169304 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/library/assign_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/library/assign_reference.py @@ -33,14 +33,11 @@ def assign_reference( detail about how references work. :param products: The list of IfcProducts you want to associate with the reference - :type products: list[ifcopenshell.entity_instance] :param reference: The IfcLibraryReference you want the product to be associated with. - :type reference: ifcopenshell.entity_instance :return: The IfcRelAssociatesLibrary relationship entity or `None` if `products` was an empty list or all products were already assigned to the `reference`. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -60,38 +57,33 @@ def assign_reference( # And now assign the IFC model's AHU with its Brickschema counterpart ifcopenshell.api.library.assign_reference(model, reference=reference, products=[ahu]) """ - settings = { - "products": products, - "reference": reference, - } - # TODO: do we need to support non-ifcroot elements like we do in classification.add_reference? - referenced_elements = ifcopenshell.util.element.get_referenced_elements(settings["reference"]) - products: set[ifcopenshell.entity_instance] = set(settings["products"]) - products = products - referenced_elements + referenced_elements = ifcopenshell.util.element.get_referenced_elements(reference) + products_set: set[ifcopenshell.entity_instance] = set(products) + products_set = products_set - referenced_elements - if not products: + if not products_set: return if file.schema == "IFC2X3": rel = next( - (r for r in file.by_type("IfcRelAssociatesLibrary") if r.RelatingLibrary == settings["reference"]), + (r for r in file.by_type("IfcRelAssociatesLibrary") if r.RelatingLibrary == reference), None, ) else: - rel = next(iter(settings["reference"].LibraryRefForObjects), None) + rel = next(iter(reference.LibraryRefForObjects), None) if not rel: return file.create_entity( "IfcRelAssociatesLibrary", GlobalId=ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), - RelatedObjects=list(products), - RelatingLibrary=settings["reference"], + RelatedObjects=list(products_set), + RelatingLibrary=reference, ) - related_objects = set(rel.RelatedObjects) | products + related_objects = set(rel.RelatedObjects) | products_set rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, element=rel) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/library/edit_library.py b/src/ifcopenshell-python/ifcopenshell/api/library/edit_library.py index b3bc87b1c3..4e12195bda 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/library/edit_library.py +++ b/src/ifcopenshell-python/ifcopenshell/api/library/edit_library.py @@ -28,11 +28,8 @@ def edit_library(file: ifcopenshell.file, library: ifcopenshell.entity_instance, IfcLibraryInformation, consult the IFC documentation. :param library: The IfcLibraryInformation entity you want to edit - :type library: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: diff --git a/src/ifcopenshell-python/ifcopenshell/api/library/edit_reference.py b/src/ifcopenshell-python/ifcopenshell/api/library/edit_reference.py index f3016fd9cf..e6acacbd8f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/library/edit_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/library/edit_reference.py @@ -28,11 +28,8 @@ def edit_reference( IfcLibraryReference, consult the IFC documentation. :param reference: The IfcLibraryReference entity you want to edit - :type reference: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -44,7 +41,5 @@ def edit_reference( ifcopenshell.api.library.edit_reference(model, reference=reference, attributes={"Identification": "http://example.org/digitaltwin#AHU01"}) """ - settings = {"reference": reference, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["reference"], name, value) + for name, value in attributes.items(): + setattr(reference, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/library/unassign_reference.py b/src/ifcopenshell-python/ifcopenshell/api/library/unassign_reference.py index 876a9b86e6..d92ed2fc1e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/library/unassign_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/library/unassign_reference.py @@ -31,11 +31,8 @@ def unassign_reference( If the product isn't assigned to the reference, nothing will happen. :param reference: The IfcLibraryReference to unassign from - :type reference: ifcopenshell.entity_instance :param products: A list of IfcProduct elements to unassign from the reference - :type products: list[ifcopenshell.entity_instance] :return: None - :rtype: None Example: @@ -58,24 +55,19 @@ def unassign_reference( # Let's change our mind and unassign it. ifcopenshell.api.library.unassign_reference(model, reference=reference, products=[ahu]) """ - - settings = {"reference": reference, "products": products} - # TODO: do we need to support non-ifcroot elements like we do in classification.add_reference? reference_rels: set[ifcopenshell.entity_instance] = set() - products = set(settings["products"]) - for product in products: + products_set = set(products) + for product in products_set: reference_rels.update(product.HasAssociations) reference_rels = { - rel - for rel in reference_rels - if rel.is_a("IfcRelAssociatesLibrary") and rel.RelatingLibrary == settings["reference"] + rel for rel in reference_rels if rel.is_a("IfcRelAssociatesLibrary") and rel.RelatingLibrary == reference } for rel in reference_rels: - related_objects = set(rel.RelatedObjects) - products + related_objects = set(rel.RelatedObjects) - products_set if related_objects: rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/material/__init__.py index 6e57e28210..f836c5bd4f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/__init__.py @@ -55,6 +55,7 @@ from .remove_material import remove_material from .remove_material_set import remove_material_set from .remove_profile import remove_profile from .reorder_set_item import reorder_set_item +from .set_shape_aspect_constituents import set_shape_aspect_constituents from .unassign_material import unassign_material wrap_usecases(__path__, __name__) @@ -83,5 +84,6 @@ __all__ = [ "remove_material_set", "remove_profile", "reorder_set_item", + "set_shape_aspect_constituents", "unassign_material", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_list_item.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_list_item.py index 944a835493..994d4665ef 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/add_list_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_list_item.py @@ -40,11 +40,8 @@ def add_list_item( :param material_list: The IfcMaterialList the material should be added to. - :type material_list: ifcopenshell.entity_instance :param material: The IfcMaterial to add to the list - :type material: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -80,8 +77,6 @@ def add_list_item( # aluminium and glass. ifcopenshell.api.material.assign_material(model, products=[window_type], material=material_set) """ - settings = {"material_list": material_list, "material": material} - - materials = list(settings["material_list"].Materials or []) - materials.append(settings["material"]) - settings["material_list"].Materials = materials + materials = list(material_list.Materials or []) + materials.append(material) + material_list.Materials = materials diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_material.py index d938c75380..6b04f78be2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/add_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_material.py @@ -58,13 +58,9 @@ def add_material( :param name: The name of the material, typically tagged in a finishes drawing or schedule. - :type name: str, optional :param category: The category of the material. - :type category: str, optional :param description: A description of the material. - :type description: str, optional :return: The newly created IfcMaterial - :rtype: ifcopenshell.entity_instance Example: @@ -81,11 +77,9 @@ def add_material( # "Style" has been specified. ifcopenshell.api.material.assign_material(model, products=[concrete_bench], material=concrete) """ - settings = {"name": name or "Unnamed", "category": category, "description": description} - - material = file.create_entity("IfcMaterial", **{"Name": settings["name"] or "Unnamed"}) - if settings["category"]: - material.Category = settings["category"] - if settings["description"]: - material.Description = settings["description"] + material = file.create_entity("IfcMaterial", **{"Name": name or "Unnamed"}) + if category: + material.Category = category + if description: + material.Description = description return material diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py index 72c090b1ae..bd23688ac8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py @@ -68,14 +68,11 @@ def add_material_set( :param name: The name of the material set, which may be purely descriptive or annotated in drawings. Defaults to "Unnamed". - :type name: str, optional :param set_type: What type of set you want to create, chosen from IfcMaterialLayerSet, IfcMaterialProfileSet, IfcMaterialConstituentSet, or IfcMaterialList. Defaults to IfcMaterialConstituentSet. - :type set_type: str, optional :return: The newly created material set element - :rtype: ifcopenshell.entity_instance Example: @@ -113,10 +110,8 @@ def add_material_set( # Great! Let's assign our material set to our wall type. ifcopenshell.api.material.assign_material(model, products=[wall_type], material=material_set) """ - settings = {"name": name or "Unnamed", "set_type": set_type} - - if settings["set_type"] == "IfcMaterialLayerSet": - return file.create_entity("IfcMaterialLayerSet", LayerSetName=settings["name"] or "Unnamed") - elif settings["set_type"] == "IfcMaterialList": + if set_type == "IfcMaterialLayerSet": + return file.create_entity("IfcMaterialLayerSet", LayerSetName=name or "Unnamed") + elif set_type == "IfcMaterialList": return file.create_entity("IfcMaterialList") - return file.create_entity(settings["set_type"], Name=settings["name"] or "Unnamed") + return file.create_entity(set_type, Name=name or "Unnamed") diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py index b62d5e2ef3..160f79e453 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_profile.py @@ -67,7 +67,7 @@ def add_profile( # First, let's create a material set. This will later be assigned # to our beam type element. - material_set = ifcopenshell.api.material.add_profile_set(model, + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") # Create a steel material. diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/assign_profile.py b/src/ifcopenshell-python/ifcopenshell/api/material/assign_profile.py index 7719fd86c8..bca30a36c2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/assign_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/assign_profile.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.util.representation +from typing import Any def assign_profile( @@ -47,7 +48,7 @@ def assign_profile( # First, let's create a material set. This will later be assigned # to our beam type element. - material_set = ifcopenshell.api.material.add_profile_set(model, + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") # Create a steel material. @@ -55,7 +56,7 @@ def assign_profile( # Create an I-beam profile curve. Notice how we name our profiles # based on standardised steel profile names. - hea100 = usecase.file.create_entity( + hea100 = model.create_entity( "IfcIShapeProfileDef", ProfileName="HEA100", ProfileType="AREA", OverallWidth=100, OverallDepth=96, WebThickness=5, FlangeThickness=8, FilletRadius=12, ) @@ -83,7 +84,7 @@ def assign_profile( # Now let's change the profile to a HEA200 standard profile instead. # This will automatically change the body representation that we # just added as well to a HEA200 profile. - hea200 = usecase.file.create_entity( + hea200 = model.create_entity( "IfcIShapeProfileDef", ProfileName="HEA200", ProfileType="AREA", OverallWidth=200, OverallDepth=190, WebThickness=6.5, FlangeThickness=10, FilletRadius=18, ) @@ -91,18 +92,17 @@ def assign_profile( """ usecase = Usecase() usecase.file = file - usecase.settings = {"material_profile": material_profile, "profile": profile} - return usecase.execute() + return usecase.execute(material_profile, profile) class Usecase: file: ifcopenshell.file - def execute(self) -> None: + def execute(self, material_profile: ifcopenshell.entity_instance, profile: ifcopenshell.entity_instance) -> None: # TODO: handle composite profiles - old_profile = self.settings["material_profile"].Profile - self.settings["material_profile"].Profile = self.settings["profile"] - for profile_set in self.settings["material_profile"].ToMaterialProfileSet: + old_profile = material_profile.Profile + material_profile.Profile = profile + for profile_set in material_profile.ToMaterialProfileSet: for inverse in self.file.get_inverse(profile_set): if not inverse.is_a("IfcMaterialProfileSetUsage"): continue @@ -111,20 +111,20 @@ class Usecase: if not rel.is_a("IfcRelAssociatesMaterial"): continue for element in rel.RelatedObjects: - self.change_profile(element) + self.change_profile(element, profile) else: for rel in inverse.AssociatedTo: for element in rel.RelatedObjects: - self.change_profile(element) + self.change_profile(element, profile) - if old_profile and len(self.file.get_inverse(old_profile)) == 0: + if old_profile and self.file.get_total_inverses(old_profile) == 0: # TODO: check remove deep self.file.remove(old_profile) - def change_profile(self, element: ifcopenshell.entity_instance) -> None: + def change_profile(self, element: ifcopenshell.entity_instance, profile: ifcopenshell.entity_instance) -> None: representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: return for subelement in self.file.traverse(representation): if subelement.is_a("IfcSweptAreaSolid"): - subelement.SweptArea = self.settings["profile"] + subelement.SweptArea = profile diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_assigned_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_assigned_material.py index 939e995a06..9a01322743 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_assigned_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_assigned_material.py @@ -28,11 +28,8 @@ def edit_assigned_material( IfcMaterial, consult the IFC documentation. :param element: The IfcMaterial entity you want to edit - :type element: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_assigned_material( ifcopenshell.api.material.edit_assigned_material(model, element=concrete, attributes={"Description": "40MPA concrete with broom finish"}) """ - settings = {"element": element, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["element"], name, value) + for name, value in attributes.items(): + setattr(element, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_constituent.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_constituent.py index eda33d349b..a7398ca5f0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_constituent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_constituent.py @@ -31,13 +31,9 @@ def edit_constituent( IfcMaterialConstituent, consult the IFC documentation. :param constituent: The IfcMaterialConstituent entity you want to edit - :type constituent: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict, optional :param material: The IfcMaterial entity you want to change the constituent to - :type material: ifcopenshell.entity_instance, optional :return: None - :rtype: None Example: @@ -65,8 +61,6 @@ def edit_constituent( ifcopenshell.api.material.edit_constituent(model, constituent=constituent, attributes={"Name": "Glazing"}) """ - settings = {"constituent": constituent, "attributes": attributes or {}, "material": material} - - for name, value in settings["attributes"].items(): - setattr(settings["constituent"], name, value) - settings["constituent"].Material = settings["material"] + for name, value in (attributes or {}).items(): + setattr(constituent, name, value) + constituent.Material = material diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer.py index d691d6b154..68f3c6d832 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer.py @@ -31,14 +31,10 @@ def edit_layer( IfcMaterialLayer, consult the IFC documentation. :param layer: The IfcMaterialLayer entity you want to edit - :type layer: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict, optional :param material: The IfcMaterial entity you want the layer to be made from. - :type material: ifcopenshell.entity_instance, optional :return: None - :rtype: None Example: @@ -63,9 +59,7 @@ def edit_layer( layer = ifcopenshell.api.material.add_layer(model, layer_set=material_set, material=gypsum) ifcopenshell.api.material.edit_layer(model, layer=layer, attributes={"LayerThickness": 13}) """ - settings = {"layer": layer, "attributes": attributes or {}, "material": material} - - for name, value in settings["attributes"].items(): - setattr(settings["layer"], name, value) - if settings["material"]: - settings["layer"].Material = settings["material"] + for name, value in (attributes or {}).items(): + setattr(layer, name, value) + if material: + layer.Material = material diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer_usage.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer_usage.py index 6c581c1374..9d1ffb14ec 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer_usage.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_layer_usage.py @@ -29,11 +29,8 @@ def edit_layer_usage(file: ifcopenshell.file, usage: ifcopenshell.entity_instanc IfcMaterialLayerSetUsage, consult the IFC documentation. :param usage: The IfcMaterialLayerSetUsage entity you want to edit - :type usage: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -75,7 +72,5 @@ def edit_layer_usage(file: ifcopenshell.file, usage: ifcopenshell.entity_instanc ifcopenshell.api.material.edit_layer_usage(model, usage=rel.RelatingMaterial, attributes={"OffsetFromReferenceLine": 200}) """ - settings = {"usage": usage, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["usage"], name, value) + for name, value in attributes.items(): + setattr(usage, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile.py index 17f925997e..3bc7479388 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile.py @@ -34,24 +34,19 @@ def edit_profile( IfcMaterialProfile, consult the IFC documentation. :param profile: The IfcMaterialProfile entity you want to edit - :type profile: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict, optional :param profile_def: The IfcProfileDef entity the profile curve should be extruded from. - :type profile_def: ifcopenshell.entity_instance, optional :param material: The IfcMaterial entity you want to change the profile to be made from. - :type material: ifcopenshell.entity_instance, optional :return: None - :rtype: None Example: .. code:: python # Let's create a material set to store our profiles. - material_set = ifcopenshell.api.material.add_profile_set(model, + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") # Create a couple steel materials. @@ -80,16 +75,9 @@ def edit_profile( ifcopenshell.api.material.edit_profile(model, profile=profile_item, profile_def=hea200, material=steel2) """ - settings = { - "profile": profile, - "attributes": attributes or {}, - "profile_def": profile_def, - "material": material, - } - - for name, value in settings["attributes"].items(): - setattr(settings["profile"], name, value) - if settings["material"]: - settings["profile"].Material = settings["material"] - if settings["profile_def"]: - settings["profile"].Profile = settings["profile_def"] + for name, value in (attributes or {}).items(): + setattr(profile, name, value) + if material: + profile.Material = material + if profile_def: + profile.Profile = profile_def diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile_usage.py b/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile_usage.py index f9894db5b3..4c004ecfdd 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile_usage.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/edit_profile_usage.py @@ -17,6 +17,8 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.geom import ifcopenshell.util.representation +import ifcopenshell.util.shape +from ifcopenshell.geom import ShapeType from typing import Any @@ -34,11 +36,8 @@ def edit_profile_usage( IfcMaterialProfileSetUsage, consult the IFC documentation. :param usage: The IfcMaterialProfileSetUsage entity you want to edit - :type usage: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -51,7 +50,7 @@ def edit_profile_usage( # First, let's create a material set. This will later be assigned # to our beam type element. - material_set = ifcopenshell.api.material.add_profile_set(model, + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") # Create a steel material. @@ -59,7 +58,7 @@ def edit_profile_usage( # Create an I-beam profile curve. Notice how we name our profiles # based on standardised steel profile names. - hea100 = usecase.file.create_entity( + hea100 = model.create_entity( "IfcIShapeProfileDef", ProfileName="HEA100", ProfileType="AREA", OverallWidth=100, OverallDepth=96, WebThickness=5, FlangeThickness=8, FilletRadius=12, ) @@ -75,7 +74,7 @@ def edit_profile_usage( # Let's create an occurrence of this beam. beam = ifcopenshell.api.root.create_entity(model, ifc_class="IfcBeam", name="B1.01") - rel = ifcopenshell.api.material.assign_material(model, + rel = ifcopenshell.api.material.assign_material(model, material=material_set, products=[beam], type="IfcMaterialProfileSetUsage") # Let's give a 1000mm long beam body representation. @@ -93,21 +92,24 @@ def edit_profile_usage( usecase = Usecase() usecase.file = file - usecase.settings = {"usage": usage, "attributes": attributes} - return usecase.execute() + return usecase.execute(usage, attributes) class Usecase: - def execute(self): - self.cardinal_point = self.settings["attributes"].get("CardinalPoint") - if self.cardinal_point and self.cardinal_point != self.settings["usage"].CardinalPoint: + file: ifcopenshell.file + + def execute(self, usage: ifcopenshell.entity_instance, attributes: dict[str, Any]) -> None: + self.usage = usage + self.attributes = attributes + self.cardinal_point = attributes.get("CardinalPoint") + if self.cardinal_point and self.cardinal_point != usage.CardinalPoint: self.update_cardinal_point() - for name, value in self.settings["attributes"].items(): - setattr(self.settings["usage"], name, value) + for name, value in attributes.items(): + setattr(usage, name, value) def update_cardinal_point(self): - material_set = self.settings["usage"].ForProfileSet + material_set = self.usage.ForProfileSet self.profile = material_set.CompositeProfile if not self.profile and material_set.MaterialProfiles: self.profile = material_set.MaterialProfiles[0].Profile @@ -117,13 +119,13 @@ class Usecase: self.position = self.calculate_position() if self.file.schema == "IFC2X3": - for rel in self.file.get_inverse(self.settings["usage"]): + for rel in self.file.get_inverse(self.usage): if not rel.is_a("IfcRelAssociatesMaterial"): continue for element in rel.RelatedObjects: self.update_representation(element) else: - for rel in self.settings["usage"].AssociatedTo: + for rel in self.usage.AssociatedTo: for element in rel.RelatedObjects: self.update_representation(element) @@ -147,6 +149,8 @@ class Usecase: self.settings_2d.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) shape = ifcopenshell.geom.create_shape(self.settings_2d, dummy_solid) + # NOTE: points do not need unit conversion + # as dummy file is inherently using project units. if self.cardinal_point == 1: return self.get_bottom_left(shape) elif self.cardinal_point == 2: @@ -166,66 +170,46 @@ class Usecase: elif self.cardinal_point == 9: return self.get_top_right(shape) - def get_bottom_left(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - y = [v[i + 1] for i in range(0, len(v), 3)] - width = max(x) - min(x) - height = max(y) - min(y) + def get_bottom_left(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((-width / 2, height / 2, 0.0))) - def get_bottom_centre(self, shape): - v = shape.verts - y = [v[i + 1] for i in range(0, len(v), 3)] - height = max(y) - min(y) + def get_bottom_centre(self, shape: ShapeType) -> ifcopenshell.entity_instance: + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((0.0, height / 2, 0.0))) - def get_bottom_right(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - y = [v[i + 1] for i in range(0, len(v), 3)] - width = max(x) - min(x) - height = max(y) - min(y) + def get_bottom_right(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((width / 2, height / 2, 0.0))) - def get_mid_depth_left(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - width = max(x) - min(x) + def get_mid_depth_left(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((-width / 2, 0.0, 0.0))) - def get_mid_depth_centre(self, shape): + def get_mid_depth_centre(self, shape: ShapeType) -> ifcopenshell.entity_instance: return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))) - def get_mid_depth_right(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - width = max(x) - min(x) + def get_mid_depth_right(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((width / 2, 0.0, 0.0))) - def get_top_left(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - y = [v[i + 1] for i in range(0, len(v), 3)] - width = max(x) - min(x) - height = max(y) - min(y) + def get_top_left(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((-width / 2, -height / 2, 0.0))) - def get_top_centre(self, shape): - v = shape.verts - y = [v[i + 1] for i in range(0, len(v), 3)] - height = max(y) - min(y) + def get_top_centre(self, shape: ShapeType) -> ifcopenshell.entity_instance: + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((0.0, -height / 2, 0.0))) - def get_top_right(self, shape): - v = shape.verts - x = [v[i] for i in range(0, len(v), 3)] - y = [v[i + 1] for i in range(0, len(v), 3)] - width = max(x) - min(x) - height = max(y) - min(y) + def get_top_right(self, shape: ShapeType) -> ifcopenshell.entity_instance: + width = ifcopenshell.util.shape.get_x(shape) + height = ifcopenshell.util.shape.get_y(shape) return self.file.createIfcAxis2Placement3D(self.file.createIfcCartesianPoint((width / 2, -height / 2, 0.0))) - def update_representation(self, element): + def update_representation(self, element: ifcopenshell.entity_instance) -> None: representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not representation: return @@ -234,5 +218,5 @@ class Usecase: if subelement.is_a("IfcSweptAreaSolid") and subelement.SweptArea == self.profile: self.update_swept_area_solid(subelement) - def update_swept_area_solid(self, element): + def update_swept_area_solid(self, element: ifcopenshell.entity_instance) -> None: element.Position = self.position diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_constituent.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_constituent.py index 7521c48a15..8367f24d0e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_constituent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_constituent.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.util.element def remove_constituent( @@ -51,7 +52,7 @@ def remove_constituent( # invalid. ifcopenshell.api.material.remove_constituent(model, constituent=glazing) """ - material = layer.Material + material = constituent.Material file.remove(constituent) if material and should_remove_material: - ifcopenshell.util.element.remove_deep2(file, subelement) + ifcopenshell.util.element.remove_deep2(file, material) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_layer.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_layer.py index 7055beaf26..443ad122c0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_layer.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_layer.py @@ -57,4 +57,4 @@ def remove_layer( material = layer.Material file.remove(layer) if material and should_remove_material: - ifcopenshell.util.element.remove_deep2(file, subelement) + ifcopenshell.util.element.remove_deep2(file, material) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_list_item.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_list_item.py index cd4f2f1cd6..6af15a7d3a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_list_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_list_item.py @@ -54,8 +54,6 @@ def remove_list_item( # Let's remove the glass ifcopenshell.api.material.remove_list_item(model, material_list=material_set, material_index=1) """ - settings = {"material_list": material_list, "material_index": material_index} - - materials = list(settings["material_list"].Materials) - materials.pop(settings["material_index"]) - settings["material_list"].Materials = materials + materials = list(material_list.Materials) + materials.pop(material_index) + material_list.Materials = materials diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py index d9894b0d96..c919f1f629 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py @@ -29,9 +29,7 @@ def remove_material(file: ifcopenshell.file, material: ifcopenshell.entity_insta take care of this situation themselves. :param material: The IfcMaterial entity you want to remove - :type material: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -43,10 +41,8 @@ def remove_material(file: ifcopenshell.file, material: ifcopenshell.entity_insta # ... and remove it ifcopenshell.api.material.remove_material(model, material=aluminium) """ - settings = {"material": material} - - inverse_elements = file.get_inverse(settings["material"]) - file.remove(settings["material"]) + inverse_elements = file.get_inverse(material) + file.remove(material) # TODO: Right now, we we choose only to delete set items (e.g. a layer) but not the material set # This can lead to invalid material sets, but we assume the user will deal with it for inverse in inverse_elements: diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py index 30edd7ad9e..51decaaa98 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py @@ -29,9 +29,7 @@ def remove_material_set(file: ifcopenshell.file, material: ifcopenshell.entity_i :param material: The IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet entity you want to remove. - :type material: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -55,20 +53,18 @@ def remove_material_set(file: ifcopenshell.file, material: ifcopenshell.entity_i ifcopenshell.api.material.remove_material_set(model, material=material_set) """ - settings = {"material": material} - - inverse_elements = file.get_inverse(settings["material"]) - if settings["material"].is_a("IfcMaterialLayerSet"): - set_items = settings["material"].MaterialLayers or [] - elif settings["material"].is_a("IfcMaterialProfileSet"): - set_items = settings["material"].MaterialProfiles or [] - elif settings["material"].is_a("IfcMaterialConstituentSet"): - set_items = settings["material"].MaterialConstituents or [] - elif settings["material"].is_a("IfcMaterialList"): + inverse_elements = file.get_inverse(material) + if material.is_a("IfcMaterialLayerSet"): + set_items = material.MaterialLayers or [] + elif material.is_a("IfcMaterialProfileSet"): + set_items = material.MaterialProfiles or [] + elif material.is_a("IfcMaterialConstituentSet"): + set_items = material.MaterialConstituents or [] + elif material.is_a("IfcMaterialList"): set_items = [] for set_item in set_items: file.remove(set_item) - file.remove(settings["material"]) + file.remove(material) for inverse in inverse_elements: if inverse.is_a("IfcRelAssociatesMaterial"): history = inverse.OwnerHistory diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_profile.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_profile.py index 2883f21e41..1314bf209e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_profile.py @@ -40,7 +40,7 @@ def remove_profile( .. code:: python # First, let's create a material set. - material_set = ifcopenshell.api.material.add_profile_set(model, + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") # Create a steel material. diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/reorder_set_item.py b/src/ifcopenshell-python/ifcopenshell/api/material/reorder_set_item.py index 0015d3558a..aa06433e66 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/reorder_set_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/reorder_set_item.py @@ -57,16 +57,17 @@ def reorder_set_item( ifcopenshell.api.material.reorder_set_item(model, material_set=material_set, old_index=0, new_index=1) """ - settings = {"material_set": material_set, "old_index": old_index, "new_index": new_index} - - if settings["material_set"].is_a("IfcMaterialConstituentSet"): + if material_set.is_a("IfcMaterialConstituentSet"): set_name = "MaterialConstituents" - elif settings["material_set"].is_a("IfcMaterialLayerSet"): + elif material_set.is_a("IfcMaterialLayerSet"): set_name = "MaterialLayers" - elif settings["material_set"].is_a("IfcMaterialProfileSet"): + elif material_set.is_a("IfcMaterialProfileSet"): set_name = "MaterialProfiles" - elif settings["material_set"].is_a("IfcMaterialList"): + elif material_set.is_a("IfcMaterialList"): set_name = "Materials" - items = list(getattr(settings["material_set"], set_name) or []) - items.insert(settings["new_index"], items.pop(settings["old_index"])) - setattr(settings["material_set"], set_name, items) + else: + raise ValueError(f"Unexpected material set type: '{material_set.is_a()}'.") + + items = list(getattr(material_set, set_name) or []) + items.insert(new_index, items.pop(old_index)) + setattr(material_set, set_name, items) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/set_shape_aspect_constituents.py b/src/ifcopenshell-python/ifcopenshell/api/material/set_shape_aspect_constituents.py new file mode 100644 index 0000000000..d95f877fcf --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/material/set_shape_aspect_constituents.py @@ -0,0 +1,114 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# 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 ifcopenshell.api.style +import ifcopenshell.api.material +import ifcopenshell.util.element +import ifcopenshell.util.representation + + +def set_shape_aspect_constituents( + file: ifcopenshell.file, + *, + element: ifcopenshell.entity_instance, + context: ifcopenshell.entity_instance, + materials: dict[str, ifcopenshell.entity_instance], +) -> None: + """Assigns a material constituent set and sets styles based on shape aspects + + An IFC element may be assigned to a set of material constituents. For + example, a window may have a framing material and a glazing material. Each + constituent may have a name, such as "Framing" (which may be assigned to an + "Aluminium" material), and "Glazing" (assigned to a "Laminated Low-e Glass" + material). + + An IFC element's geometry may be composed of multiple geometric items. + These geometric items may have names, known as "Shape Aspects". For + example a solid extrusion for the framing named "Framing" and a solid + extrusion for the glass panel named "Glazing". + + A material may be associated with a style (i.e. colour). For example, a + grey style for the "Aluminium" material and a transparent blue style for + the "Laminated Low-e Glass" material. + + These three concepts of material constituents, shape aspects, and + associated styles are correlated. For example, if the name (e.g. "Framing") + of a material constituent and a shape aspect correlate, that means that the + geometric item inherits the style (i.e. grey). + + This function lets you specify named material constituents, and it'll + create a constituent set assigned to the element with those names. It'll + then find any geometric representation items with shape aspects matching + those names, and assign the correlating style. + + If an assigned material constituent set already exists matching those + values, it will be reused. If the values do not match, the existing + material constituent set will be removed if it is not used by anything + else. + + :param element: The IfcProduct or IfcTypeProduct + :param context: The IfcGeometricRepresentationContext, typically the body + context. You can get this via + :func:`ifcopenshell.util.representation.get_context`. + :param materials: The key is the name of the constituent, and the value is + the IfcMaterial. + + Example: + + .. code:: python + + # Create two materials + aluminium = ifcopenshell.api.material.add_material(model, name="AL01", category="aluminium") + glass = ifcopenshell.api.material.add_material(model, name="GLZ01", category="glass") + + # Auto assign material constituents and styles to items based on shape aspects + ifcopenshell.api.material.set_shape_aspect_constituents( + model, element=window, context=body, materials={ + "Framing": aluminium + "Lining": aluminium + "Glazing": glass + }) + """ + should_create_new_material_set = False + if material := ifcopenshell.util.element.get_material(element): + if ( + material.is_a("IfcMaterialConstituent") + and len(names := [c.Name for c in material.MaterialConstituents]) == len(materials) + and set(names) == set(materials.keys()) + ): + should_create_new_material_set = False + else: + should_create_new_material_set = True + ifcopenshell.api.material.unassign_material(file, products=[element]) + if not material.is_a("IfcMaterial") and not file.get_total_inverses(material): + ifcopenshell.api.material.remove_material_set(file, material=material) + else: + should_create_new_material_set = True + if should_create_new_material_set: + material_set = ifcopenshell.api.material.add_material_set(file, set_type="IfcMaterialConstituentSet") + for name, material in materials.items(): + ifcopenshell.api.material.add_constituent(file, constituent_set=material_set, material=material, name=name) + ifcopenshell.api.material.assign_material(file, products=[element], material=material_set) + + styles = {n: ifcopenshell.util.representation.get_material_style(m, context) for n, m in materials.items()} + representation = ifcopenshell.util.representation.get_representation(element, context=context) + representation = ifcopenshell.util.representation.resolve_representation(representation) + for item in representation.Items: + if aspect := ifcopenshell.util.representation.get_item_shape_aspect(representation, item): + if style := styles.get(aspect.Name, None): + ifcopenshell.api.style.assign_item_style(file, item=item, style=style) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/unassign_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/unassign_material.py index 8db2060df0..f8386490c7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/unassign_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/unassign_material.py @@ -19,6 +19,7 @@ import ifcopenshell import ifcopenshell.api.owner import ifcopenshell.util.element +from typing import Any def unassign_material(file: ifcopenshell.file, products: list[ifcopenshell.entity_instance]) -> None: @@ -58,6 +59,9 @@ def unassign_material(file: ifcopenshell.file, products: list[ifcopenshell.entit class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): self.products = set(self.settings["products"]) if not self.products: diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_actor.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_actor.py index 284020a224..1456a99d5e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_actor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_actor.py @@ -49,11 +49,8 @@ def add_actor( IfcPerson if it is a sole individual, or an IfcPersonAndOrganization if a specific person is liable within an organisation and must be legally nominated. - :type actor: ifcopenshell.entity_instance :param ifc_class: Either "IfcActor" or "IfcOccupant". - :type ifc_class: str, optional :return: The newly created IfcActor or IfcOccupant - :rtype: ifcopenshell.entity_instance Example: @@ -67,8 +64,7 @@ def add_actor( # Assign that organisation to a newly created actor actor = ifcopenshell.api.owner.add_actor(model, actor=organisation) """ - settings = {"actor": actor, "ifc_class": ifc_class or "IfcActor"} - - actor = ifcopenshell.api.root.create_entity(file, ifc_class=settings["ifc_class"]) - actor.TheActor = settings["actor"] - return actor + ifc_class = ifc_class or "IfcActor" + actor_ = ifcopenshell.api.root.create_entity(file, ifc_class=ifc_class) + actor_.TheActor = actor + return actor_ diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_address.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_address.py index 5b03f9f18a..791f41c6a2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_address.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_address.py @@ -39,12 +39,9 @@ def add_address( :param assigned_object: The IfcOrganization or IfcPerson the contact address belongs to. - :type assigned_object: ifcopenshell.entity_instance :param ifc_class: Either IfcPostalAddress or IfcTelecomAddress. Defaults to IfcPostalAddress. - :type ifc_class: str, optional :return: The new IfcPostalAddress or IfcTelecomAddress - :rtype: ifcopenshell.entity_instance Example: @@ -67,10 +64,8 @@ def add_address( "ElectronicMailAddresses": ["bobthebuilder@example.com"], "WWWHomePageURL": "https://thinkmoult.com"}) """ - settings = {"assigned_object": assigned_object, "ifc_class": ifc_class} - - address = file.create_entity(settings["ifc_class"], "OFFICE") - addresses = list(settings["assigned_object"].Addresses) if settings["assigned_object"].Addresses else [] + address = file.create_entity(ifc_class, "OFFICE") + addresses = list(assigned_object.Addresses) if assigned_object.Addresses else [] addresses.append(address) - settings["assigned_object"].Addresses = addresses + assigned_object.Addresses = addresses return address diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_application.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_application.py index 991006be01..3f18754287 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_application.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_application.py @@ -17,7 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.api -from typing import Optional +from typing import Optional, Any def add_application( @@ -68,6 +68,9 @@ def add_application( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): if not self.settings["application_developer"]: self.settings["application_developer"] = self.create_application_organisation() diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_organisation.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_organisation.py index b5c2fae582..be308536e3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_organisation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_organisation.py @@ -31,11 +31,8 @@ def add_organisation( Sometimes used in drawing naming schemes. Otherise used as a canonicalised way of computers to identify the organisation. Like their stock name. - :type identification: str, optional :param name: The legal name of the organisation - :type name: str, optional :return: The newly created IfcOrganization - :rtype: ifcopenshell.entity_instance Example: @@ -44,11 +41,9 @@ def add_organisation( organisation = ifcopenshell.api.owner.add_organisation(model, identification="AWB", name="Architects Without Ballpens") """ - settings = {"identification": identification, "name": name} - - data = {"Name": settings["name"]} + data = {"Name": name} if file.schema == "IFC2X3": - data["Id"] = settings["identification"] + data["Id"] = identification else: - data["Identification"] = settings["identification"] + data["Identification"] = identification return file.create_entity("IfcOrganization", **data) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_person.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_person.py index 30d81ef117..1953d80028 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_person.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_person.py @@ -31,13 +31,9 @@ def add_person( :param identification: The computer readable unique identification of the person. For example, their username in a CDE or alias. - :type identification: str, optional :param family_name: The family name - :type family_name: str, optional :param given_name: The given name - :type given_name: str, optional :return: The newly created IfcPerson - :rtype: ifcopenshell.entity_instance Example: @@ -46,15 +42,9 @@ def add_person( ifcopenshell.api.owner.add_person(model, identification="bobthebuilder", family_name="Thebuilder", given_name="Bob") """ - settings = { - "identification": identification, - "family_name": family_name, - "given_name": given_name, - } - - data = {"FamilyName": settings["family_name"], "GivenName": settings["given_name"]} + data = {"FamilyName": family_name, "GivenName": given_name} if file.schema == "IFC2X3": - data["Id"] = settings["identification"] + data["Id"] = identification else: - data["Identification"] = settings["identification"] + data["Identification"] = identification return file.create_entity("IfcPerson", **data) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_person_and_organisation.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_person_and_organisation.py index 6d6a9bdfb7..3fbec452c0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_person_and_organisation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_person_and_organisation.py @@ -30,11 +30,8 @@ def add_person_and_organisation( :param person: The IfcPerson being the representative of the organisation. - :type person: ifcopenshell.entity_instance :param organisation: The IfcOrganization it - :type organisation: ifcopenshell.entity_instance :return: The newly created IfcPersonAndOrganization - :rtype: ifcopenshell.entity_instance Example: @@ -48,6 +45,4 @@ def add_person_and_organisation( ifcopenshell.api.owner.add_person_and_organisation(model, person=person, organisation=organisation) """ - settings = {"person": person, "organisation": organisation} - - return file.createIfcPersonAndOrganization(settings["person"], settings["organisation"]) + return file.create_entity("IfcPersonAndOrganization", person, organisation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/add_role.py b/src/ifcopenshell-python/ifcopenshell/api/owner/add_role.py index 7105e5e78d..75bb5eb517 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/add_role.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/add_role.py @@ -48,16 +48,14 @@ def add_role( identification="AWB", name="Architects Without Ballpens") ifcopenshell.api.owner.add_role(model, assigned_object=organisation, role="ARCHITECT") """ - settings = {"assigned_object": assigned_object, "role": role} - - element = file.createIfcActorRole("ARCHITECT") - if settings["role"]: + element = file.create_entity("IfcActorRole", Role="ARCHITECT") + if role: try: - element.Role = settings["role"] + element.Role = role except: element.Role = "USERDEFINED" - element.UserDefinedRole = settings["role"] - roles = list(settings["assigned_object"].Roles) if settings["assigned_object"].Roles else [] + element.UserDefinedRole = role + roles = list(assigned_object.Roles) if assigned_object.Roles else [] roles.append(element) - settings["assigned_object"].Roles = roles + assigned_object.Roles = roles return element diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/assign_actor.py b/src/ifcopenshell-python/ifcopenshell/api/owner/assign_actor.py index dcda100b34..7f2df90511 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/assign_actor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/assign_actor.py @@ -43,11 +43,8 @@ def assign_actor( ifcopenshell.api.resource.assign_resource. :param relating_actor: The IfcActor who is responsible for the object. - :type relating_actor: ifcopenshell.entity_instance :param related_object: The object the actor is responsible for. - :type related_object: ifcopenshell.entity_instance :return: The newly created IfcRelAssignsToActor relationship. - :rtype: ifcopenshell.entity_instance Example: @@ -74,24 +71,19 @@ def assign_actor( ifcopenshell.api.owner.assign_actor(model, relating_actor=manufacturer, related_object=pump_type) """ - settings = { - "relating_actor": relating_actor, - "related_object": related_object, - } - - if settings["related_object"].HasAssignments: - for rel in settings["related_object"].HasAssignments: - if rel.is_a("IfcRelAssignsToActor") and rel.RelatingActor == settings["relating_actor"]: + if related_object.HasAssignments: + for rel in related_object.HasAssignments: + if rel.is_a("IfcRelAssignsToActor") and rel.RelatingActor == relating_actor: return rel rel = None - if settings["relating_actor"].IsActingUpon: - rel = settings["relating_actor"].IsActingUpon[0] + if relating_actor.IsActingUpon: + rel = relating_actor.IsActingUpon[0] if rel: related_objects = list(rel.RelatedObjects) - related_objects.append(settings["related_object"]) + related_objects.append(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) else: @@ -100,8 +92,8 @@ def assign_actor( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingActor": settings["relating_actor"], + "RelatedObjects": [related_object], + "RelatingActor": relating_actor, } ) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_actor.py b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_actor.py index 799b37462c..8ad238f994 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_actor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_actor.py @@ -26,11 +26,8 @@ def edit_actor(file: ifcopenshell.file, actor: ifcopenshell.entity_instance, att IfcActor, consult the IFC documentation. :param actor: The IfcActor entity you want to edit - :type actor: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -49,7 +46,5 @@ def edit_actor(file: ifcopenshell.file, actor: ifcopenshell.entity_instance, att ifcopenshell.api.actor.edit_actor(model, actor=actor, attributes={"Description": "Responsible for buildings A, B, and C."}) """ - settings = {"actor": actor, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["actor"], name, value) + for name, value in attributes.items(): + setattr(actor, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_address.py b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_address.py index 43776fa711..52e80d6ef1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_address.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_address.py @@ -26,11 +26,8 @@ def edit_address(file: ifcopenshell.file, address: ifcopenshell.entity_instance, IfcAddress, consult the IFC documentation. :param address: The IfcAddress entity you want to edit - :type address: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -51,7 +48,5 @@ def edit_address(file: ifcopenshell.file, address: ifcopenshell.entity_instance, "ElectronicMailAddresses": ["bobthebuilder@example.com"], "WWWHomePageURL": "https://thinkmoult.com"}) """ - settings = {"address": address, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["address"], name, value) + for name, value in attributes.items(): + setattr(address, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_organisation.py b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_organisation.py index 9cfbfff48d..a2bb5e3eb6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_organisation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_organisation.py @@ -28,11 +28,8 @@ def edit_organisation( IfcOrganization, consult the IFC documentation. :param organisation: The IfcOrganization entity you want to edit - :type organisation: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -43,7 +40,5 @@ def edit_organisation( ifcopenshell.api.owner.edit_organisation(model, organisation=organisation, attributes={"name": "Architects Without Ballpens"}) """ - settings = {"organisation": organisation, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["organisation"], name, value) + for name, value in attributes.items(): + setattr(organisation, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_person.py b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_person.py index 0e4bd15293..6b697bd674 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_person.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_person.py @@ -26,11 +26,8 @@ def edit_person(file: ifcopenshell.file, person: ifcopenshell.entity_instance, a IfcPerson, consult the IFC documentation. :param person: The IfcPerson entity you want to edit - :type person: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -41,7 +38,5 @@ def edit_person(file: ifcopenshell.file, person: ifcopenshell.entity_instance, a ifcopenshell.api.owner.edit_person(model, person=person, attributes={"MiddleNames": ["The"], "FamilyName": "Builder"}) """ - settings = {"person": person, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["person"], name, value) + for name, value in attributes.items(): + setattr(person, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_role.py b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_role.py index 885d4b9b9b..fca80a16b0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/edit_role.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/edit_role.py @@ -26,11 +26,8 @@ def edit_role(file: ifcopenshell.file, role: ifcopenshell.entity_instance, attri IfcActorRole, consult the IFC documentation. :param role: The IfcActorRole entity you want to edit - :type role: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -45,7 +42,5 @@ def edit_role(file: ifcopenshell.file, role: ifcopenshell.entity_instance, attri # But Bob is not an architect ifcopenshell.api.owner.edit_role(model, role=role, attributes={"Role": "CONSTRUCTIONMANAGER"}) """ - settings = {"role": role, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["role"], name, value) + for name, value in attributes.items(): + setattr(role, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_actor.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_actor.py index 026808b7d2..dd86a6f937 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_actor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_actor.py @@ -24,9 +24,7 @@ def remove_actor(file: ifcopenshell.file, actor: ifcopenshell.entity_instance) - """Removes an actor :param actor: The IfcActor to remove. - :type actor: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -44,9 +42,7 @@ def remove_actor(file: ifcopenshell.file, actor: ifcopenshell.entity_instance) - # Actually we need ballpens on this project ifcopenshell.api.owner.remove_actor(model, actor=actor) """ - settings = {"actor": actor} - - history = settings["actor"].OwnerHistory - file.remove(settings["actor"]) + history = actor.OwnerHistory + file.remove(actor) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_address.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_address.py index 0d8ff96ff9..28942943a4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_address.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_address.py @@ -25,9 +25,7 @@ def remove_address(file: ifcopenshell.file, address: ifcopenshell.entity_instanc relationship removed. :param address: The IfcAddress to remove. - :type address: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -40,10 +38,8 @@ def remove_address(file: ifcopenshell.file, address: ifcopenshell.entity_instanc # Change our mind and delete it ifcopenshell.api.owner.remove_address(model, address=address) """ - settings = {"address": address} - - for inverse in file.get_inverse(settings["address"]): + for inverse in file.get_inverse(address): if inverse.is_a() in ("IfcOrganization", "IfcPerson"): - if inverse.Addresses == (settings["address"],): + if inverse.Addresses == (address,): inverse.Addresses = None - file.remove(settings["address"]) + file.remove(address) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_application.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_application.py index c744d2f97f..1a63bc4949 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_application.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_application.py @@ -25,9 +25,7 @@ def remove_application(file: ifcopenshell.file, application: ifcopenshell.entity Check whether or not the application is used anywhere prior to removal. :param address: The IfcApplication to remove. - :type address: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -36,6 +34,4 @@ def remove_application(file: ifcopenshell.file, application: ifcopenshell.entity application = ifcopenshell.api.owner.add_application(model) ifcopenshell.api.owner.remove_address(model, application=application) """ - settings = {"application": application} - - file.remove(settings["application"]) + file.remove(application) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_organisation.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_organisation.py index fbc25adcbe..acee11d9f5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_organisation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_organisation.py @@ -27,9 +27,7 @@ def remove_organisation(file: ifcopenshell.file, organisation: ifcopenshell.enti removed. :param organisation: The IfcOrganization to remove - :type organisation: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -39,31 +37,29 @@ def remove_organisation(file: ifcopenshell.file, organisation: ifcopenshell.enti identification="AWB", name="Architects Without Ballpens") ifcopenshell.api.owner.remove_organisation(model, organisation=organisation) """ - settings = {"organisation": organisation} - - for role in settings["organisation"].Roles or []: - if len(file.get_inverse(role)) == 1: + for role in organisation.Roles or []: + if (file.get_total_inverses(role)) == 1: ifcopenshell.api.owner.remove_role(file, role=role) - for address in settings["organisation"].Addresses or []: - if len(file.get_inverse(address)) == 1: + for address in organisation.Addresses or []: + if (file.get_total_inverses(address)) == 1: ifcopenshell.api.owner.remove_address(file, address=address) - for inverse in file.get_inverse(settings["organisation"]): + for inverse in file.get_inverse(organisation): if inverse.is_a("IfcOrganizationRelationship"): - if inverse.RelatingOrganization == settings["organisation"]: + if inverse.RelatingOrganization == organisation: file.remove(inverse) - elif inverse.RelatedOrganizations == (settings["organisation"],): + elif inverse.RelatedOrganizations == (organisation,): file.remove(inverse) elif inverse.is_a("IfcDocumentInformation"): - if inverse.Editors == (settings["organisation"],): + if inverse.Editors == (organisation,): inverse.Editors = None elif inverse.is_a("IfcPersonAndOrganization"): ifcopenshell.api.owner.remove_person_and_organisation(file, person_and_organisation=inverse) elif inverse.is_a("IfcActor"): ifcopenshell.api.root.remove_product(file, product=inverse) elif inverse.is_a("IfcResourceLevelRelationship") and not inverse.is_a("IfcOrganizationRelationship"): - if inverse.RelatedResourceObjects == (settings["organisation"],): + if inverse.RelatedResourceObjects == (organisation,): file.remove(inverse) elif inverse.is_a("IfcApplication"): ifcopenshell.api.owner.remove_application(file, application=inverse) - file.remove(settings["organisation"]) + file.remove(organisation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person.py index a4f4d64f1f..0c618c1e74 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person.py @@ -29,9 +29,7 @@ def remove_person(file: ifcopenshell.file, person: ifcopenshell.entity_instance) the only responsile person for them. :param person: The IfcPerson to remove - :type person: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -41,31 +39,30 @@ def remove_person(file: ifcopenshell.file, person: ifcopenshell.entity_instance) identification="bobthebuilder", family_name="Thebuilder", given_name="Bob") ifcopenshell.api.owner.remove_person(model, person=person) """ - settings = {"person": person} - for role in settings["person"].Roles or []: - if len(file.get_inverse(role)) == 1: + for role in person.Roles or []: + if file.get_total_inverses(role) == 1: ifcopenshell.api.owner.remove_role(file, role=role) - for address in settings["person"].Addresses or []: - if len(file.get_inverse(address)) == 1: + for address in person.Addresses or []: + if file.get_total_inverses(address) == 1: ifcopenshell.api.owner.remove_address(file, address=address) - for inverse in file.get_inverse(settings["person"]): + for inverse in file.get_inverse(person): if inverse.is_a("IfcWorkControl"): - if inverse.Creators == (settings["person"],): + if inverse.Creators == (person,): inverse.Creators = None elif inverse.is_a("IfcInventory"): - if inverse.ResponsiblePersons == (settings["person"],): + if inverse.ResponsiblePersons == (person,): # in IFC2X3 ResponsiblePersons is not optional and without it IfcInventory is not valid if file.schema == "IFC2X3": ifcopenshell.api.root.remove_product(file, product=inverse) elif inverse.is_a("IfcDocumentInformation"): - if inverse.Editors == (settings["person"],): + if inverse.Editors == (person,): inverse.Editors = None elif inverse.is_a("IfcPersonAndOrganization"): ifcopenshell.api.owner.remove_person_and_organisation(file, person_and_organisation=inverse) elif inverse.is_a("IfcActor"): ifcopenshell.api.root.remove_product(file, product=inverse) elif inverse.is_a("IfcResourceLevelRelationship"): - if inverse.RelatedResourceObjects == (settings["person"],): + if inverse.RelatedResourceObjects == (person,): file.remove(inverse) - file.remove(settings["person"]) + file.remove(person) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person_and_organisation.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person_and_organisation.py index 95f137f046..13607b22f3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person_and_organisation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_person_and_organisation.py @@ -28,9 +28,7 @@ def remove_person_and_organisation( the "person and organisation" group. :param person_and_organisation: The IfcPersonAndOrganization to remove. - :type person_and_organisation: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -46,17 +44,15 @@ def remove_person_and_organisation( ifcopenshell.api.owner.remove_person_and_organisation(model, person_and_organisation=user) """ - settings = {"person_and_organisation": person_and_organisation} - - for inverse in file.get_inverse(settings["person_and_organisation"]): + for inverse in file.get_inverse(person_and_organisation): if inverse.is_a("IfcDocumentInformation"): - if inverse.Editors == (settings["person_and_organisation"],): + if inverse.Editors == (person_and_organisation,): inverse.Editors = None elif inverse.is_a("IfcActor"): ifcopenshell.api.root.remove_product(file, product=inverse) elif inverse.is_a("IfcResourceLevelRelationship"): - if inverse.RelatedResourceObjects == (settings["person_and_organisation"],): + if inverse.RelatedResourceObjects == (person_and_organisation,): file.remove(inverse) elif inverse.is_a("IfcOwnerHistory"): file.remove(inverse) - file.remove(settings["person_and_organisation"]) + file.remove(person_and_organisation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_role.py b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_role.py index 07c2d36a9a..10c648cca5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/remove_role.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/remove_role.py @@ -25,9 +25,7 @@ def remove_role(file: ifcopenshell.file, role: ifcopenshell.entity_instance) -> leave some of them without roles. :param role: The IfcActorRole to remove. - :type role: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -40,13 +38,11 @@ def remove_role(file: ifcopenshell.file, role: ifcopenshell.entity_instance) -> # After running this, the organisation will have no role again ifcopenshell.api.owner.remove_role(model, role=role) """ - settings = {"role": role} - - for inverse in file.get_inverse(settings["role"]): + for inverse in file.get_inverse(role): if inverse.is_a() in ("IfcOrganization", "IfcPerson", "IfcPersonAndOrganization"): - if inverse.Roles == (settings["role"],): + if inverse.Roles == (role,): inverse.Roles = None elif inverse.is_a("IfcResourceLevelRelationship") and not inverse.is_a("IfcOrganizationRelationship"): - if inverse.RelatedResourceObjects == (settings["organisation"],): + if inverse.RelatedResourceObjects == (organisation,): file.remove(inverse) - file.remove(settings["role"]) + file.remove(role) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/settings.py b/src/ifcopenshell-python/ifcopenshell/api/owner/settings.py index 493f4077f4..b5dedfff49 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/settings.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/settings.py @@ -30,17 +30,15 @@ def get_application(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instanc IfcApplication. See ifcopenshell.api.owner.create_owner_history for details. :param ifc: The IFC file object that is being edited. - :type ifc: ifcopenshell.file :return: The IfcApplication with metadata of the authoring software. - :rtype: ifcopenshell.entity_instance """ - app = ifc.by_type("IfcApplication") + app = next(iter(ifc.by_type("IfcApplication")), None) if not app and ifc.schema == "IFC2X3": raise Exception( "Please create an application to continue. See the owner.create_owner_history docs for more info." "https://docs.ifcopenshell.org/autoapi/ifcopenshell/api/owner/create_owner_history/index.html" ) - return (app or [None])[0] + return app def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]: @@ -50,17 +48,15 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None IfcApplication. See ifcopenshell.api.owner.create_owner_history for details. :param ifc: The IFC file object that is being edited. - :type ifc: ifcopenshell.file :return: The IfcPersonAndOrganization with metadata of the authoring user. - :rtype: ifcopenshell.entity_instance """ - pao = ifc.by_type("IfcPersonAndOrganization") + pao = next(iter(ifc.by_type("IfcPersonAndOrganization")), None) if not pao and ifc.schema == "IFC2X3": raise Exception( "Please create a user to continue. See the owner.create_owner_history docs for more info." "https://docs.ifcopenshell.org/autoapi/ifcopenshell/api/owner/create_owner_history/index.html" ) - return (pao or [None])[0] + return pao get_application_factory = get_application diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/unassign_actor.py b/src/ifcopenshell-python/ifcopenshell/api/owner/unassign_actor.py index e161e046e2..98279c128d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/unassign_actor.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/unassign_actor.py @@ -29,11 +29,8 @@ def unassign_actor( This means that the actor is no longer responsible for the object. :param relating_actor: The IfcActor who is responsible for the object. - :type relating_actor: ifcopenshell.entity_instance :param related_object: The object the actor is responsible for. - :type related_object: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -55,13 +52,8 @@ def unassign_actor( ifcopenshell.api.owner.unassign_actor(model, relating_actor=manufacturer, related_object=pump_type) """ - settings = { - "relating_actor": relating_actor, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToActor") or rel.RelatingActor != settings["relating_actor"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToActor") or rel.RelatingActor != relating_actor: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -70,6 +62,6 @@ def unassign_actor( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/owner/update_owner_history.py b/src/ifcopenshell-python/ifcopenshell/api/owner/update_owner_history.py index c7cb3b719d..af40aa3b46 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/owner/update_owner_history.py +++ b/src/ifcopenshell-python/ifcopenshell/api/owner/update_owner_history.py @@ -59,9 +59,6 @@ def update_owner_history( # API calls either. ifcopenshell.api.attribute.edit_attributes(model, product=space, attributes={"Name": "Lobby"}) """ - settings = {"element": element} - - element = settings["element"] if not element.is_a("IfcRoot"): return user = ifcopenshell.api.owner.settings.get_user(file) diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile.py index 08892a555b..80f3b8cf71 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile.py @@ -16,12 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import numpy.typing as npt import ifcopenshell.util.unit -from typing import Optional +from ifcopenshell.util.shape_builder import V, SequenceOfVectors, ifc_safe_vector_type +from typing import Optional, Union def add_arbitrary_profile( - file: ifcopenshell.file, profile: list[tuple[float, float]], name: Optional[str] = None + file: ifcopenshell.file, profile: SequenceOfVectors, name: Optional[str] = None ) -> ifcopenshell.entity_instance: """Adds a new arbitrary polyline-based profile @@ -33,13 +35,10 @@ def add_arbitrary_profile( identical. :param profile: A list of coordinates - :type profile: list[tuple[float, float]] :param name: If the profile is semantically significant (i.e. to be managed and reused by the user) then it must be named. Otherwise, this may be left as none. - :type name: str, optional :return: The newly created IfcArbitraryClosedProfileDef - :rtype: ifcopenshell.entity_instance Example: @@ -53,26 +52,30 @@ def add_arbitrary_profile( """ usecase = Usecase() usecase.file = file - usecase.settings = {"profile": profile, "name": name} - return usecase.execute() + return usecase.execute(V(profile), name) class Usecase: - def execute(self): - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - points = [self.convert_si_to_unit(p) for p in self.settings["profile"]] - if self.file.schema == "IFC2X3": - curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points]) - else: - dimensions = len(points[0]) - if dimensions == 2: - ifc_points = self.file.createIfcCartesianPointList2D(points) - elif dimensions == 3: - ifc_points = self.file.createIfcCartesianPointList3D(points) - curve = self.file.createIfcIndexedPolyCurve(ifc_points) - return self.file.createIfcArbitraryClosedProfileDef("AREA", self.settings["name"], curve) + file: ifcopenshell.file - def convert_si_to_unit(self, co): - if isinstance(co, (tuple, list)): - return [self.convert_si_to_unit(o) for o in co] - return co / self.settings["unit_scale"] + def execute(self, profile: npt.NDArray, name: Union[str, None]): + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) + points = self.convert_si_to_unit(profile) + if self.file.schema == "IFC2X3": + curve = self.file.create_entity( + "IfcPolyline", + [self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(p)) for p in points], + ) + else: + dimensions = points.shape[1] + if dimensions == 2: + ifc_points = self.file.create_entity("IfcCartesianPointList2D", ifc_safe_vector_type(points)) + elif dimensions == 3: + ifc_points = self.file.create_entity("IfcCartesianPointList3D", ifc_safe_vector_type(points)) + else: + assert False, f"Invalid dimensions: {dimensions}." + curve = self.file.create_entity("IfcIndexedPolyCurve", ifc_points) + return self.file.create_entity("IfcArbitraryClosedProfileDef", "AREA", name, curve) + + def convert_si_to_unit(self, co: npt.NDArray) -> npt.NDArray: + return co / self.unit_scale diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile_with_voids.py b/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile_with_voids.py index 8412d40e23..b813a3c65f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile_with_voids.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/add_arbitrary_profile_with_voids.py @@ -17,13 +17,15 @@ # along with IfcOpenShell. If not, see . import ifcopenshell.util.unit -from typing import Optional +import numpy.typing as npt +from ifcopenshell.util.shape_builder import SequenceOfVectors, ifc_safe_vector_type, V +from typing import Optional, Union def add_arbitrary_profile_with_voids( file: ifcopenshell.file, - outer_profile: list[tuple[float, float]], - inner_profiles: list[list[tuple[float, float]]], + outer_profile: SequenceOfVectors, + inner_profiles: list[SequenceOfVectors], name: Optional[str] = None, ) -> ifcopenshell.entity_instance: """Adds a new arbitrary polyline-based profile with voids @@ -41,15 +43,11 @@ def add_arbitrary_profile_with_voids( provided in SI meters. :param outer_profile: A list of coordinates - :type profile: list[tuple[float, float]] :param inner_profiles: A list of polylines - :type profile: list[list[tuple[float, float]]] :param name: If the profile is semantically significant (i.e. to be managed and reused by the user) then it must be named. Otherwise, this may be left as none. - :type name: str, optional :return: The newly created IfcArbitraryProfileDefWithVoids - :rtype: ifcopenshell.entity_instance Example: @@ -63,37 +61,52 @@ def add_arbitrary_profile_with_voids( """ usecase = Usecase() usecase.file = file - usecase.settings = {"outer_profile": outer_profile, "inner_profiles": inner_profiles, "name": name} - return usecase.execute() + return usecase.execute(V(outer_profile), [V(p) for p in inner_profiles], name) class Usecase: - def execute(self): - self.settings["unit_scale"] = ifcopenshell.util.unit.calculate_unit_scale(self.file) - outer_points = [self.convert_si_to_unit(p) for p in self.settings["outer_profile"]] - inner_points = [] - for inner_profile in self.settings["inner_profiles"]: - inner_points.append([self.convert_si_to_unit(p) for p in inner_profile]) + file: ifcopenshell.file + + def execute( + self, + outer_profile: npt.NDArray, + inner_profiles: list[npt.NDArray], + name: Union[str, None], + ): + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) + outer_points = self.convert_si_to_unit(outer_profile) + inner_points: list[npt.NDArray] = [] + for inner_profile in inner_profiles: + inner_points.append(self.convert_si_to_unit(inner_profile)) + + inner_curves: list[ifcopenshell.entity_instance] = [] if self.file.schema == "IFC2X3": - outer_curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in outer_points]) - inner_curves = [] + outer_curve = self.file.create_entity( + "IfcPolyline", + [self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(p)) for p in outer_points], + ) for inner_point in inner_points: inner_curves.append( - self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in inner_point]) + self.file.create_entity( + "IfcPolyline", + [self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(p)) for p in inner_point], + ) ) else: - outer_curve = self.file.createIfcIndexedPolyCurve(self.file.createIfcCartesianPointList3D(outer_points)) - inner_curves = [] + outer_curve = self.file.create_entity( + "IfcIndexedPolyCurve", + (self.file.create_entity("IfcCartesianPointList3D", ifc_safe_vector_type(outer_points))), + ) for inner_point in inner_points: - dimensions = len(inner_point[0]) + dimensions = inner_point.shape[1] if dimensions == 2: - ifc_points = self.file.createIfcCartesianPointList2D(inner_point) + ifc_points = self.file.create_entity("IfcCartesianPointList2D", ifc_safe_vector_type(inner_point)) elif dimensions == 3: - ifc_points = self.file.createIfcCartesianPointList3D(inner_point) - inner_curves.append(self.file.createIfcIndexedPolyCurve(ifc_points)) - return self.file.createIfcArbitraryProfileDefWithVoids("AREA", self.settings["name"], outer_curve, inner_curves) + ifc_points = self.file.create_entity("IfcCartesianPointList3D", ifc_safe_vector_type(inner_point)) + else: + assert False, f"Invalid dimensions: {dimensions}." + inner_curves.append(self.file.create_entity("IfcIndexedPolyCurve", ifc_points)) + return self.file.create_entity("IfcArbitraryProfileDefWithVoids", "AREA", name, outer_curve, inner_curves) - def convert_si_to_unit(self, co): - if isinstance(co, (tuple, list)): - return [self.convert_si_to_unit(o) for o in co] - return co / self.settings["unit_scale"] + def convert_si_to_unit(self, co: npt.NDArray) -> npt.NDArray: + return co / self.unit_scale diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/add_parameterized_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/add_parameterized_profile.py index 3a23507e54..97d8ba3f81 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/add_parameterized_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/add_parameterized_profile.py @@ -30,9 +30,7 @@ def add_parameterized_profile(file: ifcopenshell.file, ifc_class: str) -> ifcope :param ifc_class: The subclass of IfcParameterizedProfileDef that you'd like to create. - :type ifc_class: str :return: The newly created element depending on the specified ifc_class. - :rtype: ifcopenshell.entity_instance Example: @@ -42,6 +40,4 @@ def add_parameterized_profile(file: ifcopenshell.file, ifc_class: str) -> ifcope ifc_class="IfcCircleProfileDef") circle.Radius = 1. """ - settings = {"ifc_class": ifc_class} - - return file.create_entity(settings["ifc_class"]) + return file.create_entity(ifc_class) diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/edit_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/edit_profile.py index b1fda065f0..ba7bc3cfeb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/edit_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/edit_profile.py @@ -26,11 +26,8 @@ def edit_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instance, IfcProfileDef, consult the IFC documentation. :param profile: The IfcProfileDef entity you want to edit - :type profile: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -43,7 +40,5 @@ def edit_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instance, ifcopenshell.api.profile.edit_profile(model, profile=circle, attributes={"ProfileName": "1000mm Dia"}) """ - settings = {"profile": profile, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["profile"], name, value) + for name, value in attributes.items(): + setattr(profile, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py index 5e3816fb93..be74271736 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/remove_profile.py @@ -35,11 +35,10 @@ def remove_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instanc circle = 1. ifcopenshell.api.profile.remove_profile(model, profile=circle) """ - settings = {"profile": profile} is_ifc2x3 = file.schema == "IFC2X3" subelements = set() - for attribute in settings["profile"]: + for attribute in profile: if isinstance(attribute, ifcopenshell.entity_instance): subelements.add(attribute) @@ -56,6 +55,6 @@ def remove_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instanc for pset in profile_psets: ifcopenshell.api.pset.remove_pset(file, product=profile, pset=pset) - file.remove(settings["profile"]) + file.remove(profile) for subelement in subelements: ifcopenshell.util.element.remove_deep2(file, subelement) diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index aca33128aa..ff9097c2cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -229,7 +229,9 @@ class Usecase: return next((e for e in self.file.by_type("IfcProfileDef") if e.ProfileName == profile_name), None) elif element.is_a("IfcPresentationStyle"): name = element.Name - return next((e for e in self.file.by_type("IfcPresentationStyle") if e.Name == name), None) + if name is None: + return None + return next((e for e in self.file.by_type(element.is_a()) if e.Name == name), None) else: return None @@ -261,6 +263,8 @@ class Usecase: self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"], "IfcRepresentationItem": ["StyledByItem", "LayerAssignment"], "IfcRepresentation": ["LayerAssignments"], + "IfcProductDefinitionShape": ["HasShapeAspects"], + "IfcRepresentationMap": ["HasShapeAspects"], } self.existing_contexts = self.file.by_type("IfcGeometricRepresentationContext") element = self.add_element(self.settings["element"]) @@ -278,6 +282,8 @@ class Usecase: "LayerAssignments" if self.file.schema == "IFC2X3" else "LayerAssignment", ], "IfcRepresentation": ["LayerAssignments"], + "IfcProductDefinitionShape": ["HasShapeAspects"], + "IfcRepresentationMap": ["HasShapeAspects"], } self.existing_contexts = self.file.by_type("IfcGeometricRepresentationContext") element = self.add_element(self.settings["element"]) @@ -519,6 +525,7 @@ class Usecase: if added_element := reuse_identities.get(element_identity): return added_element + ifc_class = element.is_a() attributes_ = None def get_attributes() -> tuple[W.attribute, ...]: @@ -544,6 +551,13 @@ class Usecase: if existing_material is not None: reuse_identities[element_identity] = existing_material return existing_material + elif element.is_a("IfcPresentationStyle"): + style_name = element.Name + if style_name is not None: + existing_style = next((e for e in ifc_file.by_type(ifc_class) if e.Name == style_name), None) + if existing_style is not None: + reuse_identities[element_identity] = existing_style + return existing_style attrs = {} @@ -592,7 +606,7 @@ class Usecase: attrs[attr_index] = attr_value # Adding entity at the end just to keep it consistent with `file.add`. - new = ifc_file.create_entity(element.is_a()) + new = ifc_file.create_entity(ifc_class) reuse_identities[element_identity] = new for attr_index, attr_value in attrs.items(): new[attr_index] = attr_value diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py index 38da958ed5..6eaa324b82 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py @@ -50,9 +50,7 @@ def create_file(version: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4") -> ifcope # ... and off we go! """ - settings = {"version": version} - - file = ifcopenshell.file(schema=settings["version"]) + file = ifcopenshell.file(schema=version) file.wrapped_data.header.file_name.name = "/dev/null" # Hehehe file.wrapped_data.header.file_name.time_stamp = ( datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().replace(microsecond=0).isoformat() diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py index ffa31faa8e..db585c3276 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/add_pset.py @@ -20,7 +20,7 @@ import ifcopenshell import ifcopenshell.api.owner import ifcopenshell.api.pset import ifcopenshell.guid -from typing import Optional +from typing import Optional, Any def add_pset( @@ -92,12 +92,11 @@ def add_pset( # Add a fire rating property standardised by buildingSMART. ifcopenshell.api.pset.edit_pset(model, pset=pset, properties={"FireRating": "2HR"}) """ - settings = {"product": product, "name": name} is_ifc2x3 = file.schema == "IFC2X3" - if settings["product"].is_a("IfcObject") or settings["product"].is_a("IfcContext"): - for rel in settings["product"].IsDefinedBy or []: - if rel.is_a("IfcRelDefinesByProperties") and rel.RelatingPropertyDefinition.Name == settings["name"]: + if product.is_a("IfcObject") or product.is_a("IfcContext"): + for rel in product.IsDefinedBy or []: + if rel.is_a("IfcRelDefinesByProperties") and rel.RelatingPropertyDefinition.Name == name: return rel.RelatingPropertyDefinition pset = file.create_entity( @@ -105,15 +104,15 @@ def add_pset( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "Name": settings["name"], + "Name": name, }, ) - ifcopenshell.api.pset.assign_pset(file, [settings["product"]], pset) + ifcopenshell.api.pset.assign_pset(file, [product], pset) return pset - elif settings["product"].is_a("IfcTypeObject"): - for definition in settings["product"].HasPropertySets or []: - if definition.Name == settings["name"]: + elif product.is_a("IfcTypeObject"): + for definition in product.HasPropertySets or []: + if definition.Name == name: return definition pset = file.create_entity( @@ -121,42 +120,43 @@ def add_pset( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "Name": settings["name"], + "Name": name, }, ) - ifcopenshell.api.pset.assign_pset(file, [settings["product"]], pset) + ifcopenshell.api.pset.assign_pset(file, [product], pset) return pset # in IFC2X3 IfcMaterialDefinition not yet existed - elif settings["product"].is_a("IfcMaterialDefinition") or settings["product"].is_a("IfcMaterial"): - kwargs = {"Material": settings["product"]} + elif product.is_a("IfcMaterialDefinition") or product.is_a("IfcMaterial"): + kwargs: dict[str, Any] + kwargs = {"Material": product} if file.schema == "IFC2X3": ifc_class = ifc2x3_subclass or "IfcExtendedMaterialProperties" - definitions = (d for d in file.by_type("IfcMaterialProperties") if d.Material == settings["product"]) + definitions = (d for d in file.by_type("IfcMaterialProperties") if d.Material == product) if ifc_class == "IfcExtendedMaterialProperties": - kwargs["Name"] = settings["name"] + kwargs["Name"] = name else: ifc_class = "IfcMaterialProperties" - definitions = settings["product"].HasProperties - kwargs["Name"] = settings["name"] + definitions = product.HasProperties + kwargs["Name"] = name for definition in definitions: # In IFC2X3 not all IfcMaterialProperties has Name - if getattr(definition, "Name", None) == settings["name"]: + if getattr(definition, "Name", None) == name: return definition return file.create_entity(ifc_class, **kwargs) - elif settings["product"].is_a("IfcProfileDef"): + elif product.is_a("IfcProfileDef"): # in IFC2X3 IfcProfileProperties doesn't have Name and we cannot identify them if file.schema != "IFC2X3": - for definition in settings["product"].HasProperties or []: - if definition.Name == settings["name"]: + for definition in product.HasProperties or []: + if definition.Name == name: return definition kwargs = {} - kwargs["ProfileDefinition"] = settings["product"] + kwargs["ProfileDefinition"] = product if file.schema != "IFC2X3": - kwargs["Name"] = settings["name"] + kwargs["Name"] = name if is_ifc2x3: ifc_class = ifc2x3_subclass or "IfcGeneralProfileProperties" @@ -164,4 +164,4 @@ def add_pset( ifc_class = "IfcProfileProperties" return file.create_entity(ifc_class, **kwargs) - raise TypeError(f"Class '{settings['product'].is_a(True)}' doesn't support adding a property set.") + raise TypeError(f"Class '{product.is_a(True)}' doesn't support adding a property set.") diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/add_qto.py b/src/ifcopenshell-python/ifcopenshell/api/pset/add_qto.py index 15646fe9c7..415b8ec187 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/add_qto.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/add_qto.py @@ -19,6 +19,7 @@ import ifcopenshell import ifcopenshell.api.owner import ifcopenshell.guid +from typing import Any def add_qto(file: ifcopenshell.file, product: ifcopenshell.entity_instance, name: str) -> ifcopenshell.entity_instance: @@ -83,9 +84,15 @@ def add_qto(file: ifcopenshell.file, product: ifcopenshell.entity_instance, name class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): - if self.settings["product"].is_a("IfcObject") or self.settings["product"].is_a("IfcContext"): - for rel in self.settings["product"].IsDefinedBy or []: + product: ifcopenshell.entity_instance = self.settings["product"] + name: str = self.settings["name"] + + if product.is_a("IfcObject") or product.is_a("IfcContext"): + for rel in product.IsDefinedBy or []: if ( rel.is_a("IfcRelDefinesByProperties") and rel.RelatingPropertyDefinition.Name == self.settings["name"] @@ -103,14 +110,14 @@ class Usecase: } ) return qto - elif self.settings["product"].is_a("IfcTypeObject"): - for definition in self.settings["product"].HasPropertySets or []: - if definition.Name == self.settings["name"]: + elif product.is_a("IfcTypeObject"): + for definition in product.HasPropertySets or []: + if definition.Name == name: return definition qto = self.create_qto() - has_property_sets = list(self.settings["product"].HasPropertySets or []) + has_property_sets = list(product.HasPropertySets or []) has_property_sets.append(qto) - self.settings["product"].HasPropertySets = has_property_sets + product.HasPropertySets = has_property_sets return qto def create_qto(self): diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py index d6636e9f09..7c27dbd9bf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py @@ -28,11 +28,8 @@ def remove_pset( All properties that are part of this property set are also removed. :param product: The IfcObject to remove the property set from. - :type product: ifcopenshell.entity_instance :param pset: The IfcPropertySet or IfcElementQuantity to remove. - :type pset: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -45,27 +42,25 @@ def remove_pset( # Remove it! ifcopenshell.api.pset.remove_pset(model, product=wall_type, pset=pset) """ - settings = {"product": product, "pset": pset} - to_purge = [] should_remove_pset = True - for inverse in file.get_inverse(settings["pset"]): + for inverse in file.get_inverse(pset): if inverse.is_a("IfcRelDefinesByProperties"): if not inverse.RelatedObjects or len(inverse.RelatedObjects) == 1: to_purge.append(inverse) else: related_objects = list(inverse.RelatedObjects) - related_objects.remove(settings["product"]) + related_objects.remove(product) inverse.RelatedObjects = related_objects should_remove_pset = False if should_remove_pset: properties = [] # Predefined psets have no properties - if settings["pset"].is_a("IfcPropertySet"): - properties = settings["pset"].HasProperties or [] - elif settings["pset"].is_a("IfcQuantitySet"): - properties = settings["pset"].Quantities or [] - elif settings["pset"].is_a() in ("IfcMaterialProperties", "IfcProfileProperties"): - properties = settings["pset"].Properties or [] + if pset.is_a("IfcPropertySet"): + properties = pset.HasProperties or [] + elif pset.is_a("IfcQuantitySet"): + properties = pset.Quantities or [] + elif pset.is_a() in ("IfcMaterialProperties", "IfcProfileProperties"): + properties = pset.Properties or [] for prop in properties: if file.get_total_inverses(prop) != 1: continue @@ -75,8 +70,8 @@ def remove_pset( file.remove(enumeration) file.remove(prop) # IfcMaterialProperties and IfcProfileProperties don't have OwnerHistory - history = getattr(settings["pset"], "OwnerHistory", None) - file.remove(settings["pset"]) + history = getattr(pset, "OwnerHistory", None) + file.remove(pset) if history: ifcopenshell.util.element.remove_deep2(file, history) for element in to_purge: diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_prop_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_prop_template.py index 4a621dd3d1..39d1987fa4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_prop_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_prop_template.py @@ -110,5 +110,6 @@ def add_prop_template( ) has_property_templates = list(pset_template.HasPropertyTemplates or []) has_property_templates.append(prop_template) + has_property_templates.sort(key=lambda pt: pt.Name) pset_template.HasPropertyTemplates = has_property_templates return prop_template diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py index fc1b9a4265..d66e5b2302 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py @@ -71,19 +71,15 @@ def add_pset_template( overridden by occurrences, and is applicable to everything. :param name: The name of the property set - :type name: str,optional :param template_type: Choose from one of PSET_TYPEDRIVENONLY, PSET_TYPEDRIVENOVERRIDE, PSET_OCCURRENCEDRIVEN, PSET_PERFORMANCEDRIVEN, QTO_TYPEDRIVENONLY, QTO_TYPEDRIVENOVERRIDE, QTO_OCCURRENCEDRIVEN, NOTDEFINED - :type template_type: str,optional :param applicable_entity: The entity that this template is allowed to be applied to. For example, IfcWall means that the property set may be assigned to walls only. IfcTypeObject, the default, means that the property set may be assigned to any type. - :type applicable_entity: str,optional :return: The newly created IfcPropertySetTemplate - :rtype: ifcopenshell.entity_instance Example: @@ -99,12 +95,10 @@ def add_pset_template( name="HighVoltage", description="Whether there is a risk of high voltage.", primary_measure_type="IfcBoolean") """ - settings = {"name": name, "template_type": template_type, "applicable_entity": applicable_entity} - return file.create_entity( "IfcPropertySetTemplate", GlobalId=ifcopenshell.guid.new(), - Name=settings["name"], - TemplateType=settings["template_type"], - ApplicableEntity=settings["applicable_entity"], + Name=name, + TemplateType=template_type, + ApplicableEntity=applicable_entity, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_prop_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_prop_template.py index 1984506c30..3677a8e63e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_prop_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_prop_template.py @@ -28,11 +28,8 @@ def edit_prop_template( IfcSimplePropertyTemplate, consult the IFC documentation. :param prop_template: The IfcSimplePropertyTemplate entity you want to edit - :type prop_template: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_pset_template.py index 1682fa150d..d143b5554e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/edit_pset_template.py @@ -28,11 +28,8 @@ def edit_pset_template( IfcPropertySetTemplate, consult the IFC documentation. :param pset_template: The IfcPropertySetTemplate entity you want to edit - :type pset_template: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -45,7 +42,5 @@ def edit_pset_template( ifcopenshell.api.pset_template.edit_pset_template(model, pset_template=template, attributes={"Name": "ABC_RiskFactors"}) """ - settings = {"pset_template": pset_template, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["pset_template"], name, value) + for name, value in attributes.items(): + setattr(pset_template, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py index 99558cf6e8..ca8d5ba55e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py @@ -27,9 +27,7 @@ def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.en templates. :param prop_template: The IfcSimplePropertyTemplate to remove. - :type prop_template: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -44,13 +42,11 @@ def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.en # Let's remove the second one. ifcopenshell.api.pset_template.remove_prop_template(model, prop_template=prop2) """ - settings = {"prop_template": prop_template} - - for inverse in file.get_inverse(settings["prop_template"]): + for inverse in file.get_inverse(prop_template): if len(inverse.HasPropertyTemplates) == 1: inverse.HasPropertyTemplates = [] else: has_property_templates = list(inverse.HasPropertyTemplates) - has_property_templates.remove(settings["prop_template"]) + has_property_templates.remove(prop_template) inverse.HasPropertyTemplates = has_property_templates - ifcopenshell.util.element.remove_deep(file, settings["prop_template"]) + ifcopenshell.util.element.remove_deep(file, prop_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py index de321ea6fd..07d5cf6daf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py @@ -26,9 +26,7 @@ def remove_pset_template(file: ifcopenshell.file, pset_template: ifcopenshell.en along with it. :param pset_template: The IfcPropertySetTemplate to remove. - :type pset_template: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -40,6 +38,4 @@ def remove_pset_template(file: ifcopenshell.file, pset_template: ifcopenshell.en # Let's remove the template. ifcopenshell.api.pset_template.remove_pset_template(model, pset_template=template) """ - settings = {"pset_template": pset_template} - - ifcopenshell.util.element.remove_deep(file, settings["pset_template"]) + ifcopenshell.util.element.remove_deep(file, pset_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py index 41aa8bec8c..691ad2ec6a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource.py @@ -51,20 +51,15 @@ def add_resource( :param parent_resource: If this is a child resource (typically to a crew resource), then nominate the parent IfcConstructionResource here. - :type parent_resource: ifcopenshell.entity_instance, optional :param ifc_class: The class of resource chosen from IfcConstructionEquipmentResource, IfcConstructionMaterialResource, IfcConstructionProductResource, IfcCrewResource, IfcLaborResource, or IfcSubContractResource. - :type ifc_class: str,optional :param name: The name of the resource - :type name: str,optional :param predefined_type: Consult the IFC documentation for the valid predefined types for each type of resource class. - :type predefined_type: str,optional :return: The newly created resource depending on the nominated IFC class. - :rtype: ifcopenshell.entity_instance Example: @@ -76,25 +71,17 @@ def add_resource( # Add some labour to our crew. ifcopenshell.api.resource.add_resource(model, parent_resource=crew, ifc_class="IfcLaborResource") """ - settings = { - "parent_resource": parent_resource, - "ifc_class": ifc_class, - "name": name, - "predefined_type": predefined_type, - } resource = ifcopenshell.api.root.create_entity( file, - ifc_class=settings["ifc_class"], - predefined_type=settings["predefined_type"], - name=settings["name"] or "Unnamed", + ifc_class=ifc_class, + predefined_type=predefined_type, + name=name or "Unnamed", ) # TODO: this is an ambiguity by buildingSMART: Can we nest an IfcCrewResource under an IfcCrewResource ? # https://forums.buildingsmart.org/t/what-are-allowed-to-be-root-level-construction-resources/3550 - if settings["parent_resource"]: - ifcopenshell.api.nest.assign_object( - file, related_objects=[resource], relating_object=settings["parent_resource"] - ) + if parent_resource: + ifcopenshell.api.nest.assign_object(file, related_objects=[resource], relating_object=parent_resource) elif file.schema != "IFC2X3": context = file.by_type("IfcContext")[0] ifcopenshell.api.project.assign_declaration(file, definitions=[resource], relating_context=context) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py index b445c1d1ad..fc67a8d3c7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py @@ -36,15 +36,12 @@ def add_resource_quantity( This base quantity is then used in other calculations. :param resource: The IfcConstructionResource to add a quantity to. - :type resource: ifcopenshell.entity_instance :param ifc_class: The type of quantity to add, chosen from IfcQuantityArea (for material), IfcQuantityCount (for products), IfcQuantityLength (for material), IfcQuantityTime (for equipment or labour), IfcQuantityVolume (for material), and IfcQuantityWeight (for material). - :type ifc_class: str,optional :return: The newly created quantity depending on the IFC class - :rtype: ifcopenshell.entity_instance Example: @@ -65,8 +62,6 @@ def add_resource_quantity( ifcopenshell.api.resource.edit_resource_quantity(model, physical_quantity=quantity, attributes={"TimeValue": 8.0}) """ - settings = {"resource": resource, "ifc_class": ifc_class} - resource_type = resource.is_a() supported_quantities = ifcopenshell.util.resource.RESOURCES_TO_QUANTITIES[resource_type] if ifc_class not in supported_quantities: @@ -75,14 +70,14 @@ def add_resource_quantity( f"Supported quantities: {','.join(supported_quantities)}" ) - quantity = file.create_entity(settings["ifc_class"], Name="Unnamed") + quantity = file.create_entity(ifc_class, Name="Unnamed") # 3 IfcPhysicalSimpleQuantity Value - if settings["ifc_class"] == "IfcQuantityCount": + if ifc_class == "IfcQuantityCount": quantity[3] = 0 else: quantity[3] = 0.0 - old_quantity = settings["resource"].BaseQuantity - settings["resource"].BaseQuantity = quantity + old_quantity = resource.BaseQuantity + resource.BaseQuantity = quantity if old_quantity: ifcopenshell.util.element.remove_deep(file, old_quantity) return quantity diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/assign_resource.py b/src/ifcopenshell-python/ifcopenshell/api/resource/assign_resource.py index 626082bce2..618d10895a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/assign_resource.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/assign_resource.py @@ -42,12 +42,9 @@ def assign_resource( (e.g. if the resource is a labour resource). :param relating_resource: The IfcResource to assign the object to. - :type relating_resource: ifcopenshell.entity_instance :param related_object: The IfcProduct or IfcActor to assign to the object. - :type related_object: ifcopenshell.entity_instance :return: The newly created IfcRelAssignsToResource - :rtype: ifcopenshell.entity_instance Example: @@ -82,26 +79,18 @@ def assign_resource( # This means that UCO is now our crane operator. ifcopenshell.api.resource.assign_resource(model, relating_resource=crane, related_object=actor) """ - settings = { - "relating_resource": relating_resource, - "related_object": related_object, - } - - if settings["related_object"].HasAssignments: - for assignment in settings["related_object"].HasAssignments: - if ( - assignment.is_a("IfclRelAssignsToResource") - and assignment.RelatingResource == settings["relating_resource"] - ): + if related_object.HasAssignments: + for assignment in related_object.HasAssignments: + if assignment.is_a("IfclRelAssignsToResource") and assignment.RelatingResource == relating_resource: return assignment resource_of = None - if settings["relating_resource"].ResourceOf: - resource_of = settings["relating_resource"].ResourceOf[0] + if relating_resource.ResourceOf: + resource_of = relating_resource.ResourceOf[0] if resource_of: related_objects = list(resource_of.RelatedObjects) - related_objects.append(settings["related_object"]) + related_objects.append(related_object) resource_of.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": resource_of}) else: @@ -110,8 +99,8 @@ def assign_resource( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingResource": settings["relating_resource"], + "RelatedObjects": [related_object], + "RelatingResource": relating_resource, } ) return resource_of diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/calculate_resource_usage.py b/src/ifcopenshell-python/ifcopenshell/api/resource/calculate_resource_usage.py index 0a68a83df4..545016bfd2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/calculate_resource_usage.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/calculate_resource_usage.py @@ -25,15 +25,18 @@ import ifcopenshell.util.resource def calculate_resource_usage(file: ifcopenshell.file, resource: ifcopenshell.entity_instance) -> None: - """Calculates the number of resources required to perform scheduled work on a task.""" - settings = {"resource": resource} + """Calculates the number of resources required to perform scheduled work on a task. - if ifcopenshell.util.constraint.is_attribute_locked(settings["resource"], "Usage.ScheduleUsage"): + :param resource: The IfcConstructionResource to calculate the usage for. + :return: None + """ + + if ifcopenshell.util.constraint.is_attribute_locked(resource, "Usage.ScheduleUsage"): return - if not settings["resource"].Usage or not settings["resource"].Usage.ScheduleWork: + if not resource.Usage or not resource.Usage.ScheduleWork: return - task = ifcopenshell.util.resource.get_task_assignments(settings["resource"]) + task = ifcopenshell.util.resource.get_task_assignments(resource) if not task or not task.TaskTime: return @@ -46,7 +49,7 @@ def calculate_resource_usage(file: ifcopenshell.file, resource: ifcopenshell.ent seconds = task_duration.days * hours_per_day * 60 * 60 seconds += task_duration.seconds - person_hours = ifcopenshell.util.date.ifc2datetime(settings["resource"].Usage.ScheduleWork) + person_hours = ifcopenshell.util.date.ifc2datetime(resource.Usage.ScheduleWork) required_resources = person_hours.total_seconds() / seconds - settings["resource"].Usage.ScheduleUsage = float(required_resources) + resource.Usage.ScheduleUsage = float(required_resources) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource.py b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource.py index b5bb899ead..3476032c1a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource.py @@ -26,11 +26,8 @@ def edit_resource(file: ifcopenshell.file, resource: ifcopenshell.entity_instanc IfcResource, consult the IFC documentation. :param resource: The IfcResource entity you want to edit - :type resource: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,7 +39,5 @@ def edit_resource(file: ifcopenshell.file, resource: ifcopenshell.entity_instanc # Change the name of the resource to "Zone A Crew" ifcopenshell.api.resource.edit_resource(model, resource=resource, attributes={"Name": "Foo"}) """ - settings = {"resource": resource, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["resource"], name, value) + for name, value in attributes.items(): + setattr(resource, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_quantity.py index af8f55f94f..87a14774d8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_quantity.py @@ -28,11 +28,8 @@ def edit_resource_quantity( IfC quantity, consult the IFC documentation. :param physical_quantity: The IfC quantity entity you want to edit - :type physical_quantity: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -53,10 +50,5 @@ def edit_resource_quantity( ifcopenshell.api.resource.edit_resource_quantity(model, physical_quantity=time, attributes={"TimeValue": 8.0}) """ - settings = { - "physical_quantity": physical_quantity, - "attributes": attributes, - } - - for name, value in settings["attributes"].items(): - setattr(settings["physical_quantity"], name, value) + for name, value in attributes.items(): + setattr(physical_quantity, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_time.py b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_time.py index 687fe0490d..7326a5fd90 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/edit_resource_time.py @@ -18,6 +18,9 @@ import ifcopenshell import ifcopenshell.api.sequence +import ifcopenshell.util.constraint +import ifcopenshell.util.date +import ifcopenshell.util.resource from typing import Any @@ -30,11 +33,8 @@ def edit_resource_time( IfcResourceTime, consult the IFC documentation. :param resource_time: The IfcResourceTime entity you want to edit - :type resource_time: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -62,25 +62,23 @@ def edit_resource_time( """ usecase = Usecase() usecase.file = file - usecase.settings = {"resource_time": resource_time, "attributes": attributes} - return usecase.execute() + return usecase.execute(resource_time, attributes) class Usecase: - def execute(self): - self.resource = self.get_resource() + file: ifcopenshell.file + + def execute(self, resource_time: ifcopenshell.entity_instance, attributes: dict[str, Any]) -> None: + resource = self.get_resource(resource_time) # If the user specifies both an end date and a duration, the duration takes priority - if ( - self.settings["attributes"].get("ScheduleWork", None) - and "ScheduleFinish" in self.settings["attributes"].keys() - ): - del self.settings["attributes"]["ScheduleFinish"] - if self.settings["attributes"].get("ActualWork", None) and "ActualFinish" in self.settings["attributes"].keys(): - del self.settings["attributes"]["ActualFinish"] + if attributes.get("ScheduleWork", None) and "ScheduleFinish" in attributes.keys(): + del attributes["ScheduleFinish"] + if attributes.get("ActualWork", None) and "ActualFinish" in attributes.keys(): + del attributes["ActualFinish"] - for name, value in self.settings["attributes"].items(): - metrics = ifcopenshell.util.constraint.get_metric_constraints(self.resource, "Usage." + name) + for name, value in attributes.items(): + metrics = ifcopenshell.util.constraint.get_metric_constraints(resource, "Usage." + name) if metrics and ifcopenshell.util.constraint.is_hard_constraint(metrics[0]): continue if value: @@ -88,13 +86,13 @@ class Usecase: value = ifcopenshell.util.date.datetime2ifc(value, "IfcDateTime") elif name == "ScheduleWork" or name == "ActualWork" or name == "RemainingTime": value = ifcopenshell.util.date.datetime2ifc(value, "IfcDuration") - setattr(self.settings["resource_time"], name, value) + setattr(resource_time, name, value) if name == "ScheduleUsage" and ifcopenshell.util.constraint.get_metric_constraints( - self.resource, "Usage.ScheduleWork" + resource, "Usage.ScheduleWork" ): - task = ifcopenshell.util.resource.get_task_assignments(self.resource) + task = ifcopenshell.util.resource.get_task_assignments(resource) if task: ifcopenshell.api.sequence.calculate_task_duration(self.file, task=task) - def get_resource(self): - return [e for e in self.file.get_inverse(self.settings["resource_time"]) if e.is_a("IfcResource")][0] + def get_resource(self, resource_time: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + return next(e for e in self.file.get_inverse(resource_time) if e.is_a("IfcResource")) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py index e7c91e6073..a6b014e1db 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py @@ -22,6 +22,9 @@ import ifcopenshell.util.element def remove_resource_quantity(file: ifcopenshell.file, resource: ifcopenshell.entity_instance) -> None: """Removes the base quantity of a resource + :param resource: The IfcConstructionResource to remove the quantity from. + :return: None + Example: .. code:: python @@ -41,9 +44,7 @@ def remove_resource_quantity(file: ifcopenshell.file, resource: ifcopenshell.ent # let's clean up our mess and remove the quantity. ifcopenshell.api.resource.remove_resource_quantity(model, resource=labour) """ - settings = {"resource": resource} - - old_quantity = settings["resource"].BaseQuantity - settings["resource"].BaseQuantity = None + old_quantity = resource.BaseQuantity + resource.BaseQuantity = None if old_quantity: ifcopenshell.util.element.remove_deep(file, old_quantity) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/unassign_resource.py b/src/ifcopenshell-python/ifcopenshell/api/resource/unassign_resource.py index 76ea9e6d5b..dc0807754d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/unassign_resource.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/unassign_resource.py @@ -29,12 +29,9 @@ def unassign_resource( """Removes the relationship between a resource and object :param relating_resource: The IfcResource to assign the object to. - :type relating_resource: ifcopenshell.entity_instance :param related_object: The IfcProduct or IfcActor to assign to the object. - :type related_object: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -60,13 +57,8 @@ def unassign_resource( ifcopenshell.api.resource.unassign_resource(model, relating_resource=crane, related_object=product) """ - settings = { - "relating_resource": relating_resource, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToResource") or rel.RelatingResource != settings["relating_resource"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToResource") or rel.RelatingResource != relating_resource: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -75,6 +67,6 @@ def unassign_resource( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/root/copy_class.py b/src/ifcopenshell-python/ifcopenshell/api/root/copy_class.py index a55c711e53..167680bd78 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/root/copy_class.py +++ b/src/ifcopenshell-python/ifcopenshell/api/root/copy_class.py @@ -17,12 +17,14 @@ # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.api.material import ifcopenshell.api.root import ifcopenshell.api.system import ifcopenshell.api.geometry import ifcopenshell.util.system import ifcopenshell.util.element import ifcopenshell.util.placement +from typing import Any def copy_class(file: ifcopenshell.file, product: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: @@ -53,9 +55,7 @@ def copy_class(file: ifcopenshell.file, product: ifcopenshell.entity_instance) - connections are still valid. :param product: The IfcProduct to copy. - :type param: ifcopenshell.entity_instance :return: The copied product - :rtype: ifcopenshell.entity_instance Example: @@ -69,23 +69,26 @@ def copy_class(file: ifcopenshell.file, product: ifcopenshell.entity_instance) - """ usecase = Usecase() usecase.file = file - usecase.settings = {"product": product} - return usecase.execute() + return usecase.execute(product) class Usecase: - def execute(self): - result = ifcopenshell.util.element.copy(self.file, self.settings["product"]) + file: ifcopenshell.file + + def execute(self, product: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + result = ifcopenshell.util.element.copy(self.file, product) self.copy_direct_attributes(result) - self.copy_indirect_attributes(self.settings["product"], result) + self.copy_indirect_attributes(product, result) return result - def copy_direct_attributes(self, to_element): + def copy_direct_attributes(self, to_element: ifcopenshell.entity_instance) -> None: self.remove_representations(to_element) self.copy_object_placements(to_element) self.copy_psets(to_element) - def copy_indirect_attributes(self, from_element, to_element): + def copy_indirect_attributes( + self, from_element: ifcopenshell.entity_instance, to_element: ifcopenshell.entity_instance + ) -> None: for inverse in self.file.get_inverse(from_element): if inverse.is_a("IfcRelDefinesByProperties"): # Properties must not be shared between objects for convenience of authoring @@ -157,9 +160,7 @@ class Usecase: inverse.RelatedObjects = [to_element] elif inverse.is_a("IfcRelAssociatesMaterial") and "Set" in inverse.RelatingMaterial.is_a(): inverse = ifcopenshell.util.element.copy(self.file, inverse) - inverse.RelatingMaterial = ifcopenshell.util.element.copy_deep( - self.file, inverse.RelatingMaterial, exclude=["IfcMaterial"] - ) + inverse.RelatingMaterial = ifcopenshell.api.material.copy_material(self.file, inverse.RelatingMaterial) inverse.RelatedObjects = [to_element] else: for i, value in enumerate(inverse): @@ -171,13 +172,13 @@ class Usecase: new_value.append(to_element) inverse[i] = new_value - def remove_representations(self, element): + def remove_representations(self, element: ifcopenshell.entity_instance) -> None: if element.is_a("IfcProduct"): element.Representation = None elif element.is_a("IfcTypeProduct"): element.RepresentationMaps = None - def copy_object_placements(self, element): + def copy_object_placements(self, element: ifcopenshell.entity_instance) -> None: if not element.is_a("IfcProduct") or not element.ObjectPlacement: return element.ObjectPlacement = ifcopenshell.util.element.copy(self.file, element.ObjectPlacement) @@ -185,7 +186,7 @@ class Usecase: self.file, element.ObjectPlacement.RelativePlacement ) - def copy_psets(self, element): + def copy_psets(self, element: ifcopenshell.entity_instance) -> None: if not element.is_a("IfcTypeObject") or not element.HasPropertySets: return element.HasPropertySets = [ diff --git a/src/ifcopenshell-python/ifcopenshell/api/root/create_entity.py b/src/ifcopenshell-python/ifcopenshell/api/root/create_entity.py index a31dc4c00a..ad44cede8d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/root/create_entity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/root/create_entity.py @@ -19,7 +19,7 @@ import ifcopenshell import ifcopenshell.api.owner import ifcopenshell.guid -from typing import Optional +from typing import Optional, Any def create_entity( @@ -71,45 +71,44 @@ def create_entity( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "ifc_class": ifc_class, - "predefined_type": predefined_type, - "name": name, - } - return usecase.execute() + return usecase.execute(ifc_class, predefined_type, name) class Usecase: - def execute(self): + file: ifcopenshell.file + + def execute( + self, ifc_class: str, predefined_type: Optional[str] = None, name: Optional[str] = None + ) -> ifcopenshell.entity_instance: element = self.file.create_entity( - self.settings["ifc_class"], + ifc_class, **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(self.file), } ) - element.Name = self.settings["name"] or None - if self.settings["predefined_type"]: + element.Name = name or None + if predefined_type: if hasattr(element, "PredefinedType"): try: - element.PredefinedType = self.settings["predefined_type"] + element.PredefinedType = predefined_type except: element.PredefinedType = "USERDEFINED" if hasattr(element, "ObjectType"): - element.ObjectType = self.settings["predefined_type"] + element.ObjectType = predefined_type elif hasattr(element, "ElementType"): - element.ElementType = self.settings["predefined_type"] + element.ElementType = predefined_type elif hasattr(element, "ProcessType"): - element.ProcessType = self.settings["predefined_type"] + element.ProcessType = predefined_type elif hasattr(element, "ObjectType"): - element.ObjectType = self.settings["predefined_type"] + element.ObjectType = predefined_type if self.file.schema == "IFC2X3": self.handle_2x3_defaults(element) else: self.handle_4_defaults(element) return element - def handle_2x3_defaults(self, element): + def handle_2x3_defaults(self, element: ifcopenshell.entity_instance) -> None: if element.is_a("IfcElementType"): if hasattr(element, "PredefinedType") and not element.PredefinedType: element.PredefinedType = "NOTDEFINED" @@ -126,7 +125,7 @@ class Usecase: element.ParameterTakesPrecedence = False element.Sizeable = False - def handle_4_defaults(self, element): + def handle_4_defaults(self, element: ifcopenshell.entity_instance) -> None: if element.is_a("IfcElementType"): if hasattr(element, "PredefinedType") and not element.PredefinedType: element.PredefinedType = "NOTDEFINED" diff --git a/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py b/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py index 3e4cd3faf4..0f36891a28 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py +++ b/src/ifcopenshell-python/ifcopenshell/api/root/reassign_class.py @@ -35,6 +35,7 @@ def reassign_class( product: ifcopenshell.entity_instance, ifc_class: str = "IfcBuildingElementProxy", predefined_type: Optional[str] = None, + occurrence_class: Optional[str] = None, ) -> ifcopenshell.entity_instance: """Changes the class of a product @@ -56,10 +57,14 @@ def reassign_class( Reassigning type class to occurrence (and vice versa) is supported. :param product: The IfcProduct that you want to change the class of. - :type product: ifcopenshell.entity_instance :param ifc_class: The new IFC class you want to change it to. :param predefined_type: In case you want to change the predefined type too. User defined types are also allowed, just type what you want. + :param occurrence_class: IFC class to assign to occurrences in case + if provided ``ifc_class`` is IfcTypeProduct. + If omitted, class will be deduced automatically from the type. + Only really needed in IFC2X3, since in IFC4+ there is no ambiguity on + what class to assign to occurrences. :return: The newly modified product. Example: @@ -77,24 +82,23 @@ def reassign_class( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "product": product, - "ifc_class": ifc_class, - "predefined_type": predefined_type, - } - return usecase.execute() + return usecase.execute(product, ifc_class, predefined_type, occurrence_class) class Usecase: file: ifcopenshell.file - def execute(self): - ifc_class: str = self.settings["ifc_class"] - product: ifcopenshell.entity_instance = self.settings["product"] - predefined_type: Union[str, None] = self.settings["predefined_type"] - + def execute( + self, + product: ifcopenshell.entity_instance, + ifc_class: str, + predefined_type: Union[str, None], + occurrence_class: Union[str, None], + ) -> ifcopenshell.entity_instance: + self.occurrence_class = occurrence_class was_type_product_before = product.is_a("IfcTypeProduct") schema = ifcopenshell.schema_by_name(self.file.schema) + is_type_product_after: bool is_type_product_after = schema.declaration_by_name(ifc_class)._is("IfcTypeProduct") if was_type_product_before == is_type_product_after: @@ -161,7 +165,7 @@ class Usecase: for rep in representations: ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) - # Keep IFC valid. + # Keep IFC valid (PlacementForShapeRepresentation). if switch_type == "type_to_occurrence" and representations: ifcopenshell.api.geometry.edit_object_placement(self.file, product=element) @@ -176,7 +180,14 @@ class Usecase: element = self.reassign_class(element, ifc_class, predefined_type) if element.is_a("IfcTypeProduct"): for occurrence in ifcopenshell.util.element.get_types(element): - ifc_class_ = ifcopenshell.util.type.get_applicable_entities(ifc_class, self.file.schema)[0] + if self.occurrence_class: + ifc_class_ = self.occurrence_class + else: + # NOTE: in theory we can skip reassignment in IFC2X3 in some cases + # e.g. if occurrence is IfcRoof and we're reassigning to IfcBuildingElementProxyType + # but currently type_to_entity_map doesn't completely match entity_to_type_map, + # see type.py for more details. + ifc_class_ = next(iter(ifcopenshell.util.type.get_applicable_entities(ifc_class, self.file.schema))) self.reassign_class(occurrence, ifc_class_, predefined_type) else: element_type = ifcopenshell.util.element.get_type(element) diff --git a/src/ifcopenshell-python/ifcopenshell/api/root/remove_product.py b/src/ifcopenshell-python/ifcopenshell/api/root/remove_product.py index 2df1b4eaa5..1ab4bc432e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/root/remove_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/root/remove_product.py @@ -62,42 +62,38 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc # No we don't. ifcopenshell.api.root.remove_product(model, product=wall) """ - settings = {"product": product} - - representations = [] - if settings["product"].is_a("IfcProduct"): - if settings["product"].Representation: - representations = settings["product"].Representation.Representations or [] + representations: list[ifcopenshell.entity_instance] = [] + if product.is_a("IfcProduct"): + if product.Representation: + representations = product.Representation.Representations or [] else: representations = [] # remove object placements - object_placement = settings["product"].ObjectPlacement + object_placement = product.ObjectPlacement if object_placement: if file.get_total_inverses(object_placement) == 1: - settings["product"].ObjectPlacement = None # remove the inverse for remove_deep2 to work + product.ObjectPlacement = None # remove the inverse for remove_deep2 to work ifcopenshell.util.element.remove_deep2(file, object_placement) - elif settings["product"].is_a("IfcTypeProduct"): - representations = [rm.MappedRepresentation for rm in settings["product"].RepresentationMaps or []] + elif product.is_a("IfcTypeProduct"): + representations = [rm.MappedRepresentation for rm in product.RepresentationMaps or []] # remove psets - psets = settings["product"].HasPropertySets or [] + psets = product.HasPropertySets or [] for pset in psets: if file.get_total_inverses(pset) != 1: continue - ifcopenshell.api.pset.remove_pset(file, product=settings["product"], pset=pset) + ifcopenshell.api.pset.remove_pset(file, product=product, pset=pset) for representation in representations: - ifcopenshell.api.geometry.unassign_representation( - file, product=settings["product"], representation=representation - ) - ifcopenshell.api.geometry.remove_representation(file, **{"representation": representation}) - for opening in getattr(settings["product"], "HasOpenings", []) or []: + ifcopenshell.api.geometry.unassign_representation(file, product=product, representation=representation) + ifcopenshell.api.geometry.remove_representation(file, representation=representation) + for opening in getattr(product, "HasOpenings", []) or []: ifcopenshell.api.feature.remove_feature(file, feature=opening.RelatedOpeningElement) - if settings["product"].is_a("IfcGrid"): - for axis in settings["product"].UAxes + settings["product"].VAxes + (settings["product"].WAxes or ()): + if product.is_a("IfcGrid"): + for axis in product.UAxes + product.VAxes + (product.WAxes or ()): ifcopenshell.api.grid.remove_grid_axis(file, axis=axis) def element_exists(element_id): @@ -108,22 +104,20 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc return False # TODO: remove object placement and other relationships - for inverse_id in [i.id() for i in file.get_inverse(settings["product"])]: + for inverse_id in [i.id() for i in file.get_inverse(product)]: try: inverse = file.by_id(inverse_id) except: continue if inverse.is_a("IfcRelDefinesByProperties"): - ifcopenshell.api.pset.remove_pset( - file, product=settings["product"], pset=inverse.RelatingPropertyDefinition - ) + ifcopenshell.api.pset.remove_pset(file, product=product, pset=inverse.RelatingPropertyDefinition) elif inverse.is_a("IfcRelAssociatesMaterial"): - ifcopenshell.api.material.unassign_material(file, products=[settings["product"]]) + ifcopenshell.api.material.unassign_material(file, products=[product]) elif inverse.is_a("IfcRelDefinesByType"): - if inverse.RelatingType == settings["product"]: + if inverse.RelatingType == product: ifcopenshell.api.type.unassign_type(file, related_objects=inverse.RelatedObjects) else: - ifcopenshell.api.type.unassign_type(file, related_objects=[settings["product"]]) + ifcopenshell.api.type.unassign_type(file, related_objects=[product]) elif inverse.is_a("IfcRelSpaceBoundary"): ifcopenshell.api.boundary.remove_boundary(file, boundary=inverse) elif inverse.is_a("IfcRelFillsElement"): @@ -142,7 +136,7 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelNests"): - if inverse.RelatingObject == settings["product"]: + if inverse.RelatingObject == product: inverse_id = inverse.id() for subelement in inverse.RelatedObjects: if subelement.is_a("IfcDistributionPort"): @@ -153,27 +147,27 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - elif inverse.RelatedObjects == (settings["product"],): + elif inverse.RelatedObjects == (product,): history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAggregates"): - if inverse.RelatingObject == settings["product"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingObject == product or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelContainedInSpatialStructure"): - if inverse.RelatingStructure == settings["product"] or len(inverse.RelatedElements) == 1: + if inverse.RelatingStructure == product or len(inverse.RelatedElements) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelConnectsElements"): if inverse.is_a("IfcRelConnectsWithRealizingElements"): - if settings["product"] not in (inverse.RelatingElement, inverse.RelatedElement) and any( - el for el in inverse.RealizingElements if el != settings["product"] + if product not in (inverse.RelatingElement, inverse.RelatedElement) and any( + el for el in inverse.RealizingElements if el != product ): continue history = inverse.OwnerHistory @@ -181,15 +175,15 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelConnectsPortToElement"): - if inverse.RelatedElement == settings["product"]: + if inverse.RelatedElement == product: ifcopenshell.api.root.remove_product(file, product=inverse.RelatingPort) - elif inverse.RelatingPort == settings["product"]: + elif inverse.RelatingPort == product: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelConnectsPorts"): - if settings["product"] not in (inverse.RelatingPort, inverse.RelatedPort): + if product not in (inverse.RelatingPort, inverse.RelatedPort): # if it's not RelatingPort/RelatedPort then it's optional RealizingElement # so we keep the relationship continue @@ -204,7 +198,7 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAssignsToProduct"): - if inverse.RelatingProduct == settings["product"]: + if inverse.RelatingProduct == product: history = inverse.OwnerHistory file.remove(inverse) if history: @@ -215,17 +209,17 @@ def remove_product(file: ifcopenshell.file, product: ifcopenshell.entity_instanc if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelFlowControlElements"): - if inverse.RelatingFlowElement == settings["product"]: + if inverse.RelatingFlowElement == product: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - elif inverse.RelatedControlElements == (settings["product"],): + elif inverse.RelatedControlElements == (product,): history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["product"].OwnerHistory - file.remove(settings["product"]) + history = product.OwnerHistory + file.remove(product) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py index 1e1b4fe2df..f6fa8feb8e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task.py @@ -69,23 +69,16 @@ def add_task( :param work_schedule: The work schedule to group the task in, if the task is to be a top-level or root task. This is mutually exclusive with the parent_task parameter. - :type work_schedule: ifcopenshell.entity_instance, optional :param parent_task: The parent task, if the task is to be a subtask or child task. This is mutually exclusive with the work_schedule parameter. - :type parent_task: ifcopenshell.entity_instance, optioanl :param name: The name of the task. - :type name: str,optional :param description: The description of the task. - :type description: str,optional :param identification: The identification code of the task. - :type identification: str,optional :param predefined_type: The predefined type of the task. Common ones include CONSTRUCTION, DEMOLITION, or MAINTENANCE. Consultant the IFC documentation for IfcTaskTypeEnum for more information. - :type predefined_type: str :return: The newly created IfcTask - :rtype: ifcopenshell.entity_instance Example: @@ -139,39 +132,28 @@ def add_task( ifcopenshell.api.sequence.add_task(model, parent_task=cleaning, identification="3", description="Setup the water pressure by tapping to a water supply and connecting to a ...") """ - settings = { - "work_schedule": work_schedule, - "parent_task": parent_task, - "name": name, - "description": description, - "identification": identification, - "predefined_type": predefined_type, - } - - task = ifcopenshell.api.root.create_entity( - file, ifc_class="IfcTask", name=settings["name"], predefined_type=settings["predefined_type"] - ) - if settings["description"]: - task.Description = settings["description"] - if settings["identification"]: - task.Identification = settings["identification"] + task = ifcopenshell.api.root.create_entity(file, ifc_class="IfcTask", name=name, predefined_type=predefined_type) + if description: + task.Description = description + if identification: + task.Identification = identification task.IsMilestone = False - if settings["work_schedule"]: + if work_schedule: file.create_entity( "IfcRelAssignsToControl", **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), "RelatedObjects": [task], - "RelatingControl": settings["work_schedule"], + "RelatingControl": work_schedule, } ) - elif settings["parent_task"]: + elif parent_task: rel = ifcopenshell.api.nest.assign_object( file, related_objects=[task], - relating_object=settings["parent_task"], + relating_object=parent_task, ) - if file.schema != "IFC2X3" and settings["parent_task"].Identification: - task.Identification = settings["parent_task"].Identification + "." + str(len(rel.RelatedObjects)) + if file.schema != "IFC2X3" and parent_task.Identification: + task.Identification = parent_task.Identification + "." + str(len(rel.RelatedObjects)) return task diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task_time.py index 89037d4eb0..38d64aa1bc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_task_time.py @@ -29,11 +29,8 @@ def add_task_time( (especially for maintenance tasks). :param task: The task to add time data to. - :type task: ifcopenshell.entity_instance :param is_recurring: Whether or not the time should recur. - :type is_recurring: bool :return: The newly created IfcTaskTime. - :rtype: ifcopenshell.entity_instance Example: @@ -61,11 +58,9 @@ def add_task_time( ifcopenshell.api.sequence.edit_task_time(model, task_time=time, attributes={"ScheduleStart": "2000-01-01", "ScheduleDuration": "P2D"}) """ - settings = {"task": task, "is_recurring": is_recurring} - - if settings["is_recurring"]: + if is_recurring: task_time = file.create_entity("IfcTaskTimeRecurring") else: task_time = file.create_entity("IfcTaskTime") - settings["task"].TaskTime = task_time + task.TaskTime = task_time return task_time diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_time_period.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_time_period.py index 28814da438..41e0785b1d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_time_period.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_time_period.py @@ -44,17 +44,13 @@ def add_time_period( :param recurrence_pattern: The IfcRecurrencePattern to add the time period to. See ifcopenshell.api.sequence.assign_recurrence_pattern. - :type recurrence_pattern: ifcopenshell.entity_instance :param start_time: The start time of the time period, in a format compatible with IfcTime, such as an ISO format time string or a datetime.time object. - :type start_time: str,datetime.time :param end_time: The end time of the time period, in a format compatible with IfcTime, such as an ISO format time string or a datetime.time object. - :type end_time: str,datetime.time :return: The newly created IfcTimePeriod - :rtype: ifcopenshell.entity_instance Example: @@ -81,18 +77,12 @@ def add_time_period( ifcopenshell.api.sequence.add_time_period(model, recurrence_pattern=pattern, start_time="13:00", end_time="17:00") """ - settings = { - "recurrence_pattern": recurrence_pattern, - "start_time": start_time, - "end_time": end_time, - } - time_period = file.create_entity("IfcTimePeriod") - time_period.StartTime = ifcopenshell.util.date.datetime2ifc(settings["start_time"], "IfcTime") - time_period.EndTime = ifcopenshell.util.date.datetime2ifc(settings["end_time"], "IfcTime") - time_periods = list(settings["recurrence_pattern"].TimePeriods or []) + time_period.StartTime = ifcopenshell.util.date.datetime2ifc(start_time, "IfcTime") + time_period.EndTime = ifcopenshell.util.date.datetime2ifc(end_time, "IfcTime") + time_periods = list(recurrence_pattern.TimePeriods or []) time_periods.append(time_period) - settings["recurrence_pattern"].TimePeriods = time_periods + recurrence_pattern.TimePeriods = time_periods ifcopenshell.util.sequence.is_working_day.cache_clear() ifcopenshell.util.sequence.is_calendar_applicable.cache_clear() diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_calendar.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_calendar.py index e4af1d88b2..254188dcd8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_calendar.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_calendar.py @@ -38,12 +38,10 @@ def add_work_calendar( :param name: The name of the calendar. Typically something like "5 Day Working Week" or "24/7". - :type name: str, optional :param predefined_type: The type of calendar, typically used to more specifically define shifts, such as FIRSTSHIFT, SECONDSHIFT, or THIRDSHIFT. Leave as NOTDEFINED for basic calendar usage. :return: The newly created IfcWorkCalendar - :rtype: ifcopenshell.entity_instance Example: @@ -79,13 +77,11 @@ def add_work_calendar( # this calendar by default (though you can override them). ifcopenshell.api.control.assign_control(model, relating_control=calendar, related_object=task) """ - settings = {"name": name, "predefined_type": predefined_type} - work_calendar = ifcopenshell.api.root.create_entity( file, ifc_class="IfcWorkCalendar", - predefined_type=settings["predefined_type"], - name=settings["name"], + predefined_type=predefined_type, + name=name, ) context = file.by_type("IfcContext")[0] ifcopenshell.api.project.assign_declaration( diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_plan.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_plan.py index 0bd53cb14f..2cb4f05ec6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_plan.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_plan.py @@ -41,15 +41,11 @@ def add_work_plan( :param name: The name of the work plan. Recommended to be "Maintenance" or "Construction" for the two main purposes. - :type name: str, optional :param predefined_type: The type of work plan, used for baselining. Leave as "NOTDEFINED" if unsure. - :type predefined_type: str :param start_time: The earliest start time when the schedules grouped within the work plan are relevant. - :type start_time: str,datetime.time :return: The newly created IfcWorkPlan - :rtype: ifcopenshell.entity_instance Example: @@ -62,23 +58,18 @@ def add_work_plan( schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A", work_plan=work_plan) """ - settings = { - "name": name, - "predefined_type": predefined_type, - "start_time": start_time or datetime.now(), - } - + start_time = start_time or datetime.now() work_plan = ifcopenshell.api.root.create_entity( file, ifc_class="IfcWorkPlan", - predefined_type=settings["predefined_type"], - name=settings["name"], + predefined_type=predefined_type, + name=name, ) work_plan.CreationDate = ifcopenshell.util.date.datetime2ifc(datetime.now(), "IfcDateTime") user = ifcopenshell.api.owner.settings.get_user(file) if user: work_plan.Creators = [user.ThePerson] - work_plan.StartTime = ifcopenshell.util.date.datetime2ifc(settings["start_time"], "IfcDateTime") + work_plan.StartTime = ifcopenshell.util.date.datetime2ifc(start_time, "IfcDateTime") context = file.by_type("IfcContext")[0] ifcopenshell.api.project.assign_declaration( diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_schedule.py index 4bdc77b955..d81998021b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_schedule.py @@ -77,19 +77,12 @@ def add_work_schedule( construction = ifcopenshell.api.sequence.add_task(model, work_schedule=schedule, name="Construction", identification="C") """ - settings = { - "name": name, - "predefined_type": predefined_type, - "object_type": object_type, - "start_time": start_time or datetime.now(), - "work_plan": work_plan, - } - + start_time = start_time or datetime.now() work_schedule = ifcopenshell.api.root.create_entity( file, ifc_class="IfcWorkSchedule", - predefined_type=settings["predefined_type"], - name=settings["name"], + predefined_type=predefined_type, + name=name, ) if file.schema == "IFC2X3": work_schedule.CreationDate = createIfcDateAndTime(file, datetime.now()) @@ -99,17 +92,17 @@ def add_work_schedule( if user: work_schedule.Creators = [user.ThePerson] if file.schema == "IFC2X3": - work_schedule.StartTime = createIfcDateAndTime(file, settings["start_time"]) + work_schedule.StartTime = createIfcDateAndTime(file, start_time) else: - work_schedule.StartTime = ifcopenshell.util.date.datetime2ifc(settings["start_time"], "IfcDateTime") - if settings["object_type"]: - work_schedule.ObjectType = settings["object_type"] - if settings["work_plan"]: + work_schedule.StartTime = ifcopenshell.util.date.datetime2ifc(start_time, "IfcDateTime") + if object_type: + work_schedule.ObjectType = object_type + if work_plan: ifcopenshell.api.aggregate.assign_object( file, **{ "products": [work_schedule], - "relating_object": settings["work_plan"], + "relating_object": work_plan, } ) elif file.schema != "IFC2X3": diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_time.py index af55e01a2e..05b4960e09 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/add_work_time.py @@ -36,12 +36,9 @@ def add_work_time( :param work_calendar: The IfcWorkCalendar to add the work or holiday time definition to. - :type work_calendar: ifcopenshell.entity_instance :param time_type: Either WorkingTimes or ExceptionTimes, depending on what you want to define. - :type time_type: str :return: The newly created IfcWorkTime - :rtype: ifcopenshell.entity_instance Example: @@ -74,15 +71,13 @@ def add_work_time( ifcopenshell.api.sequence.edit_recurrence_pattern(model, recurrence_pattern=pattern, attributes={"DayComponent": [1], "MonthComponent": [1]}) """ - settings = {"work_calendar": work_calendar, "time_type": time_type} - work_time = file.create_entity("IfcWorkTime") - if settings["time_type"] == "WorkingTimes": - working_times = list(settings["work_calendar"].WorkingTimes or []) + if time_type == "WorkingTimes": + working_times = list(work_calendar.WorkingTimes or []) working_times.append(work_time) - settings["work_calendar"].WorkingTimes = working_times - elif settings["time_type"] == "ExceptionTimes": - exception_times = list(settings["work_calendar"].ExceptionTimes or []) + work_calendar.WorkingTimes = working_times + elif time_type == "ExceptionTimes": + exception_times = list(work_calendar.ExceptionTimes or []) exception_times.append(work_time) - settings["work_calendar"].ExceptionTimes = exception_times + work_calendar.ExceptionTimes = exception_times return work_time diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_lag_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_lag_time.py index 65ea1db9bd..2c08ab00a3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_lag_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_lag_time.py @@ -34,17 +34,13 @@ def assign_lag_time( are allowed. :param rel_sequence: The IfcRelSequence to assign the lag time to. - :type rel_sequence: ifcopenshell.entity_instance :param lag_value: An ISO standardised duration string. - :type lag_value: str :param duration_type: Choose from WORKTIME for the associated calendar-based lag times (this is the most common scenario and is recommended as a default), or ELAPSEDTIME to not follow the calendar. You may also choose NOTDEFINED but the behaviour of this is unclear. - :type duration_type: str :return: The newly created IfcLagTime - :rtype: ifcopenshell.entity_instance Example: @@ -84,16 +80,10 @@ def assign_lag_time( # for whatever reason. ifcopenshell.api.sequence.assign_lag_time(model, rel_sequence=sequence, lag_value="P1D") """ - settings = { - "rel_sequence": rel_sequence, - "lag_value": lag_value, - "duration_type": duration_type, - } - - lag_value = file.createIfcDuration(ifcopenshell.util.date.datetime2ifc(settings["lag_value"], "IfcDuration")) - lag_time = file.create_entity("IfcLagTime", DurationType=settings["duration_type"], LagValue=lag_value) - if settings["rel_sequence"].is_a("IfcRelSequence"): - if settings["rel_sequence"].TimeLag and len(file.get_inverse(settings["rel_sequence"].TimeLag)) == 1: - file.remove(settings["rel_sequence"].TimeLag) - settings["rel_sequence"].TimeLag = lag_time + duration = file.create_entity("IfcDuration", ifcopenshell.util.date.datetime2ifc(lag_value, "IfcDuration")) + lag_time = file.create_entity("IfcLagTime", DurationType=duration_type, LagValue=duration) + if rel_sequence.is_a("IfcRelSequence"): + if (current_lag_time := rel_sequence.TimeLag) and file.get_total_inverses(current_lag_time) == 1: + file.remove(current_lag_time) + rel_sequence.TimeLag = lag_time return lag_time diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py index fac605ac40..238e9eedc2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py @@ -65,12 +65,9 @@ def assign_process( :param relating_process: The IfcProcess (typically IfcTask) that the input, control, or resource is related to. - :type relating_process: ifcopenshell.entity_instance :param related_object: The IfcProduct (for input), IfcCostItem (for control) or IfcConstructionResource (for resource). - :type related_object: ifcopenshell.entity_instance :return: The newly created IfcRelAssignsToProcess relationship - :rtype: ifcopenshell.entity_instance Example: @@ -91,23 +88,18 @@ def assign_process( # Let's demolish that wall! ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall) """ - settings = { - "relating_process": relating_process, - "related_object": related_object, - } - - if settings["related_object"].HasAssignments: - for assignment in settings["related_object"].HasAssignments: - if assignment.is_a("IfcRelAssignsToProcess") and assignment.RelatingProcess == settings["relating_process"]: + if related_object.HasAssignments: + for assignment in related_object.HasAssignments: + if assignment.is_a("IfcRelAssignsToProcess") and assignment.RelatingProcess == relating_process: return operates_on = None - if settings["relating_process"].OperatesOn: - operates_on = settings["relating_process"].OperatesOn[0] + if relating_process.OperatesOn: + operates_on = relating_process.OperatesOn[0] if operates_on: related_objects = list(operates_on.RelatedObjects) - related_objects.append(settings["related_object"]) + related_objects.append(related_object) operates_on.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": operates_on}) else: @@ -116,8 +108,8 @@ def assign_process( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingProcess": settings["relating_process"], + "RelatedObjects": [related_object], + "RelatingProcess": relating_process, } ) return operates_on diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_product.py index 65971cdae4..8a657302b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_product.py @@ -41,12 +41,9 @@ def assign_product( :param relating_product: The IfcProduct that was constructed as a result of the task. - :type relating_product: ifcopenshell.entity_instance :param related_object: The IfcProcess (typically IfcTask) of the construction task. - :type related_object: ifcopenshell.entity_instance :return: The newly created IfcRelAssignsToProduct relationship - :rtype: ifcopenshell.entity_instance Example: @@ -67,23 +64,18 @@ def assign_product( # Let's construct that wall! ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=task) """ - settings = { - "relating_product": relating_product, - "related_object": related_object, - } - - if settings["related_object"].HasAssignments: - for assignment in settings["related_object"].HasAssignments: - if assignment.is_a("IfcRelAssignsToProduct") and assignment.RelatingProduct == settings["relating_product"]: + if related_object.HasAssignments: + for assignment in related_object.HasAssignments: + if assignment.is_a("IfcRelAssignsToProduct") and assignment.RelatingProduct == relating_product: return assignment referenced_by = None - if settings["relating_product"].ReferencedBy: - referenced_by = settings["relating_product"].ReferencedBy[0] + if relating_product.ReferencedBy: + referenced_by = relating_product.ReferencedBy[0] if referenced_by: related_objects = list(referenced_by.RelatedObjects) - related_objects.append(settings["related_object"]) + related_objects.append(related_object) referenced_by.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, **{"element": referenced_by}) else: @@ -92,8 +84,8 @@ def assign_product( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedObjects": [settings["related_object"]], - "RelatingProduct": settings["relating_product"], + "RelatedObjects": [related_object], + "RelatingProduct": relating_product, } ) return referenced_by diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_recurrence_pattern.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_recurrence_pattern.py index 0c64fb09e2..7f74fb300b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_recurrence_pattern.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_recurrence_pattern.py @@ -65,11 +65,8 @@ def assign_recurrence_pattern( :param parent: Either an IfcTaskTimeRecurring if you are defining a recurring schedule for a task, or IfcWorkTime if you are defining a recurring pattern for a workdays or holidays in a calendar. - :type parent: ifcopenshell.entity_instance :param recurrence_type: One of the types of recurrences. - :type recurrence_type: str :return: The newly created IfcRecurrencePattern - :rtype: ifcopenshell.entity_instance Example: @@ -108,16 +105,14 @@ def assign_recurrence_pattern( ifcopenshell.api.sequence.edit_recurrence_pattern(model, recurrence_pattern=pattern, attributes={"DayComponent": [1], "Interval": 6}) """ - settings = {"parent": parent, "recurrence_type": recurrence_type} + recurrence = file.create_entity("IfcRecurrencePattern", recurrence_type) - recurrence = file.createIfcRecurrencePattern(settings["recurrence_type"]) - - if settings["parent"].is_a("IfcWorkTime"): - if settings["parent"].RecurrencePattern and len(file.get_inverse(settings["parent"].RecurrencePattern)) == 1: - file.remove(settings["parent"].RecurrencePattern) - settings["parent"].RecurrencePattern = recurrence - elif settings["parent"].is_a("IfcTaskTimeRecurring"): - if recurrence_old := settings["parent"].Recurrence and len(file.get_inverse(recurrence_old)) == 1: + if parent.is_a("IfcWorkTime"): + if (old_recurrence := parent.RecurrencePattern) and file.get_total_inverses(old_recurrence) == 1: + file.remove(old_recurrence) + parent.RecurrencePattern = recurrence + elif parent.is_a("IfcTaskTimeRecurring"): + if (recurrence_old := parent.Recurrence) and file.get_total_inverses(recurrence_old) == 1: file.remove(recurrence_old) - settings["parent"].Recurrence = recurrence + parent.Recurrence = recurrence return recurrence diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py index 664d5780ec..65491604aa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py @@ -51,13 +51,10 @@ def assign_sequence( predecessor and successor tasks in the planning profession. :param relating_process: The previous / predecessor task. - :type relating_process: ifcopenshell.entity_instance :param related_process: The next / successor task. - :type related_process: ifcopenshell.entity_instance :param sequence_type: Choose from FINISH_START, FINISH_FINISH, START_START, or START_FINISH. :return: The newly created IfcRelSequence - :rtype: ifcopenshell.entity_instance Example: @@ -109,24 +106,18 @@ def assign_sequence( # to be 2000-01-05. ifcopenshell.api.sequence.cascade_schedule(model, task=formwork) """ - settings = { - "relating_process": relating_process, - "related_process": related_process, - "sequence_type": sequence_type, - } - - for rel in settings["related_process"].IsSuccessorFrom or []: - if rel.RelatingProcess == settings["relating_process"]: + for rel in related_process.IsSuccessorFrom or []: + if rel.RelatingProcess == relating_process: return rel rel = file.create_entity( "IfcRelSequence", **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatingProcess": settings["relating_process"], - "RelatedProcess": settings["related_process"], - "SequenceType": settings["sequence_type"], + "RelatingProcess": relating_process, + "RelatedProcess": related_process, + "SequenceType": sequence_type, } ) - ifcopenshell.api.sequence.cascade_schedule(file, task=settings["relating_process"]) + ifcopenshell.api.sequence.cascade_schedule(file, task=relating_process) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_work_plan.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_work_plan.py index 0a32b9d580..4b2fd704f4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_work_plan.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_work_plan.py @@ -19,11 +19,12 @@ import ifcopenshell import ifcopenshell.api.project import ifcopenshell.api.aggregate +from typing import Union def assign_work_plan( file: ifcopenshell.file, work_schedule: ifcopenshell.entity_instance, work_plan: ifcopenshell.entity_instance -) -> ifcopenshell.entity_instance: +) -> Union[ifcopenshell.entity_instance, None]: """Assigns a work schedule to a work plan Typically, work schedules would be assigned to a work plan at creation. @@ -31,11 +32,8 @@ def assign_work_plan( :param work_schedule: The IfcWorkSchedule that will be assigned to the work plan. - :type work_schedule: ifcopenshell.entity_instance :param work_plan: The IfcWorkPlan for the schedule to be assigned to. - :type work_plan: ifcopenshell.entity_instance :return: The IfcRelAggregates relationship - :rtype: ifcopenshell.entity_instance Example: @@ -50,20 +48,16 @@ def assign_work_plan( # ... you can assign the work plan afterwards. ifcopenshell.api.sequence.assign_work_plan(work_schedule=schedule, work_plan=work_plan) """ - settings = {"work_schedule": work_schedule, "work_plan": work_plan} - # TODO: this is an ambiguity by buildingSMART # See https://forums.buildingsmart.org/t/is-the-ifcworkschedule-project-declaration-mutually-exclusive-to-aggregation-within-a-relating-ifcworkplan/3510 ifcopenshell.api.project.unassign_declaration( file, - definitions=[settings["work_schedule"]], + definitions=[work_schedule], relating_context=file.by_type("IfcContext")[0], ) rel_aggregates = ifcopenshell.api.aggregate.assign_object( file, - **{ - "products": [settings["work_schedule"]], - "relating_object": settings["work_plan"], - } + products=[work_schedule], + relating_object=work_plan, ) return rel_aggregates diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/calculate_task_duration.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/calculate_task_duration.py index 7c3f821e7e..d5f0e5947e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/calculate_task_duration.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/calculate_task_duration.py @@ -20,6 +20,7 @@ import math import ifcopenshell.api.sequence import ifcopenshell.util.date import ifcopenshell.util.element +from typing import Union def calculate_task_duration(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> None: @@ -35,9 +36,7 @@ def calculate_task_duration(file: ifcopenshell.file, task: ifcopenshell.entity_i then nothing happens. :param task: The IfcTask to calculate the duration for. - :type task: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -82,18 +81,20 @@ def calculate_task_duration(file: ifcopenshell.file, task: ifcopenshell.entity_i """ usecase = Usecase() usecase.file = file - usecase.settings = {"task": task} - return usecase.execute() + return usecase.execute(task) class Usecase: - def execute(self): + file: ifcopenshell.file + + def execute(self, task: ifcopenshell.entity_instance) -> None: + self.task = task self.seconds_per_workday = self.calculate_seconds_per_workday() duration = self.calculate_max_resource_usage_duration() if duration: self.set_task_duration(duration) - def calculate_seconds_per_workday(self): + def calculate_seconds_per_workday(self) -> float: def get_work_schedule(task): for rel in task.HasAssignments or []: if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcWorkSchedule"): @@ -102,7 +103,7 @@ class Usecase: return get_work_schedule(rel.RelatingObject) default_seconds_per_workday = 8 * 60 * 60 - work_schedule = get_work_schedule(self.settings["task"]) + work_schedule = get_work_schedule(self.task) if not work_schedule: return default_seconds_per_workday psets = ifcopenshell.util.element.get_psets(work_schedule) @@ -115,9 +116,9 @@ class Usecase: work_day_duration = ifcopenshell.util.date.ifc2datetime(psets["Pset_WorkControlCommon"]["WorkDayDuration"]) return work_day_duration.seconds - def calculate_max_resource_usage_duration(self): + def calculate_max_resource_usage_duration(self) -> float: max_duration = 0 - for rel in self.settings["task"].OperatesOn or []: + for rel in self.task.OperatesOn or []: for related_object in rel.RelatedObjects: if related_object.is_a("IfcConstructionResource"): duration = self.calculate_duration_in_days(related_object) @@ -125,7 +126,7 @@ class Usecase: max_duration = duration return max_duration - def calculate_duration_in_days(self, resource): + def calculate_duration_in_days(self, resource: ifcopenshell.entity_instance) -> Union[float, None]: def is_hourly_work(schedule_work): return "T" in schedule_work @@ -140,7 +141,7 @@ class Usecase: schedule_seconds = (schedule_duration.days + partial_days) * self.seconds_per_workday return math.ceil((schedule_seconds / self.seconds_per_workday) / schedule_usage) - def set_task_duration(self, duration): - if not self.settings["task"].TaskTime: - ifcopenshell.api.sequence.add_task_time(self.file, task=self.settings["task"]) - self.settings["task"].TaskTime.ScheduleDuration = f"P{duration}D" + def set_task_duration(self, duration: float) -> None: + if not (task_time := self.task.TaskTime): + task_time = ifcopenshell.api.sequence.add_task_time(self.file, task=self.task) + task_time.ScheduleDuration = f"P{duration}D" diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py index 8a989ecb55..65cbd5a58a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py @@ -19,6 +19,8 @@ import datetime import ifcopenshell.util.date import ifcopenshell.util.sequence +from ifcopenshell.util.sequence import DURATION_TYPE +from typing import Union, Optional def cascade_schedule(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> None: @@ -41,9 +43,7 @@ def cascade_schedule(file: ifcopenshell.file, task: ifcopenshell.entity_instance be equivalent to be Tuesday 8am, for instance. :param task: The start task to begin cascading from. - :type task: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -103,16 +103,22 @@ def cascade_schedule(file: ifcopenshell.file, task: ifcopenshell.entity_instance """ usecase = Usecase() usecase.file = file - usecase.settings = {"task": task} - return usecase.execute() + return usecase.execute(task) class Usecase: - def execute(self): - self.calendar_cache = {} - self.cascade_task(self.settings["task"], is_first_task=True) + file: ifcopenshell.file - def cascade_task(self, task, is_first_task=False, task_sequence=None): + def execute(self, task: ifcopenshell.entity_instance): + self.calendar_cache = {} + self.cascade_task(task, is_first_task=True) + + def cascade_task( + self, + task: ifcopenshell.entity_instance, + is_first_task: bool = False, + task_sequence: Optional[list[ifcopenshell.entity_instance]] = None, + ) -> None: if task_sequence is None: task_sequence = [] @@ -316,18 +322,22 @@ class Usecase: for nested_task in rel.RelatedObjects or [] ] - def get_lag_time_days(self, lag_time): + def get_lag_time_days(self, lag_time: ifcopenshell.entity_instance) -> int: return ifcopenshell.util.date.ifc2datetime(lag_time.LagValue.wrappedValue).days - def get_calendar(self, task): + def get_calendar(self, task: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: if task.id() not in self.calendar_cache: self.calendar_cache[task.id()] = ifcopenshell.util.sequence.derive_calendar(task) return self.calendar_cache[task.id()] - def offset_date(self, date, days, duration_type, calendar): + def offset_date( + self, date: datetime.datetime, days: int, duration_type: DURATION_TYPE, calendar: ifcopenshell.entity_instance + ) -> datetime.datetime: return ifcopenshell.util.sequence.offset_date(date, datetime.timedelta(days=days), duration_type, calendar) - def get_task_time_attribute(self, task, attribute): + def get_task_time_attribute( + self, task: ifcopenshell.entity_instance, attribute: str + ) -> Union[datetime.datetime, None]: if task.TaskTime: value = getattr(task.TaskTime, attribute) if value: diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/create_baseline.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/create_baseline.py index d0670b76e8..ab1035bbab 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/create_baseline.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/create_baseline.py @@ -24,7 +24,7 @@ import ifcopenshell.guid import ifcopenshell.util.element import ifcopenshell.util.sequence import ifcopenshell.util.system -from typing import Optional +from typing import Optional, Union def create_baseline( @@ -44,11 +44,8 @@ def create_baseline( * Same Resource Relationships :param work_schedule: The planned work_schedule to baseline - :type work_schedule: ifcopenshell.entity_instance :param name: baseline work schedule name - :type name: str, optional :return: The baseline work_schedule - :rtype: ifcopenshell.entity_instance Example: @@ -62,33 +59,34 @@ def create_baseline( """ usecase = Usecase() usecase.file = file - usecase.settings = {"work_schedule": work_schedule, "name": name} - return usecase.execute() + return usecase.execute(work_schedule, name) class Usecase: - def execute(self): - result = self.create_baseline_work_schedule(self.settings["work_schedule"]) - return result + file: ifcopenshell.file - def create_baseline_work_schedule(self, work_schedule): + def execute(self, work_schedule: ifcopenshell.entity_instance, name: Union[str, None]) -> None: # create work schedule if not work_schedule.PredefinedType == "PLANNED": return baseline_work_schedule = ifcopenshell.api.sequence.add_work_schedule( self.file, name=work_schedule.Name, predefined_type="BASELINE" ) - baseline_work_schedule.Name = self.settings["name"] + baseline_work_schedule.Name = name self.create_baseline_reference(work_schedule, baseline_work_schedule) for summary_task in ifcopenshell.util.sequence.get_root_tasks(work_schedule): - current, duplicate = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task) + res = ifcopenshell.api.sequence.duplicate_task(self.file, task=summary_task) + assert isinstance(res, list) + current, duplicate = res ifcopenshell.api.control.assign_control( self.file, relating_control=baseline_work_schedule, related_object=duplicate[0] ) for i, task in enumerate(current): self.create_baseline_reference(task, duplicate[i]) - def create_baseline_reference(self, relating_object, related_object): + def create_baseline_reference( + self, relating_object: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance + ) -> ifcopenshell.entity_instance: referenced_by = None if relating_object.Declares: referenced_by = relating_object.Declares[0] diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/duplicate_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/duplicate_task.py index db47ccd5ec..a70868b084 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/duplicate_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/duplicate_task.py @@ -21,11 +21,15 @@ import ifcopenshell.guid import ifcopenshell.api.nest import ifcopenshell.api.owner import ifcopenshell.api.sequence +import ifcopenshell.util.date import ifcopenshell.util.element import ifcopenshell.util.sequence +from typing import Union, Any -def duplicate_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: +def duplicate_task( + file: ifcopenshell.file, task: ifcopenshell.entity_instance +) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: """Duplicates a task in the project The following relationships are also duplicated: @@ -35,9 +39,7 @@ def duplicate_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) * The copy will have duplicated nested tasks :param task: The task to be duplicated - :type task: ifcopenshell.entity_instance :return: The duplicated task or the list of duplicated tasks if the latter has children - :rtype: ifcopenshell.entity_instance or list of ifcopenshell.entity_instance Example: .. code:: python @@ -55,6 +57,9 @@ def duplicate_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): self.tracker = {"current": [], "duplicate": []} self.duplicate_task(self.settings["task"]) @@ -151,7 +156,9 @@ class Usecase: duration_type=inverse.TimeLag.DurationType, ) - def create_object_reference(self, relating_object, related_object): + def create_object_reference( + self, relating_object: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance + ) -> ifcopenshell.entity_instance: referenced_by = None if relating_object.Declares: referenced_by = relating_object.Declares[0] diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_lag_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_lag_time.py index d3c8a4418a..d55bf0e4b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_lag_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_lag_time.py @@ -28,11 +28,8 @@ def edit_lag_time(file: ifcopenshell.file, lag_time: ifcopenshell.entity_instanc IfcLagTime, consult the IFC documentation. :param lag_time: The IfcLagTime entity you want to edit - :type lag_time: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -75,14 +72,12 @@ def edit_lag_time(file: ifcopenshell.file, lag_time: ifcopenshell.entity_instanc # Or, let's make it 2 days instead. ifcopenshell.api.sequence.edit_lag_time(model, lag_time=lag, attributes={"LagValue": "P2D"}) """ - settings = {"lag_time": lag_time, "attributes": attributes} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if name == "LagValue" and value is not None: if isinstance(value, float): value = file.createIfcRatioMeasure(value) else: value = file.createIfcDuration(ifcopenshell.util.date.datetime2ifc(value, "IfcDuration")) - setattr(settings["lag_time"], name, value) - for rel in [r for r in file.get_inverse(settings["lag_time"]) if r.is_a("IfcRelSequence")]: + setattr(lag_time, name, value) + for rel in [r for r in file.get_inverse(lag_time) if r.is_a("IfcRelSequence")]: ifcopenshell.api.sequence.cascade_schedule(file, task=rel.RelatedProcess) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_recurrence_pattern.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_recurrence_pattern.py index 6b165f716a..519c5cdf25 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_recurrence_pattern.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_recurrence_pattern.py @@ -30,11 +30,8 @@ def edit_recurrence_pattern( IfcRecurrencePattern, consult the IFC documentation. :param recurrence_pattern: The IfcRecurrencePattern entity you want to edit - :type recurrence_pattern: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -55,13 +52,8 @@ def edit_recurrence_pattern( ifcopenshell.api.sequence.edit_recurrence_pattern(model, recurrence_pattern=pattern, attributes={"WeekdayComponent": [1, 2, 3, 4, 5]}) """ - settings = { - "recurrence_pattern": recurrence_pattern, - "attributes": attributes, - } - - for name, value in settings["attributes"].items(): - setattr(settings["recurrence_pattern"], name, value) + for name, value in attributes.items(): + setattr(recurrence_pattern, name, value) ifcopenshell.util.sequence.is_working_day.cache_clear() ifcopenshell.util.sequence.is_calendar_applicable.cache_clear() diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_sequence.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_sequence.py index dee8a32e64..2686ec5ace 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_sequence.py @@ -30,11 +30,8 @@ def edit_sequence( IfcRelSequence, consult the IFC documentation. :param rel_sequence: The IfcRelSequence entity you want to edit - :type rel_sequence: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -62,9 +59,7 @@ def edit_sequence( ifcopenshell.api.sequence.edit_sequence(model, rel_sequence=sequence, attributes={"SequenceType": "START_START"}) """ - settings = {"rel_sequence": rel_sequence, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["rel_sequence"], name, value) - if "SequenceType" in settings["attributes"].keys(): - ifcopenshell.api.sequence.cascade_schedule(file, task=settings["rel_sequence"].RelatedProcess) + for name, value in attributes.items(): + setattr(rel_sequence, name, value) + if "SequenceType" in attributes.keys(): + ifcopenshell.api.sequence.cascade_schedule(file, task=rel_sequence.RelatedProcess) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task.py index dcd218059f..3fa1b88caf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task.py @@ -26,11 +26,8 @@ def edit_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance, attri IfcTask, consult the IFC documentation. :param task: The IfcTask entity you want to edit - :type task: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -48,7 +45,5 @@ def edit_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance, attri # Change the identification ifcopenshell.api.sequence.edit_task(model, task=task, attributes={"Identification": "M"}) """ - settings = {"task": task, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["task"], name, value) + for name, value in attributes.items(): + setattr(task, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py index 6d9d3404d1..119ab82a14 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py @@ -36,11 +36,8 @@ def edit_task_time( IfcTaskTime, consult the IFC documentation. :param task_time: The IfcTaskTime entity you want to edit - :type task_time: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -61,94 +58,89 @@ def edit_task_time( """ usecase = Usecase() usecase.file = file - usecase.settings = {"task_time": task_time, "attributes": attributes} - return usecase.execute() + return usecase.execute(task_time, attributes) class Usecase: - def execute(self): + file: ifcopenshell.file + + def execute(self, task_time: ifcopenshell.entity_instance, attributes: dict[str, Any]) -> None: + self.task_time = task_time self.task = self.get_task() self.calendar = ifcopenshell.util.sequence.derive_calendar(self.task) # If the user specifies both an end date and a duration, the duration takes priority - if ( - self.settings["attributes"].get("ScheduleDuration", None) - and "ScheduleFinish" in self.settings["attributes"].keys() - ): - del self.settings["attributes"]["ScheduleFinish"] + if attributes.get("ScheduleDuration", None) and "ScheduleFinish" in attributes.keys(): + del attributes["ScheduleFinish"] - duration_type = self.settings["attributes"].get("DurationType", self.settings["task_time"].DurationType) - finish = self.settings["attributes"].get("ScheduleFinish", None) + duration_type = attributes.get("DurationType", self.task_time.DurationType) + finish = attributes.get("ScheduleFinish", None) if finish: if isinstance(finish, str): finish = datetime.datetime.fromisoformat(finish) - self.settings["attributes"]["ScheduleFinish"] = datetime.datetime.combine( + attributes["ScheduleFinish"] = datetime.datetime.combine( ifcopenshell.util.sequence.get_soonest_working_day(finish, duration_type, self.calendar), datetime.time(17), ) - start = self.settings["attributes"].get("ScheduleStart", None) + start = attributes.get("ScheduleStart", None) if start: if isinstance(start, str): start = datetime.datetime.fromisoformat(start) - self.settings["attributes"]["ScheduleStart"] = datetime.datetime.combine( + attributes["ScheduleStart"] = datetime.datetime.combine( ifcopenshell.util.sequence.get_soonest_working_day(start, duration_type, self.calendar), datetime.time(9), ) - for name, value in self.settings["attributes"].items(): + for name, value in attributes.items(): if value is not None: if "Start" in name or "Finish" in name or name == "StatusTime": value = ifcopenshell.util.date.datetime2ifc(value, "IfcDateTime") elif name == "ScheduleDuration" or name == "ActualDuration" or name == "RemainingTime": value = ifcopenshell.util.date.datetime2ifc(value, "IfcDuration") - setattr(self.settings["task_time"], name, value) + setattr(self.task_time, name, value) - if ( - "ScheduleDuration" in self.settings["attributes"].keys() - and self.settings["task_time"].ScheduleDuration - and self.settings["task_time"].ScheduleStart - ): + if "ScheduleDuration" in attributes.keys() and task_time.ScheduleDuration and task_time.ScheduleStart: self.calculate_finish() - elif self.settings["attributes"].get("ScheduleStart", None) and self.settings["task_time"].ScheduleDuration: + elif attributes.get("ScheduleStart", None) and task_time.ScheduleDuration: self.calculate_finish() - elif self.settings["attributes"].get("ScheduleFinish", None) and self.settings["task_time"].ScheduleStart: + elif attributes.get("ScheduleFinish", None) and task_time.ScheduleStart: self.calculate_duration() - if self.settings["task_time"].ScheduleDuration and ( - "ScheduleStart" in self.settings["attributes"].keys() - or "ScheduleFinish" in self.settings["attributes"].keys() - or "ScheduleDuration" in self.settings["attributes"].keys() + if task_time.ScheduleDuration and ( + "ScheduleStart" in attributes.keys() + or "ScheduleFinish" in attributes.keys() + or "ScheduleDuration" in attributes.keys() ): ifcopenshell.api.sequence.cascade_schedule(self.file, task=self.task) - if self.settings["task_time"].ScheduleDuration: + if task_time.ScheduleDuration: self.handle_resource_calculation() def calculate_finish(self): finish = ifcopenshell.util.sequence.get_start_or_finish_date( - ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleStart), - ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleDuration), - self.settings["task_time"].DurationType, + ifcopenshell.util.date.ifc2datetime(self.task_time.ScheduleStart), + ifcopenshell.util.date.ifc2datetime(self.task_time.ScheduleDuration), + self.task_time.DurationType, self.calendar, date_type="FINISH", ) - self.settings["task_time"].ScheduleFinish = ifcopenshell.util.date.datetime2ifc(finish, "IfcDateTime") + self.task_time.ScheduleFinish = ifcopenshell.util.date.datetime2ifc(finish, "IfcDateTime") def calculate_duration(self): - start = ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleStart) - finish = ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleFinish) + start = ifcopenshell.util.date.ifc2datetime(self.task_time.ScheduleStart) + finish = ifcopenshell.util.date.ifc2datetime(self.task_time.ScheduleFinish) current_date = datetime.date(start.year, start.month, start.day) finish_date = datetime.date(finish.year, finish.month, finish.day) duration = datetime.timedelta(days=1) while current_date < finish_date: - if self.settings["task_time"].DurationType == "ELAPSEDTIME" or not self.calendar: + if self.task_time.DurationType == "ELAPSEDTIME" or not self.calendar: duration += datetime.timedelta(days=1) elif ifcopenshell.util.sequence.is_working_day(current_date, self.calendar): duration += datetime.timedelta(days=1) current_date += datetime.timedelta(days=1) - self.settings["task_time"].ScheduleDuration = ifcopenshell.util.date.datetime2ifc(duration, "IfcDuration") + self.task_time.ScheduleDuration = ifcopenshell.util.date.datetime2ifc(duration, "IfcDuration") def get_task(self) -> ifcopenshell.entity_instance: - return next(e for e in self.file.get_inverse(self.settings["task_time"]) if e.is_a("IfcTask")) + return next(e for e in self.file.get_inverse(self.task_time) if e.is_a("IfcTask")) def handle_resource_calculation(self): resources = ifcopenshell.util.sequence.get_task_resources(self.task, is_deep=False) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_calendar.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_calendar.py index f82f53e534..83e56a4372 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_calendar.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_calendar.py @@ -28,11 +28,8 @@ def edit_work_calendar( IfcWorkCalendar, consult the IFC documentation. :param work_calendar: The IfcWorkCalendar entity you want to edit - :type work_calendar: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -45,7 +42,5 @@ def edit_work_calendar( ifcopenshell.api.sequence.edit_work_calendar(model, work_calendar=calendar, attributes={"Description": "Monday to Friday 8 hour days"}) """ - settings = {"work_calendar": work_calendar, "attributes": attributes} - - for name, value in settings["attributes"].items(): - setattr(settings["work_calendar"], name, value) + for name, value in attributes.items(): + setattr(work_calendar, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_plan.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_plan.py index 920e2c671e..145460e11c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_plan.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_plan.py @@ -29,11 +29,8 @@ def edit_work_plan( IfcWorkPlan, consult the IFC documentation. :param work_plan: The IfcWorkPlan entity you want to edit - :type work_plan: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -46,12 +43,10 @@ def edit_work_plan( ifcopenshell.api.sequence.edit_work_plan(model, work_plan=work_plan, attributes={"Description": "Construction of phase 1"}) """ - settings = {"work_plan": work_plan, "attributes": attributes} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if value: if "Date" in name or "Time" in name: value = ifcopenshell.util.date.datetime2ifc(value, "IfcDateTime") elif name == "Duration" or name == "TotalFloat": value = ifcopenshell.util.date.datetime2ifc(value, "IfcDuration") - setattr(settings["work_plan"], name, value) + setattr(work_plan, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_schedule.py index 5ed392ddff..98905b9a1f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_schedule.py @@ -29,11 +29,8 @@ def edit_work_schedule( IfcWorkSchedule, consult the IFC documentation. :param work_schedule: The IfcWorkSchedule entity you want to edit - :type work_schedule: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -50,12 +47,10 @@ def edit_work_schedule( ifcopenshell.api.sequence.edit_work_schedule(model, work_schedule=work_schedule, attributes={"Description": "3 crane design option"}) """ - settings = {"work_schedule": work_schedule, "attributes": attributes} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if value: if "Date" in name or "Time" in name: value = ifcopenshell.util.date.datetime2ifc(value, "IfcDateTime") elif name == "Duration" or name == "TotalFloat": value = ifcopenshell.util.date.datetime2ifc(value, "IfcDuration") - setattr(settings["work_schedule"], name, value) + setattr(work_schedule, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_time.py index 07c1f818d5..7fae8b4c18 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_work_time.py @@ -31,11 +31,8 @@ def edit_work_time( IfcWorkTime, consult the IFC documentation. :param work_time: The IfcWorkTime entity you want to edit - :type work_time: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -54,16 +51,14 @@ def edit_work_time( ifcopenshell.api.sequence.edit_work_time(model, work_time=work_time, attributes={"StartDate": "2000-01-01", "FinishDate": "2000-01-02"}) """ - settings = {"work_time": work_time, "attributes": attributes} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if name in ("Start", "StartDate"): value = ifcopenshell.util.date.datetime2ifc(value, "IfcDate") # 4 IfcWorktime Start - settings["work_time"][4] = value + work_time[4] = value elif name in ("Finish", "FinishDate"): value = ifcopenshell.util.date.datetime2ifc(value, "IfcDate") # 5 IfcWorktime Finish - settings["work_time"][5] = value + work_time[5] = value else: - setattr(settings["work_time"], name, value) + setattr(work_time, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py index a7fb9d01e6..14e82822e3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py @@ -35,9 +35,7 @@ def recalculate_schedule(file: ifcopenshell.file, work_schedule: ifcopenshell.en error. :param work_schedule: The IfcWorkSchedule to perform the calculation on. - :type work_schedule: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -50,12 +48,14 @@ def recalculate_schedule(file: ifcopenshell.file, work_schedule: ifcopenshell.en """ usecase = Usecase() usecase.file = file - usecase.settings = {"work_schedule": work_schedule} - return usecase.execute() + return usecase.execute(work_schedule) class Usecase: - def execute(self): + file: ifcopenshell.file + + def execute(self, work_schedule: ifcopenshell.entity_instance) -> None: + self.work_schedule = work_schedule # The method implemented is the same as shown here: # https://www.youtube.com/watch?v=qTErIV6OqLg self.start_dates = [] @@ -88,7 +88,6 @@ class Usecase: if is_cyclic: raise RecursionError("Task graph is cyclic and so critical path method cannot be performed.") - return self.pending_nodes = set(self.g.nodes) while self.pending_nodes: @@ -100,7 +99,7 @@ class Usecase: self.update_task_times() - def build_network_graph(self): + def build_network_graph(self) -> None: self.sequence_type_map = { None: "FS", "START_START": "SS", @@ -114,14 +113,14 @@ class Usecase: self.edges = [] self.g.add_node("start", duration=0, duration_type="ELAPSEDTIME", calendar=None) self.g.add_node("finish", duration=0, duration_type="ELAPSEDTIME", calendar=None) - for rel in self.settings["work_schedule"].Controls: + for rel in self.work_schedule.Controls: for related_object in rel.RelatedObjects: if not related_object.is_a("IfcTask"): continue self.add_node(related_object) self.g.add_edges_from(self.edges) - def add_node(self, task): + def add_node(self, task: ifcopenshell.entity_instance) -> None: if task.IsNestedBy: for rel in task.IsNestedBy: [self.add_node(o) for o in rel.RelatedObjects] @@ -176,7 +175,7 @@ class Usecase: if not successor_types: self.edges.append((task.id(), "finish", {"lag_time": 0, "type": "FF"})) - def update_task_times(self): + def update_task_times(self) -> None: for ifc_definition_id in self.g.nodes: if ifc_definition_id in ("start", "finish"): continue @@ -198,12 +197,12 @@ class Usecase: }, ) - def offset_date(self, date, days, node): + def offset_date(self, date: datetime.datetime, days: int, node: dict) -> datetime.datetime: return ifcopenshell.util.sequence.offset_date( date, datetime.timedelta(days=days), node["duration_type"], node["calendar"] ) - def forward_pass(self, node): + def forward_pass(self, node) -> bool: successors = self.g.successors(node) predecessors = list(self.g.predecessors(node)) data = self.g.nodes[node] @@ -326,7 +325,7 @@ class Usecase: return True - def backward_pass(self, node): + def backward_pass(self, node) -> bool: successors = list(self.g.successors(node)) predecessors = self.g.predecessors(node) data = self.g.nodes[node] @@ -496,12 +495,12 @@ class Usecase: def calculate_free_float( self, - predecessor_date, - successor_date, - lag_time, - predecessor_data, - successor_data, - ): + predecessor_date: datetime.datetime, + successor_date: datetime.datetime, + lag_time: int, + predecessor_data: dict, + successor_data: dict, + ) -> datetime.timedelta: if not lag_time: min_successor_date = successor_date else: diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py index 9af73898ea..107dcada2b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py @@ -31,9 +31,7 @@ def remove_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> sequences or controls are also removed. :param task: The IfcTask to remove. - :type task: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -56,76 +54,74 @@ def remove_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> # just fix it on site. ifcopenshell.api.sequence.remove_task(model, task=design) """ - settings = {"task": task} - # TODO: do a deep purge ifcopenshell.api.project.unassign_declaration( file, - definitions=[settings["task"]], + definitions=[task], relating_context=file.by_type("IfcContext")[0], ) - if task_time := settings["task"].TaskTime: + if task_time := task.TaskTime: if task_time.is_a("IfcTaskTimeRecurring"): ifcopenshell.api.sequence.unassign_recurrence_pattern(file, task_time.Recurrence) file.remove(task_time) # Handle IfcRelNests. - if rels := settings["task"].IsNestedBy: + if rels := task.IsNestedBy: subtasks = rels[0].RelatedObjects # Use batching for optimization. ifcopenshell.api.nest.unassign_object(file, subtasks) for task_ in subtasks: ifcopenshell.api.sequence.remove_task(file, task=task_) - if settings["task"].Nests: - ifcopenshell.api.nest.unassign_object(file, [settings["task"]]) + if task.Nests: + ifcopenshell.api.nest.unassign_object(file, [task]) - for inverse in file.get_inverse(settings["task"]): + for inverse in file.get_inverse(task): if inverse.is_a("IfcRelSequence"): history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAssignsToControl"): - if inverse.RelatingControl == settings["task"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingControl == task or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) else: related_objects = list(inverse.RelatedObjects) - related_objects.remove(settings["task"]) + related_objects.remove(task) inverse.RelatedObjects = related_objects elif inverse.is_a("IfcRelDefinesByProperties"): ifcopenshell.api.pset.remove_pset( file, - product=settings["task"], + product=task, pset=inverse.RelatingPropertyDefinition, ) elif inverse.is_a("IfcRelAssignsToProcess"): - if inverse.RelatingProcess == settings["task"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingProcess == task or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAssignsToProduct"): - if inverse.RelatingProduct == settings["task"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingProduct == task or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) else: related_objects = list(inverse.RelatedObjects) - related_objects.remove(settings["task"]) + related_objects.remove(task) inverse.RelatedObjects = related_objects elif inverse.is_a("IfcRelAssignsToObject"): - if inverse.RelatingObject == settings["task"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingObject == task or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) else: related_objects = list(inverse.RelatedObjects) - related_objects.remove(settings["task"]) + related_objects.remove(task) inverse.RelatedObjects = related_objects elif inverse.is_a("IfcRelAssignsToProcess"): history = inverse.OwnerHistory @@ -133,7 +129,7 @@ def remove_task(file: ifcopenshell.file, task: ifcopenshell.entity_instance) -> if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["task"].OwnerHistory - file.remove(settings["task"]) + history = task.OwnerHistory + file.remove(task) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_time_period.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_time_period.py index 247323c04a..50314f8362 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_time_period.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_time_period.py @@ -23,9 +23,7 @@ def remove_time_period(file: ifcopenshell.file, time_period: ifcopenshell.entity """Removes a time period :param time_period: The IfcTimePeriod to remove. - :type time_period: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -55,6 +53,4 @@ def remove_time_period(file: ifcopenshell.file, time_period: ifcopenshell.entity # Let's take the afternoon off! ifcopenshell.api.sequence.remove_time_period(model, time_period=afternoon) """ - settings = {"time_period": time_period} - - file.remove(settings["time_period"]) + file.remove(time_period) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_calendar.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_calendar.py index 27213630e5..0ff9c5109d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_calendar.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_calendar.py @@ -30,9 +30,7 @@ def remove_work_calendar(file: ifcopenshell.file, work_calendar: ifcopenshell.en calendar. :param work_calendar: The IfcWorkCalendar to remove - :type work_calendar: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -44,32 +42,30 @@ def remove_work_calendar(file: ifcopenshell.file, work_calendar: ifcopenshell.en # And remove it immediately ifcopenshell.api.sequence.remove_work_calendar(model, work_calendar=calendar) """ - settings = {"work_calendar": work_calendar} - # TODO: do a deep purge ifcopenshell.api.project.unassign_declaration( file, - definitions=[settings["work_calendar"]], + definitions=[work_calendar], relating_context=file.by_type("IfcContext")[0], ) - if settings["work_calendar"].Controls: - for rel in settings["work_calendar"].Controls: + if work_calendar.Controls: + for rel in work_calendar.Controls: for related_object in rel.RelatedObjects: ifcopenshell.api.control.unassign_control( file, - relating_control=settings["work_calendar"], + relating_control=work_calendar, related_object=related_object, ) # Currently in API work times are created already attached # to the work calendar, so they are never reused. - for working_time in settings["work_calendar"].WorkingTimes or []: + for working_time in work_calendar.WorkingTimes or []: ifcopenshell.api.sequence.remove_work_time(file, work_time=working_time) - for exception_time in settings["work_calendar"].ExceptionTimes or []: + for exception_time in work_calendar.ExceptionTimes or []: ifcopenshell.api.sequence.remove_work_time(file, work_time=exception_time) - history = settings["work_calendar"].OwnerHistory - file.remove(settings["work_calendar"]) + history = work_calendar.OwnerHistory + file.remove(work_calendar) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_plan.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_plan.py index 4f655fbd0c..1d7fd8ce3a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_plan.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_plan.py @@ -29,9 +29,7 @@ def remove_work_plan(file: ifcopenshell.file, work_plan: ifcopenshell.entity_ins removed. :param work_plan: The IfcWorkPlan to remove. - :type work_plan: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -43,11 +41,9 @@ def remove_work_plan(file: ifcopenshell.file, work_plan: ifcopenshell.entity_ins # And remove it immediately ifcopenshell.api.sequence.remove_work_plan(model, work_plan=work_plan) """ - settings = {"work_plan": work_plan} - ifcopenshell.api.project.unassign_declaration( file, - definitions=[settings["work_plan"]], + definitions=[work_plan], relating_context=file.by_type("IfcContext")[0], ) @@ -55,7 +51,7 @@ def remove_work_plan(file: ifcopenshell.file, work_plan: ifcopenshell.entity_ins if related_objects: ifcopenshell.api.aggregate.unassign_object(file, related_objects) - history = settings["work_plan"].OwnerHistory - file.remove(settings["work_plan"]) + history = work_plan.OwnerHistory + file.remove(work_plan) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_schedule.py index d14258e6ec..5304ac64ad 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_work_schedule.py @@ -47,32 +47,30 @@ def remove_work_schedule(file: ifcopenshell.file, work_schedule: ifcopenshell.en # And remove it immediately ifcopenshell.api.sequence.remove_work_schedule(model, work_schedule=schedule) """ - settings = {"work_schedule": work_schedule} - # TODO: do a deep purge ifcopenshell.api.project.unassign_declaration( - file, definitions=[settings["work_schedule"]], relating_context=file.by_type("IfcContext")[0] + file, definitions=[work_schedule], relating_context=file.by_type("IfcContext")[0] ) - if settings["work_schedule"].Declares: - for rel in settings["work_schedule"].Declares: - for work_schedule in rel.RelatedObjects: - ifcopenshell.api.sequence.remove_work_schedule(file, work_schedule=work_schedule) + if work_schedule.Declares: + for rel in work_schedule.Declares: + for work_schedule_ in rel.RelatedObjects: + ifcopenshell.api.sequence.remove_work_schedule(file, work_schedule=work_schedule_) # Unassign from work plans. - if settings["work_schedule"].Decomposes: - ifcopenshell.api.aggregate.unassign_object(file, [settings["work_schedule"]]) + if work_schedule.Decomposes: + ifcopenshell.api.aggregate.unassign_object(file, [work_schedule]) - for inverse in file.get_inverse(settings["work_schedule"]): + for inverse in file.get_inverse(work_schedule): if inverse.is_a("IfcRelDefinesByObject"): - if inverse.RelatingObject == settings["work_schedule"] or len(inverse.RelatedObjects) == 1: + if inverse.RelatingObject == work_schedule or len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) else: related_objects = list(inverse.RelatedObjects) - related_objects.remove(settings["work_schedule"]) + related_objects.remove(work_schedule) inverse.RelatedObjects = related_objects elif inverse.is_a("IfcRelAssignsToControl"): [ @@ -81,7 +79,7 @@ def remove_work_schedule(file: ifcopenshell.file, work_schedule: ifcopenshell.en if related_object.is_a("IfcTask") ] - history = settings["work_schedule"].OwnerHistory - file.remove(settings["work_schedule"]) + history = work_schedule.OwnerHistory + file.remove(work_schedule) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_lag_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_lag_time.py index 08ffcd9f40..b07a630684 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_lag_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_lag_time.py @@ -25,9 +25,7 @@ def unassign_lag_time(file: ifcopenshell.file, rel_sequence: ifcopenshell.entity The schedule is cascaded afterwards. :param rel_sequence: The sequence to remove the lag time from. - :type rel_sequence: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -57,12 +55,8 @@ def unassign_lag_time(file: ifcopenshell.file, rel_sequence: ifcopenshell.entity # What if you didn't? ifcopenshell.api.sequence.unassign_lag_time(model, rel_sequence=sequence) """ - settings = { - "rel_sequence": rel_sequence, - } - - if len(file.get_inverse(settings["rel_sequence"].TimeLag)) == 1: - file.remove(settings["rel_sequence"].TimeLag) + if file.get_total_inverses((current_lag_time := rel_sequence.TimeLag)) == 1: + file.remove(current_lag_time) else: - settings["rel_sequence"].TimeLag = None - ifcopenshell.api.sequence.cascade_schedule(file, task=settings["rel_sequence"].RelatedProcess) + rel_sequence.TimeLag = None + ifcopenshell.api.sequence.cascade_schedule(file, task=rel_sequence.RelatedProcess) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_process.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_process.py index 20e801a76c..1b2be09d20 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_process.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_process.py @@ -31,11 +31,8 @@ def unassign_process( See ifcopenshell.api.sequence.assign_process for details. :param relating_process: The IfcTask in the relationship. - :type relating_process: ifcopenshell.entity_instance :param related_object: The related object. - :type related_object: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -59,13 +56,8 @@ def unassign_process( # Change our mind. ifcopenshell.api.sequence.unassign_process(model, relating_process=task, related_object=wall) """ - settings = { - "relating_process": relating_process, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToProcess") or rel.RelatingProcess != settings["relating_process"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToProcess") or rel.RelatingProcess != relating_process: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -74,7 +66,7 @@ def unassign_process( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, element=rel) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_product.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_product.py index 30f2675aeb..ff59b463fa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_product.py @@ -31,11 +31,8 @@ def unassign_product( See ifcopenshell.api.sequence.assign_product for details. :param relating_product: The IfcProduct in the relationship. - :type relating_product: ifcopenshell.entity_instance :param related_object: The IfcTask in the relationship. - :type related_object: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -59,13 +56,8 @@ def unassign_product( # Change our mind. ifcopenshell.api.sequence.unassign_product(relating_product=wall, related_object=task) """ - settings = { - "relating_product": relating_product, - "related_object": related_object, - } - - for rel in settings["related_object"].HasAssignments or []: - if not rel.is_a("IfcRelAssignsToProduct") or rel.RelatingProduct != settings["relating_product"]: + for rel in related_object.HasAssignments or []: + if not rel.is_a("IfcRelAssignsToProduct") or rel.RelatingProduct != relating_product: continue if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory @@ -74,7 +66,7 @@ def unassign_product( ifcopenshell.util.element.remove_deep2(file, history) return related_objects = list(rel.RelatedObjects) - related_objects.remove(settings["related_object"]) + related_objects.remove(related_object) rel.RelatedObjects = related_objects ifcopenshell.api.owner.update_owner_history(file, element=rel) return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_recurrence_pattern.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_recurrence_pattern.py index 2ba9cdb229..0c0dac45ee 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_recurrence_pattern.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_recurrence_pattern.py @@ -28,9 +28,7 @@ def unassign_recurrence_pattern(file: ifcopenshell.file, recurrence_pattern: ifc or replace IfcTaskTimeRecurring with IfcTaskTime). :param recurrence_pattern: The IfcRecurrencePattern to remove. - :type recurrence_pattern: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -50,8 +48,6 @@ def unassign_recurrence_pattern(file: ifcopenshell.file, recurrence_pattern: ifc # Change our mind, let's just maintain it whenever we feel like it. ifcopenshell.api.sequence.unassign_recurrence_pattern(recurrence_pattern=pattern) """ - settings = {"recurrence_pattern": recurrence_pattern} - - for time_period in settings["recurrence_pattern"].TimePeriods or []: + for time_period in recurrence_pattern.TimePeriods or []: file.remove(time_period) - file.remove(settings["recurrence_pattern"]) + file.remove(recurrence_pattern) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_sequence.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_sequence.py index eea4e3eff5..15ed7d3cf1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/unassign_sequence.py @@ -29,11 +29,8 @@ def unassign_sequence( """Removes a sequence relationship between tasks :param relating_process: The previous / predecessor task. - :type relating_process: ifcopenshell.entity_instance :param related_process: The next / successor task. - :type related_process: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -60,15 +57,10 @@ def unassign_sequence( ifcopenshell.api.sequence.unassign_sequence(model, relating_process=zone1, related_process=zone2) """ - settings = { - "relating_process": relating_process, - "related_process": related_process, - } - - for rel in settings["related_process"].IsSuccessorFrom or []: - if rel.RelatingProcess == settings["relating_process"]: + for rel in related_process.IsSuccessorFrom or []: + if rel.RelatingProcess == relating_process: history = rel.OwnerHistory file.remove(rel) if history: ifcopenshell.util.element.remove_deep2(file, history) - ifcopenshell.api.sequence.cascade_schedule(file, task=settings["related_process"]) + ifcopenshell.api.sequence.cascade_schedule(file, task=related_process) diff --git a/src/ifcopenshell-python/ifcopenshell/api/spatial/assign_container.py b/src/ifcopenshell-python/ifcopenshell/api/spatial/assign_container.py index 81dca88437..244b5ae8f7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/spatial/assign_container.py +++ b/src/ifcopenshell-python/ifcopenshell/api/spatial/assign_container.py @@ -69,13 +69,11 @@ def assign_container( previous aggregation, containment, or nesting relationships it may have. :param products: A list of physical IfcElements existing in the space. - :type products: list[ifcopenshell.entity_instance] :param relating_structure: The IfcSpatialStructureElement element, such as IfcBuilding, IfcBuildingStorey, or IfcSpace that the element exists in. :return: The IfcRelContainedInSpatialStructure relationship instance or `None` if `products` was empty list. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -103,16 +101,10 @@ def assign_container( ifcopenshell.api.spatial.assign_container(model, products=[wall], relating_structure=storey) ifcopenshell.api.spatial.assign_container(model, products=[furniture], relating_structure=space) """ - settings = { - "products": products, - "relating_structure": relating_structure, - } - - if not settings["products"]: + if not products: return - products = set(settings["products"]) - relating_structure = settings["relating_structure"] + products_set = set(products) structure_rel = next(iter(relating_structure.ContainsElements), None) previous_containers_rels: set[ifcopenshell.entity_instance] = set() @@ -120,7 +112,7 @@ def assign_container( products_with_containers: list[ifcopenshell.entity_instance] = [] # check if there is anything to change - for product in products: + for product in products_set: product_rel = next(iter(product.ContainedInStructure), None) if product_rel is None: @@ -144,7 +136,7 @@ def assign_container( # unassign elements from previous containers for rel in previous_containers_rels: - related_elements = set(rel.RelatedElements) - products + related_elements = set(rel.RelatedElements) - products_set if related_elements: rel.RelatedElements = list(related_elements) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) @@ -156,7 +148,7 @@ def assign_container( # assign elements to a new container if structure_rel: - structure_rel.RelatedElements = list(set(structure_rel.RelatedElements) | products) + structure_rel.RelatedElements = list(set(structure_rel.RelatedElements) | products_set) ifcopenshell.api.owner.update_owner_history(file, **{"element": structure_rel}) else: structure_rel = file.create_entity( @@ -164,8 +156,8 @@ def assign_container( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedElements": list(products), - "RelatingStructure": settings["relating_structure"], + "RelatedElements": list(products_set), + "RelatingStructure": relating_structure, } ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/spatial/dereference_structure.py b/src/ifcopenshell-python/ifcopenshell/api/spatial/dereference_structure.py index bb31cd7c7d..abd66e7fa8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/spatial/dereference_structure.py +++ b/src/ifcopenshell-python/ifcopenshell/api/spatial/dereference_structure.py @@ -29,12 +29,10 @@ def dereference_structure( """Dereferences a list of products and space :param products: The list of physical IfcElements that exists in the space. - :type products: list[ifcopenshell.entity_instance] :param relating_structure: The IfcSpatialStructureElement element, such as IfcBuilding, IfcBuildingStorey, or IfcSpace that the element exists in. :return: None - :rtype: None Example: @@ -68,14 +66,12 @@ def dereference_structure( # Actually, it only goes up to storey 2. ifcopenshell.api.spatial.dereference_structure(model, products=[column], relating_structure=storey3) """ - settings = {"products": products, "relating_structure": relating_structure} - - products = set(settings["products"]) - for rel in settings["relating_structure"].ReferencesElements: + products_set = set(products) + for rel in relating_structure.ReferencesElements: related_elements = set(rel.RelatedElements) - if not related_elements.intersection(products): + if not related_elements.intersection(products_set): continue - related_elements = related_elements - products + related_elements = related_elements - products_set if related_elements: rel.RelatedElements = list(related_elements) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/spatial/reference_structure.py b/src/ifcopenshell-python/ifcopenshell/api/spatial/reference_structure.py index 2897219e68..41452244d1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/spatial/reference_structure.py +++ b/src/ifcopenshell-python/ifcopenshell/api/spatial/reference_structure.py @@ -46,14 +46,11 @@ def reference_structure( spaces simultaneously. :param products: The list of physical IfcElements that exists in the space. - :type products: list[ifcopenshell.entity_instance] :param relating_structure: The IfcSpatialStructureElement element, such as IfcBuilding, IfcBuildingStorey, or IfcSpace that the element exists in. - :type relating_structure: ifcopenshell.entity_instance :return: The IfcRelReferencedInSpatialStructure relationship instance or `None` if `products` was an empty list. - :rtype: Union[ifcopenshell.entity_instance, None] Example: @@ -85,19 +82,16 @@ def reference_structure( model, products=[column], relating_structure=[storey2, storey3] ) """ - settings = { - "products": products, - "relating_structure": relating_structure, - } - structure = settings["relating_structure"] - products = set(settings["products"]) + structure = relating_structure + products_set = set(products) - if not products: + if not products_set: return referenced = ifcopenshell.util.element.get_structure_referenced_elements(structure) - products_to_assign = products - referenced + products_to_assign = products_set - referenced + rel: Union[ifcopenshell.entity_instance, None] rel = next(iter(structure.ReferencesElements), None) if not products_to_assign: diff --git a/src/ifcopenshell-python/ifcopenshell/api/spatial/unassign_container.py b/src/ifcopenshell-python/ifcopenshell/api/spatial/unassign_container.py index 9567fa4841..cd95dae448 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/spatial/unassign_container.py +++ b/src/ifcopenshell-python/ifcopenshell/api/spatial/unassign_container.py @@ -25,9 +25,7 @@ def unassign_container(file: ifcopenshell.file, products: list[ifcopenshell.enti """Unassigns a container from products. :param product: A list of IfcProducts to remove the containment from. - :type product: list[ifcopenshell.entity_instance] :return: None - :rtype: None Example: @@ -54,15 +52,11 @@ def unassign_container(file: ifcopenshell.file, products: list[ifcopenshell.enti # Not anymore! ifcopenshell.api.spatial.unassign_container(model, products=[wall]) """ - settings = { - "products": products, - } - - products = set(settings["products"]) - rels = set(rel for product in products if (rel := next(iter(product.ContainedInStructure), None))) + products_set = set(products) + rels = set(rel for product in products_set if (rel := next(iter(product.ContainedInStructure), None))) for rel in rels: - related_elements = set(rel.RelatedElements) - products + related_elements = set(rel.RelatedElements) - products_set if related_elements: rel.RelatedElements = list(related_elements) ifcopenshell.api.owner.update_owner_history(file, element=rel) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_activity.py b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_activity.py index 53d07abcc6..466d26526f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_activity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_activity.py @@ -27,7 +27,7 @@ def add_structural_activity( ifc_class: str = "IfcStructuralPlanarAction", predefined_type: str = "CONST", global_or_local: Literal["GLOBAL_COORDS", "LOCAL_COORDS"] = "GLOBAL_COORDS", -) -> None: +) -> ifcopenshell.entity_instance: """Adds a new structural activity A structural activity is either a structural action or a reaction. It @@ -38,42 +38,29 @@ def add_structural_activity( a structural member. :param ifc_class: Choose from any subtype of IfcStructuralActivity. - :type ifc_class: str :param predefined_type: View the IFC documentation for what valid predefined types may be chosen. - :type predefined_type: str :param global_or_local: The location coordinates of the load is always defined locally relative to the structural member the activity is assigned to. However, the directions of the applied load may either be specified globally or locally depending on how this argument is set. Choose from GLOBAL_COORDS or LOCAL_COORDS. - :type global_or_local: str :param applied_load: The IfcStructuralLoad that is applied in this activity. - :type applied_load: ifcopenshell.entity_instance :param structural_member: The IfcStructuralMember that the load is applied to. - :type structural_member: ifcopenshell.entity_instance :return: The newly created entity based on the ifc_class - :rtype: ifcopenshell.entity_instance """ - settings = { - "ifc_class": ifc_class, - "predefined_type": predefined_type, - "global_or_local": global_or_local, - "applied_load": applied_load, - "structural_member": structural_member, - } activity = ifcopenshell.api.root.create_entity( file, - ifc_class=settings["ifc_class"], - predefined_type=settings["predefined_type"], + ifc_class=ifc_class, + predefined_type=predefined_type, ) - activity.AppliedLoad = settings["applied_load"] - activity.GlobalOrLocal = settings["global_or_local"] + activity.AppliedLoad = applied_load + activity.GlobalOrLocal = global_or_local rel = ifcopenshell.api.root.create_entity(file, ifc_class="IfcRelConnectsStructuralActivity") - rel.RelatingElement = settings["structural_member"] + rel.RelatingElement = structural_member rel.RelatedStructuralActivity = activity return activity diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py index 36c232ecdd..4e3ac0c1b9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_boundary_condition.py @@ -32,18 +32,14 @@ def add_structural_boundary_condition( edge condition, and surface connections will have a face condition. :param name: The name of the boundary condition. - :type name: str,optional :param connection: The IfcStructuralConnection to apply the boundary condition to. This will determine the type of condition that is created. If no connection is supplied, an orphan boundary condition will be created using the ifc_class that you specify. - :type connection: ifcopenshell.entity_instance,optional :param ifc_class: The class of IfcBoundaryCondition to create, only relevant if you do not specify a connection and want to create an orphaned boundary condition. - :type ifc_class: str,optional :return: The newly created IfcBoundaryCondition - :rtype: ifcopenshell.entity_instance Example: @@ -51,14 +47,12 @@ def add_structural_boundary_condition( ifcopenshell.api.structural.add_structural_boundary_condition(model, connection=connection) """ - settings = {"name": name, "connection": connection, "ifc_class": ifc_class} - - if settings["connection"]: + if connection: # assign boundary condition to a connection - if settings["connection"].is_a("IfcRelConnectsStructuralMember"): - related_connection = settings["connection"].RelatedStructuralConnection + if connection.is_a("IfcRelConnectsStructuralMember"): + related_connection = connection.RelatedStructuralConnection else: - related_connection = settings["connection"] + related_connection = connection if related_connection.is_a("IfcStructuralPointConnection"): boundary_class = "IfcBoundaryNodeCondition" @@ -67,9 +61,9 @@ def add_structural_boundary_condition( elif related_connection.is_a("IfcStructuralSurfaceConnection"): boundary_class = "IfcBoundaryFaceCondition" - condition = file.create_entity(boundary_class, Name=settings["name"]) - settings["connection"].AppliedCondition = condition + condition = file.create_entity(boundary_class, Name=name) + connection.AppliedCondition = condition return condition else: # add an orphan boundary condition - return file.create_entity(settings["ifc_class"], Name=settings["name"]) + return file.create_entity(ifc_class, Name=name) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_member_connection.py b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_member_connection.py index 077e0a55fc..56ad73dca3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_member_connection.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/add_structural_member_connection.py @@ -29,22 +29,15 @@ def add_structural_member_connection( :param relating_structural_member: The IfcStructuralMember to have a connection added to it. - :type relating_structural_member: ifcopenshell.entity_instance :param related_structural_connection: The IfcStructuralConnection to add to the IfcStructuralMember. - :type related_structural_connection: ifcopenshell.entity_instance :return: The IfcRelConnectsStructuralMember relationship - :rtype: ifcopenshell.entity_instance """ - settings = { - "relating_structural_member": relating_structural_member, - "related_structural_connection": related_structural_connection, - } - for connection in settings["related_structural_connection"].ConnectsStructuralMembers or []: - if connection.RelatingStructuralMember == settings["relating_structural_member"]: + for connection in related_structural_connection.ConnectsStructuralMembers or []: + if connection.RelatingStructuralMember == relating_structural_member: return connection rel = ifcopenshell.api.root.create_entity(file, ifc_class="IfcRelConnectsStructuralMember") - rel.RelatingStructuralMember = settings["relating_structural_member"] - rel.RelatedStructuralConnection = settings["related_structural_connection"] + rel.RelatingStructuralMember = relating_structural_member + rel.RelatedStructuralConnection = related_structural_connection return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_analysis_model.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_analysis_model.py index 4b7d94a4a2..65c03eb238 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_analysis_model.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_analysis_model.py @@ -28,14 +28,8 @@ def edit_structural_analysis_model( IfcStructuralAnalysisModel, consult the IFC documentation. :param structural_analysis_model: The IfcStructuralAnalysisModel entity you want to edit - :type structural_analysis_model: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None """ - settings = {"structural_analysis_model": structural_analysis_model, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["structural_analysis_model"], name, value) - return settings["structural_analysis_model"] + for name, value in attributes.items(): + setattr(structural_analysis_model, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_boundary_condition.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_boundary_condition.py index 54963415df..3d33e4e8ad 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_boundary_condition.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_boundary_condition.py @@ -28,19 +28,14 @@ def edit_structural_boundary_condition( IfcBoundaryCondition, consult the IFC documentation. :param condition: The IfcBoundaryCondition entity you want to edit - :type condition: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None """ - settings = {"condition": condition, "attributes": attributes} - - for name, data in settings["attributes"].items(): + for name, data in attributes.items(): if data["type"] == "string" or data["type"] == "null": value = data["value"] elif data["type"] == "IfcBoolean": value = file.createIfcBoolean(data["value"]) else: value = file.create_entity(data["type"], data["value"]) - setattr(settings["condition"], name, value) + setattr(condition, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_connection_cs.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_connection_cs.py index 0d569c5f56..38befd9c6f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_connection_cs.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_connection_cs.py @@ -16,42 +16,33 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell +from ifcopenshell.util.shape_builder import VectorType, ifc_safe_vector_type def edit_structural_connection_cs( file: ifcopenshell.file, structural_item: ifcopenshell.entity_instance, - axis: tuple[float, float, float] = (0.0, 0.0, 1.0), - ref_direction: tuple[float, float, float] = (1.0, 0.0, 0.0), + axis: VectorType = (0.0, 0.0, 1.0), + ref_direction: VectorType = (1.0, 0.0, 0.0), ) -> None: """Edits the coordinate system of a structural connection :param structural_item: The IfcStructuralItem you want to modify. - :type structural_item: ifcopenshell.entity_instance :param axis: The unit Z axis vector defined as a list of 3 floats. Defaults to (0., 0., 1.). - :type axis: tuple[float, float, float] :param ref_direction: The unit X axis vector defined as a list of 3 floats. Defaults to (1., 0., 0.). - :type ref_direction: tuple[float, float, float] :return: None - :rtype: None """ - settings = { - "structural_item": structural_item, - "axis": axis, - "ref_direction": ref_direction, - } - - if settings["structural_item"].ConditionCoordinateSystem is None: + if structural_item.ConditionCoordinateSystem is None: point = file.createIfcCartesianPoint((0.0, 0.0, 0.0)) ccs = file.createIfcAxis2Placement3D(point, None, None) - settings["structural_item"].ConditionCoordinateSystem = ccs + structural_item.ConditionCoordinateSystem = ccs - ccs = settings["structural_item"].ConditionCoordinateSystem - if ccs.Axis and len(file.get_inverse(ccs.Axis)) == 1: - file.remove(ccs.Axis) - ccs.Axis = file.createIfcDirection(settings["axis"]) - if ccs.RefDirection and len(file.get_inverse(ccs.RefDirection)) == 1: - file.remove(ccs.RefDirection) - ccs.RefDirection = file.createIfcDirection(settings["ref_direction"]) + ccs = structural_item.ConditionCoordinateSystem + if (current_axis := ccs.Axis) and file.get_total_inverses(current_axis) == 1: + file.remove(current_axis) + ccs.Axis = file.create_entity("IfcDirection", ifc_safe_vector_type(axis)) + if (prev_ref_direction := ccs.RefDirection) and file.get_total_inverses(prev_ref_direction) == 1: + file.remove(prev_ref_direction) + ccs.RefDirection = file.create_entity("IfcDirection", ifc_safe_vector_type(ref_direction)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_item_axis.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_item_axis.py index 8b90457a47..d3858bec95 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_item_axis.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_item_axis.py @@ -16,25 +16,21 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell +from ifcopenshell.util.shape_builder import VectorType, ifc_safe_vector_type def edit_structural_item_axis( file: ifcopenshell.file, structural_item: ifcopenshell.entity_instance, - axis: tuple[float, float, float] = (0.0, 0.0, 1.0), + axis: VectorType = (0.0, 0.0, 1.0), ) -> None: """Edits the coordinate system of a structural connection :param structural_item: The IfcStructuralItem you want to modify. - :type structural_item: ifcopenshell.entity_instance :param axis: The unit Z axis vector defined as a list of 3 floats. Defaults to (0., 0., 1.). - :type axis: tuple[float, float, float] :return: None - :rtype: None """ - settings = {"structural_item": structural_item, "axis": axis} - - if len(file.get_inverse(settings["structural_item"].Axis)) == 1: - file.remove(settings["structural_item"].Axis) - settings["structural_item"].Axis = file.createIfcDirection(settings["axis"]) + if file.get_total_inverses(axis_dir := structural_item.Axis) == 1: + file.remove(axis_dir) + structural_item.Axis = file.create_entity("IfcDirection", ifc_safe_vector_type(axis)) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load.py index 20298d1c0f..faa218a7a3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load.py @@ -28,13 +28,8 @@ def edit_structural_load( IfcStructuralLoad, consult the IFC documentation. :param structural_load: The IfcStructuralLoad entity you want to edit - :type structural_load: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None """ - settings = {"structural_load": structural_load, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["structural_load"], name, value) + for name, value in attributes.items(): + setattr(structural_load, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load_case.py b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load_case.py index 59231fb9e4..02948c637d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load_case.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/edit_structural_load_case.py @@ -28,13 +28,8 @@ def edit_structural_load_case( IfcStructuralLoadCase, consult the IFC documentation. :param load_case: The IfcStructuralLoadCase entity you want to edit - :type load_case: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None """ - settings = {"load_case": load_case, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["load_case"], name, value) + for name, value in attributes.items(): + setattr(load_case, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_analysis_model.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_analysis_model.py index 8135bc0255..144b96f5a9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_analysis_model.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_analysis_model.py @@ -29,18 +29,14 @@ def remove_structural_analysis_model( :param structural_analysis_model: The IfcStructuralAnalysisModel to remove. - :type structural_analysis_model: ifcopenshell.entity_instance :return: None - :rtype: None """ - settings = {"structural_analysis_model": structural_analysis_model} - - for rel in settings["structural_analysis_model"].IsGroupedBy or []: + for rel in structural_analysis_model.IsGroupedBy or []: history = rel.OwnerHistory file.remove(rel) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["structural_analysis_model"].OwnerHistory - file.remove(settings["structural_analysis_model"]) + history = structural_analysis_model.OwnerHistory + file.remove(structural_analysis_model) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_boundary_condition.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_boundary_condition.py index bd9018c1e2..d31811a739 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_boundary_condition.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_boundary_condition.py @@ -24,27 +24,25 @@ def remove_structural_boundary_condition( connection: Optional[ifcopenshell.entity_instance] = None, boundary_condition: Optional[ifcopenshell.entity_instance] = None, ) -> None: - """Removes a condition from a connection, or an orphased boundary condition + """Removes a condition from a connection, or an orphaned boundary condition :param connection: The IfcStructuralConnection to remove the condition from. If omitted, it is assumed to be an orphaned condition. - :type connection: ifcopenshell.entity_instance,optional :param boundary_condition: The IfcBoundaryCondition to remove. - :type boundary_condition: ifcopenshell.entity_instance, optional. :return: None - :rtype: None """ - settings = {"connection": connection, "boundary_condition": boundary_condition} - if settings["connection"]: + if connection: # remove boundary condition from a connection - if not settings["connection"].AppliedCondition: + if not connection.AppliedCondition: return - if len(file.get_inverse(settings["connection"].AppliedCondition)) == 1: - file.remove(settings["connection"].AppliedCondition) - settings["connection"].AppliedCondition = None + applied_condition = connection.AppliedCondition + if file.get_total_inverses(applied_condition) == 1: + file.remove(applied_condition) + connection.AppliedCondition = None else: + assert boundary_condition, "Either connection or boundary_condition must be provided." # remove the boundary condition - for conn in file.get_inverse(settings["boundary_condition"]): + for conn in file.get_inverse(boundary_condition): conn.AppliedCondition = None - file.remove(settings["boundary_condition"]) + file.remove(boundary_condition) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_connection_condition.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_connection_condition.py index c16a441753..eea472ef73 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_connection_condition.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_connection_condition.py @@ -27,18 +27,14 @@ def remove_structural_connection_condition(file: ifcopenshell.file, relation: if The condition and the member itself is preserved. :param relation: The IfcRelConnectsStructuralMember to remove. - :type relation: ifcopenshell.entity_instance :return: None - :rtype: None """ - settings = {"relation": relation} - - if settings["relation"].AppliedCondition: + if relation.AppliedCondition: ifcopenshell.api.structural.remove_structural_boundary_condition( file, - connection=settings["relation"].RelatedStructuralConnection, + connection=relation.RelatedStructuralConnection, ) - history = settings["relation"].OwnerHistory - file.remove(settings["relation"]) + history = relation.OwnerHistory + file.remove(relation) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load.py index 1d406a16e8..0c1a6ad3a1 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load.py @@ -22,10 +22,6 @@ def remove_structural_load(file: ifcopenshell.file, structural_load: ifcopenshel """Removes a structural load :param structural_load: The IfcStructuralLoad to remove. - :type structural_load: ifcopenshell.entity_instance :return: None - :rtype: None """ - settings = {"structural_load": structural_load} - - file.remove(settings["structural_load"]) + file.remove(structural_load) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_case.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_case.py index 1d9473515a..401db40dbc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_case.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_case.py @@ -25,18 +25,14 @@ def remove_structural_load_case(file: ifcopenshell.file, load_case: ifcopenshell """Removes a structural load case :param load_case: The IfcStructuralLoadCase to remove. - :type load_case: ifcopenshell.entity_instance :return: None - :rtype: None """ - settings = {"load_case": load_case} - # TODO: do a deep purge - for rel in settings["load_case"].IsGroupedBy or []: + for rel in load_case.IsGroupedBy or []: history = rel.OwnerHistory file.remove(rel) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["load_case"].OwnerHistory - file.remove(settings["load_case"]) + history = load_case.OwnerHistory + file.remove(load_case) ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_group.py b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_group.py index 281630fd7a..25acbbe9cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_group.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/remove_structural_load_group.py @@ -25,20 +25,16 @@ def remove_structural_load_group(file: ifcopenshell.file, load_group: ifcopenshe """Removes a structural load group :param load_group: The IfcStructuralLoadGroup to remove. - :type load_group: ifcopenshell.entity_instance :return: None - :rtype: None """ - settings = {"load_group": load_group} - # TODO: do a deep purge - for inverse in file.get_inverse(settings["load_group"]): + for inverse in file.get_inverse(load_group): if inverse.is_a("IfcRelAssignsToGroup") and len(inverse.RelatedObjects) == 1: history = inverse.OwnerHistory file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["load_group"].OwnerHistory - file.remove(settings["load_group"]) + history = load_group.OwnerHistory + file.remove(load_group) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_style.py index 408f8e7906..55af1b35b2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_style.py @@ -22,11 +22,12 @@ from typing import Any, Optional, Literal SURFACE_STYLE_TYPES = Literal[ - "IfcExternallyDefinedSurfaceStyle", + "IfcSurfaceStyleShading", + "IfcSurfaceStyleRendering", + "IfcSurfaceStyleWithTextures", "IfcSurfaceStyleLighting", "IfcSurfaceStyleRefraction", - "IfcSurfaceStyleShading", - "IfcSurfaceStyleWithTextures", + "IfcExternallyDefinedSurfaceStyle", ] @@ -35,7 +36,7 @@ def add_surface_style( style: ifcopenshell.entity_instance, ifc_class: SURFACE_STYLE_TYPES = "IfcSurfaceStyleShading", attributes: Optional[dict[str, Any]] = None, -) -> None: +) -> ifcopenshell.entity_instance: """Adds a new presentation item to a surface style A surface style can have multiple different types of presentation items @@ -82,17 +83,13 @@ def add_surface_style( :param style: The IfcSurfaceStyle you want to add to presentation item to. See ifcopenshell.api.style.add_style. - :type style: ifcopenshell.entity_instance :param ifc_class: Choose from IfcSurfaceStyleShading, IfcSurfaceStyleRendering, IfcSurfaceStyleWithTextures, IfcSurfaceStyleLighting, IfcSurfaceStyleReflectance, or IfcExternallyDefinedSurfaceStyle. - :type ifc_class: str :param attributes: a dictionary of attribute names and values. - :type attributes: dict, optional :return: The newly created presentation item based on the provided ifc_class. - :rtype: ifcopenshell.entity_instance Example: @@ -127,20 +124,20 @@ def add_surface_style( "SpecularHighlight": {"SpecularRoughness": 0.5}, # Roughness factor }) """ - settings = {"style": style, "ifc_class": ifc_class, "attributes": attributes or {}} + attributes = attributes or {} + style_item = file.create_entity(ifc_class) + ifcopenshell.api.style.edit_surface_style(file, style=style_item, attributes=attributes) + styles: list[ifcopenshell.entity_instance] + styles = list(style.Styles or []) - style_item = file.create_entity(settings["ifc_class"]) - ifcopenshell.api.style.edit_surface_style(file, style=style_item, attributes=settings["attributes"]) - styles = list(settings["style"].Styles or []) - - select_class = settings["ifc_class"] + select_class = ifc_class if select_class == "IfcSurfaceStyleRendering": select_class = "IfcSurfaceStyleShading" duplicate_items = [s for s in styles if s.is_a(select_class)] for duplicate_item in duplicate_items: ifcopenshell.api.style.remove_surface_style(file, style=duplicate_item) - styles = list(settings["style"].Styles or []) + styles = list(style.Styles or []) styles.append(style_item) - settings["style"].Styles = styles + style.Styles = styles return style_item diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/assign_material_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/assign_material_style.py index 3ecc91177f..fbd706df68 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/assign_material_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/assign_material_style.py @@ -19,6 +19,7 @@ import ifcopenshell import ifcopenshell.api.style import ifcopenshell.util.element +from typing import Any def assign_material_style( @@ -115,6 +116,9 @@ def assign_material_style( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): self.style = self.settings["style"] if self.file.schema == "IFC2X3" or self.settings["should_use_presentation_style_assignment"]: @@ -170,7 +174,7 @@ class Usecase: new_items.append(self.create_styled_item(item_to_reuse)) representation.Items = new_items for item in same_style_items: - if len(self.file.get_inverse(item)) == 0: + if self.file.get_total_inverses(item) == 0: self.file.remove(item) else: representations = list(definition_representation.Representations) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/edit_presentation_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/edit_presentation_style.py index d1a42e5d9e..7a39629528 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/edit_presentation_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/edit_presentation_style.py @@ -28,11 +28,8 @@ def edit_presentation_style( IfcPresentationStyle, consult the IFC documentation. :param style: The IfcPresentationStyle entity you want to edit - :type style: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -44,7 +41,5 @@ def edit_presentation_style( # Change the name of the style to "Foo" ifcopenshell.api.style.edit_presentation_style(model, style=style, attributes={"Name": "Foo"}) """ - settings = {"style": style, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["style"], name, value) + for name, value in attributes.items(): + setattr(style, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/edit_surface_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/edit_surface_style.py index c5be1d757f..7f7f05948d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/edit_surface_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/edit_surface_style.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell -from typing import Any +from typing import Any, Union def edit_surface_style( @@ -36,11 +36,8 @@ def edit_surface_style( example below. :param style: The IfcPresentationStyle entity you want to edit - :type style: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -74,24 +71,27 @@ def edit_surface_style( """ usecase = Usecase() usecase.file = file - usecase.settings = {"style": style, "attributes": attributes or {}} - return usecase.execute() + return usecase.execute(style, attributes) class Usecase: - def execute(self): - attributes = {} - for attribute in self.settings["style"].wrapped_data.declaration().as_entity().all_attributes(): + file: ifcopenshell.file + + def execute(self, style: ifcopenshell.entity_instance, attributes: dict[str, Any]) -> None: + self.style = style + + attribute_types: dict[str, str] = {} + for attribute in style.wrapped_data.declaration().as_entity().all_attributes(): attribute_type = attribute.type_of_attribute() if attribute_type.as_aggregation_type() is None: attribute_type = attribute_type.declared_type().name() else: # doesn't have .declared_type() attribute_type = attribute_type.type_of_element() - attributes[attribute.name()] = attribute_type + attribute_types[attribute.name()] = attribute_type - for key, value in self.settings["attributes"].items(): - attribute_class = attributes.get(key) + for key, value in attributes.items(): + attribute_class = attribute_types.get(key) if attribute_class == "IfcColourRgb": self.edit_colour_rgb(key, value) elif key == "SpecularHighlight": @@ -99,41 +99,39 @@ class Usecase: elif attribute_class == "IfcColourOrFactor": self.edit_colour_or_factor(key, value) else: - setattr(self.settings["style"], key, value) + setattr(style, key, value) - def edit_colour_rgb(self, name, value: dict): - if (attribute := getattr(self.settings["style"], name)) is None: + def edit_colour_rgb(self, name: str, value: dict[str, Any]): + if (attribute := getattr(self.style, name)) is None: attribute = self.file.createIfcColourRgb() - setattr(self.settings["style"], name, attribute) + setattr(self.style, name, attribute) attribute.Name = value.get("Name", None) attribute.Red = value["Red"] attribute.Green = value["Green"] attribute.Blue = value["Blue"] - def edit_colour_or_factor(self, name, value): + def edit_colour_or_factor(self, name: str, value: Union[dict[str, Any], ifcopenshell.entity_instance, None]): if isinstance(value, dict): - attribute = getattr(self.settings["style"], name) + attribute = getattr(self.style, name) if not attribute or not attribute.is_a("IfcColourRgb"): colour = self.file.createIfcColourRgb(None, 0, 0, 0) - setattr(self.settings["style"], name, colour) - attribute = getattr(self.settings["style"], name) + setattr(self.style, name, colour) + attribute = getattr(self.style, name) attribute[1] = value["Red"] attribute[2] = value["Green"] attribute[3] = value["Blue"] else: # assume it's float value for IfcNormalisedRatioMeasure or None - existing_value = getattr(self.settings["style"], name) + existing_value = getattr(self.style, name) if existing_value and existing_value.id(): self.file.remove(existing_value) if value is not None: - value = self.file.createIfcNormalisedRatioMeasure(value) - setattr(self.settings["style"], name, value) + value = self.file.create_entity("IfcNormalisedRatioMeasure", value) + setattr(self.style, name, value) - def edit_specular_highlight(self, value): + def edit_specular_highlight(self, value: Union[dict[str, Any], None]) -> None: if value is None: - self.settings["style"].SpecularHighlight = None + self.style.SpecularHighlight = None elif value.get("IfcSpecularExponent", None): - self.settings["style"].SpecularHighlight = self.file.createIfcSpecularExponent(value["IfcSpecularExponent"]) + self.style.SpecularHighlight = self.file.createIfcSpecularExponent(value["IfcSpecularExponent"]) elif value.get("IfcSpecularRoughness", None): - self.settings["style"].SpecularHighlight = self.file.createIfcSpecularRoughness( - value["IfcSpecularRoughness"] - ) + self.style.SpecularHighlight = self.file.createIfcSpecularRoughness(value["IfcSpecularRoughness"]) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py index 3ee4cbf6d5..c72cca657e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/remove_style.py @@ -41,7 +41,6 @@ def remove_style(file: ifcopenshell.file, style: ifcopenshell.entity_instance) - """ usecase = Usecase() usecase.file = file - usecase.settings = {"style": style} return usecase.execute(style) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/remove_styled_representation.py b/src/ifcopenshell-python/ifcopenshell/api/style/remove_styled_representation.py index bc6267f0f7..d8441cd43a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/remove_styled_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/remove_styled_representation.py @@ -25,9 +25,7 @@ def remove_styled_representation(file: ifcopenshell.file, representation: ifcope removes the representation but not the underlying styles. :param representation: The IfcStyledRepresentation to remove. - :type representation: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -36,17 +34,15 @@ def remove_styled_representation(file: ifcopenshell.file, representation: ifcope # Remove a styled representation ifcopenshell.api.style.remove_styled_representation(model, representation=representation) """ - settings = {"representation": representation} - - for inverse in file.get_inverse(settings["representation"]): + for inverse in file.get_inverse(representation): if inverse.is_a("IfcMaterialDefinitionRepresentation") and len(inverse.Representations) == 1: file.remove(inverse) - for item in settings["representation"].Items: + for item in representation.Items: if item.is_a("IfcStyledItem") and file.get_total_inverses(item) == 1: for style in item.Styles: if style.is_a("IfcPresentationStyleAssignment"): file.remove(style) file.remove(item) - file.remove(settings["representation"]) + file.remove(representation) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/remove_surface_style.py b/src/ifcopenshell-python/ifcopenshell/api/style/remove_surface_style.py index bad29c878f..19bb9e1b79 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/remove_surface_style.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/remove_surface_style.py @@ -24,9 +24,7 @@ def remove_surface_style(file: ifcopenshell.file, style: ifcopenshell.entity_ins """Removes a presentation item from a presentation style :param style: The IfcPresentationItem to remove. - :type style: ifcopenshell.entity_instance :return: None - :rtype: None Example: diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/unassign_representation_styles.py b/src/ifcopenshell-python/ifcopenshell/api/style/unassign_representation_styles.py index f56fad763c..4141adbacc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/unassign_representation_styles.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/unassign_representation_styles.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . import ifcopenshell +from typing import Any def unassign_representation_styles( @@ -63,6 +64,9 @@ def unassign_representation_styles( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): if not self.settings["styles"]: return [] diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/add_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/add_port.py index faa7c7a584..5c1ba67bda 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/add_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/add_port.py @@ -22,7 +22,9 @@ import ifcopenshell.api.system from typing import Optional -def add_port(file: ifcopenshell.file, element: Optional[ifcopenshell.entity_instance] = None) -> None: +def add_port( + file: ifcopenshell.file, element: Optional[ifcopenshell.entity_instance] = None +) -> ifcopenshell.entity_instance: """Adds a new distribution port to an element A distribution port represents a connection point on an element, where @@ -36,9 +38,7 @@ def add_port(file: ifcopenshell.file, element: Optional[ifcopenshell.entity_inst :param element: The IfcDistributionElement you want to add a distribution port to. - :type element: ifcopenshell.entity_instance, optional :return: The newly created IfcDistributionPort - :rtype: ifcopenshell.entity_instance Example: @@ -52,11 +52,7 @@ def add_port(file: ifcopenshell.file, element: Optional[ifcopenshell.entity_inst port1 = ifcopenshell.api.system.add_port(model, element=duct) port2 = ifcopenshell.api.system.add_port(model, element=duct) """ - settings = { - "element": element, - } - port = ifcopenshell.api.root.create_entity(file, ifc_class="IfcDistributionPort") - if settings["element"]: - ifcopenshell.api.system.assign_port(file, element=settings["element"], port=port) + if element: + ifcopenshell.api.system.assign_port(file, element=element, port=port) return port diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/add_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/add_system.py index a9272ee989..dbd1f81e3c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/add_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/add_system.py @@ -34,9 +34,7 @@ def add_system(file: ifcopenshell.file, ifc_class: str = "IfcDistributionSystem" security systems. Alternatively you may choose IfcBuildingSystem for specialised building facade systems or similar. For IFC2X3, choose IfcSystem. - :type ifc_class: str :return: The newly created IfcSystem. - :rtype: ifcopenshell.entity_instance Example: @@ -45,9 +43,7 @@ def add_system(file: ifcopenshell.file, ifc_class: str = "IfcDistributionSystem" # A completely empty distribution system system = ifcopenshell.api.system.add_system(model) """ - settings = {"ifc_class": ifc_class} - - ifc_class = settings["ifc_class"] + ifc_class = ifc_class # workaround for failing default argument in ifc2x3 if file.schema == "IFC2X3" and ifc_class == "IfcDistributionSystem": ifc_class = "IfcSystem" diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/assign_flow_control.py b/src/ifcopenshell-python/ifcopenshell/api/system/assign_flow_control.py index b41db08c79..011a510705 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/assign_flow_control.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/assign_flow_control.py @@ -34,12 +34,9 @@ def assign_flow_control( :param related_flow_control: IfcDistributionControlElement which may be used to impart control on the flow element - :type related_flow_control: ifcopenshell.entity_instance :param relating_flow_element: The IfcDistributionFlowElement that is being controlled / sensed - :type relating_flow_element: ifcopenshell.entity_instance :return: Matching or newly created IfcRelFlowControlElements. If control is already assigned to some other element method will return None. - :rtype: ifcopenshell.entity_instance, None Example: @@ -51,26 +48,21 @@ def assign_flow_control( model, related_flow_control=flow_control, relating_flow_element=flow_element ) """ - settings = { - "relating_flow_element": relating_flow_element, - "related_flow_control": related_flow_control, - } - - if settings["related_flow_control"].AssignedToFlowElement: + if related_flow_control.AssignedToFlowElement: # only 1 control per 1 flow element is possible - assignment = settings["related_flow_control"].AssignedToFlowElement[0] - if assignment.RelatingFlowElement == settings["relating_flow_element"]: + assignment = related_flow_control.AssignedToFlowElement[0] + if assignment.RelatingFlowElement == relating_flow_element: return assignment # return None if this control is already assigned to another flow element return - if settings["relating_flow_element"].HasControlElements: - assignment = settings["relating_flow_element"].HasControlElements[0] - if settings["related_flow_control"] in assignment.RelatedControlElements: + if relating_flow_element.HasControlElements: + assignment = relating_flow_element.HasControlElements[0] + if related_flow_control in assignment.RelatedControlElements: return assignment related_flow_controls = set(assignment.RelatedControlElements) - related_flow_controls.add(settings["related_flow_control"]) + related_flow_controls.add(related_flow_control) assignment.RelatedControlElements = list(related_flow_controls) ifcopenshell.api.owner.update_owner_history(file, **{"element": assignment}) return assignment @@ -80,8 +72,8 @@ def assign_flow_control( **{ "GlobalId": ifcopenshell.guid.new(), "OwnerHistory": ifcopenshell.api.owner.create_owner_history(file), - "RelatedControlElements": [settings["related_flow_control"]], - "RelatingFlowElement": settings["relating_flow_element"], + "RelatedControlElements": [related_flow_control], + "RelatingFlowElement": relating_flow_element, }, ) return assignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/assign_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/assign_port.py index 8506e6b53d..75415fd98b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/assign_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/assign_port.py @@ -34,12 +34,9 @@ def assign_port( it may be useful when patching up models. :param element: The IfcDistributionElement to assign the port to. - :type element: ifcopenshell.entity_instance :param port: The IfcDistributionPort you want to assign. - :type port: ifcopenshell.entity_instance :return: The IfcRelNests relationship, or the IfcRelConnectsPortToElement for IFC2X3. - :rtype: ifcopenshell.entity_instance Example: @@ -61,31 +58,30 @@ def assign_port( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "element": element, - "port": port, - } - return usecase.execute() + return usecase.execute(element, port) class Usecase: file: ifcopenshell.file - settings: dict[str, Any] - def execute(self): + def execute( + self, element: ifcopenshell.entity_instance, port: ifcopenshell.entity_instance + ) -> ifcopenshell.entity_instance: + self.element = element + self.port = port if self.file.schema == "IFC2X3": return self.execute_ifc2x3() - rels = self.settings["element"].IsNestedBy or [] + rels = self.element.IsNestedBy or [] for rel in rels: - if self.settings["port"] in rel.RelatedObjects: + if self.port in rel.RelatedObjects: return rel if rels: rel = rels[0] related_objects = set(rel.RelatedObjects) or set() - related_objects.add(self.settings["port"]) + related_objects.add(self.port) rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(self.file, **{"element": rel}) else: @@ -93,34 +89,34 @@ class Usecase: "IfcRelNests", GlobalId=ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(self.file), - RelatedObjects=[self.settings["port"]], - RelatingObject=self.settings["element"], + RelatedObjects=[self.port], + RelatingObject=self.element, ) self.update_port_placement() return rel - def execute_ifc2x3(self): - for rel in self.settings["element"].HasPorts or []: - if rel.RelatingPort == self.settings["port"]: + def execute_ifc2x3(self) -> ifcopenshell.entity_instance: + for rel in self.element.HasPorts or []: + if rel.RelatingPort == self.port: return rel rel = self.file.create_entity( "IfcRelConnectsPortToElement", GlobalId=ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(self.file), - RelatingPort=self.settings["port"], - RelatedElement=self.settings["element"], + RelatingPort=self.port, + RelatedElement=self.element, ) self.update_port_placement() return rel - def update_port_placement(self): - placement = getattr(self.settings["port"], "ObjectPlacement", None) + def update_port_placement(self) -> None: + placement = getattr(self.port, "ObjectPlacement", None) if placement and placement.is_a("IfcLocalPlacement"): ifcopenshell.api.geometry.edit_object_placement( self.file, - product=self.settings["port"], - matrix=ifcopenshell.util.placement.get_local_placement(self.settings["port"].ObjectPlacement), + product=self.port, + matrix=ifcopenshell.util.placement.get_local_placement(self.port.ObjectPlacement), is_si=False, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py index 4e6f5b89ee..8677f748b4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/assign_system.py @@ -19,24 +19,22 @@ import ifcopenshell import ifcopenshell.api.group import ifcopenshell.util.system +from typing import Union def assign_system( file: ifcopenshell.file, products: list[ifcopenshell.entity_instance], system: ifcopenshell.entity_instance, -) -> None: +) -> Union[ifcopenshell.entity_instance, None]: """Assigns distribution elements to a system Note that it is not necessary to assign distribution ports to a system. :param products: The list of IfcDistributionElements to assign to the system. - :type products: list[ifcopenshell.entity_instance] :param system: The IfcSystem you want to assign the element to. - :type system: ifcopenshell.entity_instance :return: The IfcRelAssignsToGroup relationship or `None` if `products` was empty list. - :rtype: [ifcopenshell.entity_instance, None] Example: @@ -52,14 +50,6 @@ def assign_system( # This duct is part of the system ifcopenshell.api.system.assign_system(model, products=[duct], system=system) """ - settings = { - "products": products, - "system": system, - } - - system = settings["system"] - products = settings["products"] - if not all(ifcopenshell.util.system.is_assignable(failed_product := product, system) for product in products): raise TypeError(f"You cannot assign an {failed_product.is_a()} to an {system.is_a()}") diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/connect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/connect_port.py index 9a1bc76f43..b0b39cfd2b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/connect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/connect_port.py @@ -20,7 +20,7 @@ import ifcopenshell import ifcopenshell.api.owner import ifcopenshell.guid import ifcopenshell.util.element -from typing import Optional +from typing import Optional, Any def connect_port( @@ -110,6 +110,9 @@ def connect_port( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): # Note: there are a number of ambiguities with port connectivity. We # assume system topology is represented by a directed graph. In other diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index cd6cd38fb1..3bc1d6d2b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -64,12 +64,8 @@ def disconnect_port(file: ifcopenshell.file, port: ifcopenshell.entity_instance) # fitting_port1 instead of duct_port2 ifcopenshell.api.system.disconnect_port(model, port=duct_port2) """ - settings = { - "port": port, - } - - rels = settings["port"].ConnectedTo or () - rels += settings["port"].ConnectedFrom or () + rels = port.ConnectedTo or () + rels += port.ConnectedFrom or () for rel in rels: rel.RelatingPort.FlowDirection = None diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/edit_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/edit_system.py index 00311b7ac0..c90894ec7f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/edit_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/edit_system.py @@ -26,11 +26,8 @@ def edit_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance, a IfcSystem, consult the IFC documentation. :param system: The IfcSystem entity you want to edit - :type system: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -42,8 +39,5 @@ def edit_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance, a # Change the name of the system to "HW" for Hot Water ifcopenshell.api.system.edit_system(model, system=system, attributes={"Name": "HW"}) """ - - settings = {"system": system, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["system"], name, value) + for name, value in attributes.items(): + setattr(system, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/remove_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/remove_system.py index cef253cded..6cd39cc298 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/remove_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/remove_system.py @@ -27,9 +27,7 @@ def remove_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance) All the distribution elements within the system are retained. :param system: The IfcSystem to remove. - :type system: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -41,9 +39,7 @@ def remove_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance) # Delete it. ifcopenshell.api.system.remove_system(model, system=system) """ - settings = {"system": system} - - for inverse_id in [i.id() for i in file.get_inverse(settings["system"])]: + for inverse_id in [i.id() for i in file.get_inverse(system)]: try: inverse = file.by_id(inverse_id) except: @@ -51,11 +47,11 @@ def remove_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance) if inverse.is_a("IfcRelDefinesByProperties"): ifcopenshell.api.pset.remove_pset( file, - product=settings["system"], + product=system, pset=inverse.RelatingPropertyDefinition, ) elif inverse.is_a("IfcRelAssignsToGroup"): - if inverse.RelatingGroup == settings["system"]: + if inverse.RelatingGroup == system: history = inverse.OwnerHistory file.remove(inverse) if history: @@ -65,7 +61,7 @@ def remove_system(file: ifcopenshell.file, system: ifcopenshell.entity_instance) file.remove(inverse) if history: ifcopenshell.util.element.remove_deep2(file, history) - history = settings["system"].OwnerHistory - file.remove(settings["system"]) + history = system.OwnerHistory + file.remove(system) if history: ifcopenshell.util.element.remove_deep2(file, history) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_flow_control.py b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_flow_control.py index 4aeefe5a47..69b9ddfc62 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_flow_control.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_flow_control.py @@ -30,11 +30,8 @@ def unassign_flow_control( :param related_flow_control: IfcDistributionControlElement controling the flow element - :type related_flow_control: ifcopenshell.entity_instance :param relating_flow_element: The IfcDistributionFlowElement that is being controlled - :type relating_flow_element: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -53,15 +50,10 @@ def unassign_flow_control( ) """ - settings = { - "relating_flow_element": relating_flow_element, - "related_flow_control": related_flow_control, - } - - if not settings["related_flow_control"].AssignedToFlowElement: + if not related_flow_control.AssignedToFlowElement: return - assignment = settings["related_flow_control"].AssignedToFlowElement[0] - if assignment.RelatingFlowElement != settings["relating_flow_element"]: + assignment = related_flow_control.AssignedToFlowElement[0] + if assignment.RelatingFlowElement != relating_flow_element: return if len(assignment.RelatedControlElements) == 1: history = assignment.OwnerHistory @@ -70,6 +62,6 @@ def unassign_flow_control( ifcopenshell.util.element.remove_deep2(file, history) return related_flow_controls = list(assignment.RelatedControlElements) - related_flow_controls.remove(settings["related_flow_control"]) + related_flow_controls.remove(related_flow_control) assignment.RelatedControlElements = related_flow_controls ifcopenshell.api.owner.update_owner_history(file, **{"element": assignment}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_port.py index 1bf459920f..87b6c0fc16 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_port.py @@ -31,11 +31,8 @@ def unassign_port( port for cleaning or patchin purposes. :param element: The IfcDistributionElement to unassign the port from. - :type element: ifcopenshell.entity_instance :param port: The IfcDistributionPort you want to unassign. - :type port: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -54,20 +51,20 @@ def unassign_port( """ usecase = Usecase() usecase.file = file - usecase.settings = { - "element": element, - "port": port, - } - return usecase.execute() + return usecase.execute(element, port) class Usecase: - def execute(self): + file: ifcopenshell.file + + def execute(self, element: ifcopenshell.entity_instance, port: ifcopenshell.entity_instance) -> None: if self.file.schema == "IFC2X3": + self.element = element + self.port = port return self.execute_ifc2x3() - for rel in self.settings["element"].IsNestedBy or []: - if self.settings["port"] in rel.RelatedObjects: + for rel in element.IsNestedBy or []: + if port in rel.RelatedObjects: if len(rel.RelatedObjects) == 1: history = rel.OwnerHistory self.file.remove(rel) @@ -75,13 +72,13 @@ class Usecase: ifcopenshell.util.element.remove_deep2(self.file, history) return related_objects = set(rel.RelatedObjects) or set() - related_objects.remove(self.settings["port"]) + related_objects.remove(port) rel.RelatedObjects = list(related_objects) ifcopenshell.api.owner.update_owner_history(self.file, **{"element": rel}) - def execute_ifc2x3(self): - for rel in self.settings["element"].HasPorts or []: - if rel.RelatingPort == self.settings["port"]: + def execute_ifc2x3(self) -> None: + for rel in self.element.HasPorts or []: + if rel.RelatingPort == self.port: history = rel.OwnerHistory self.file.remove(rel) if history: diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_system.py b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_system.py index 4956b33b56..2921524094 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/unassign_system.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/unassign_system.py @@ -29,11 +29,8 @@ def unassign_system( """Unassigns list of products from a system :param products: The list of IfcDistributionElements to unassign from the system. - :type products: list[ifcopenshell.entity_instance] :param system: The IfcSystem you want to unassign the element from. - :type system: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -52,9 +49,4 @@ def unassign_system( # Not anymore! ifcopenshell.api.system.unassign_system(model, products=[duct], system=system) """ - settings = { - "products": products, - "system": system, - } - - ifcopenshell.api.group.unassign_group(file, products=settings["products"], group=settings["system"]) + ifcopenshell.api.group.unassign_group(file, products=products, group=system) diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py index 2a20f57f2f..f4bfae022e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/type/assign_type.py @@ -22,7 +22,7 @@ import ifcopenshell.api.owner import ifcopenshell.api.material import ifcopenshell.guid import ifcopenshell.util.element -from typing import Union, Iterable +from typing import Union, Iterable, Any def assign_type( @@ -186,6 +186,9 @@ def assign_type( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): if not self.settings["related_objects"]: return diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py b/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py index 995408b063..0c4c66ae16 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py +++ b/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py @@ -33,11 +33,8 @@ def map_type_representations( be used to ensure consistency of the occurrence's representations. :param related_object: The IfcElement occurrence. - :type related_object: ifcopenshell.entity_instance :param relating_type: The IfcElementType type. - :type relating_type: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -84,28 +81,23 @@ def map_type_representations( # ifcopenshell.api.type.map_type_representations(model, # related_object=furniture, relating_type=furniture_type) """ - settings = { - "related_object": related_object, - "relating_type": relating_type, - } - - if not settings["relating_type"].RepresentationMaps: + if not relating_type.RepresentationMaps: return representations = [] - if settings["related_object"].Representation: - representations = settings["related_object"].Representation.Representations + if related_object.Representation: + representations = related_object.Representation.Representations for representation in representations: ifcopenshell.api.geometry.unassign_representation( file, - product=settings["related_object"], + product=related_object, representation=representation, ) - ifcopenshell.api.geometry.remove_representation(file, **{"representation": representation}) - for representation_map in settings["relating_type"].RepresentationMaps: + ifcopenshell.api.geometry.remove_representation(file, representation=representation) + for representation_map in relating_type.RepresentationMaps: representation = representation_map.MappedRepresentation mapped_representation = ifcopenshell.api.geometry.map_representation(file, representation=representation) ifcopenshell.api.geometry.assign_representation( file, - product=settings["related_object"], + product=related_object, representation=mapped_representation, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/unassign_type.py b/src/ifcopenshell-python/ifcopenshell/api/type/unassign_type.py index 2723dd760f..572327afb7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/type/unassign_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/type/unassign_type.py @@ -28,9 +28,7 @@ def unassign_type(file: ifcopenshell.file, related_objects: list[ifcopenshell.en and material usages associated with the previously assigned type. :param related_objects: List of IfcElement occurrences. - :type related_objects: list[ifcopenshell.entity_instance] :return: None - :rtype: None Example: @@ -50,23 +48,21 @@ def unassign_type(file: ifcopenshell.file, related_objects: list[ifcopenshell.en # Change our mind. Maybe it's a different type? ifcopenshell.api.type.unassign_type(model, related_objects=[furniture]) """ - settings = {"related_objects": related_objects} - - related_objects = set(settings["related_objects"]) + related_objects_set = set(related_objects) if file.schema == "IFC2X3": rels = set( rel - for object in related_objects + for object in related_objects_set if (rel := next((rel for rel in object.IsDefinedBy if rel.is_a("IfcRelDefinesByType")), None)) ) else: - rels = set(rel for object in related_objects if (rel := next((rel for rel in object.IsTypedBy), None))) + rels = set(rel for object in related_objects_set if (rel := next((rel for rel in object.IsTypedBy), None))) for rel in rels: - related_objects = set(rel.RelatedObjects) - related_objects - if related_objects: - rel.RelatedObjects = list(related_objects) + related_objects_set = set(rel.RelatedObjects) - related_objects_set + if related_objects_set: + rel.RelatedObjects = list(related_objects_set) ifcopenshell.api.owner.update_owner_history(file, **{"element": rel}) else: history = rel.OwnerHistory diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_context_dependent_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_context_dependent_unit.py index 135c805f2f..f9c76f9072 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_context_dependent_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_context_dependent_unit.py @@ -35,9 +35,7 @@ def add_context_dependent_unit( sensible normal unit for. In that case, firstly stop whatever you're doing and have a hard think about your life, and then if life really is going that badly for you, check out the IFC docs for IfcUnitEnum. - :type unit_type: str :param name: Give your unit a name. X what? X bananas? - :type name: str :param dimensions: Units typically measure one of 7 fundamental physical dimensions: length, mass, time, electric current, temperature, substance amount, or luminous intensity. These are represented as a @@ -46,9 +44,7 @@ def add_context_dependent_unit( where as an area unit is (2, 0, 0, 0, 0, 0, 0). A unit of meters per second is (1, 0, -1, 0, 0, 0, 0). For context dependent units, it is recommended to leave this as the default of (0, 0, 0, 0, 0, 0, 0). - :type dimensions: list[int] :return: The new IfcContextDependentUnit - :rtype: ifcopenshell.entity_instance Example: @@ -57,11 +53,9 @@ def add_context_dependent_unit( # Boxes of things ifcopenshell.api.unit.add_context_dependent_unit(model, name="BOXES") """ - settings = {"unit_type": unit_type, "name": name, "dimensions": dimensions} - return file.create_entity( "IfcContextDependentUnit", - Dimensions=file.createIfcDimensionalExponents(*settings["dimensions"]), - UnitType=settings["unit_type"], - Name=settings["name"], + Dimensions=file.createIfcDimensionalExponents(*dimensions), + UnitType=unit_type, + Name=name, ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py index daa91145ee..bb1e920257 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py @@ -36,17 +36,14 @@ def add_conversion_based_unit( kip, psi, ksi, minute, hour, day, btu, and fahrenheit. :param name: A converted name chosen from the list above. - :type name: str :param conversion_offset: If you want to offset the conversion further by a set number, you may specify it here. For example, fahrenheit is 1.8 * kelvin - 459.67. The -459.67 is the conversion offset. Note that this is just an example and you don't actually need to specify that for fahrenheit as it's built into this API function. For advanced users only. - :type conversion_offset: float, optional :return: The new IfcConversionBasedUnit or IfcConversionBasedUnitWithOffset - :rtype: ifcopenshell.entity_instance Example: @@ -59,28 +56,26 @@ def add_conversion_based_unit( # Make it our default units, if we are doing an imperial building ifcopenshell.api.unit.assign_unit(model, units=[length, area]) """ - settings = {"name": name, "conversion_offset": conversion_offset} - unit_type = ifcopenshell.util.unit.imperial_types.get(settings["name"], "USERDEFINED") + unit_type = ifcopenshell.util.unit.imperial_types.get(name, "USERDEFINED") dimensions = ifcopenshell.util.unit.named_dimensions[unit_type] exponents = file.createIfcDimensionalExponents(*dimensions) si_name = ifcopenshell.util.unit.si_type_names[unit_type] si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name) - conversion_real = ifcopenshell.util.unit.si_conversions.get(settings["name"], 1) + conversion_real = ifcopenshell.util.unit.si_conversions.get(name, 1) value_component = file.create_entity("IfcReal", **{"wrappedValue": conversion_real}) conversion_factor = file.createIfcMeasureWithUnit(value_component, si_unit) - conversion_offset = settings["conversion_offset"] if not conversion_offset: - conversion_offset = ifcopenshell.util.unit.si_offsets.get(settings["name"], 0) + conversion_offset = ifcopenshell.util.unit.si_offsets.get(name, 0) if conversion_offset: return file.createIfcConversionBasedUnitWithOffset( exponents, unit_type, - settings["name"], + name, conversion_factor, conversion_offset, ) - return file.createIfcConversionBasedUnit(exponents, unit_type, settings["name"], conversion_factor) + return file.createIfcConversionBasedUnit(exponents, unit_type, name, conversion_factor) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py index 8a68e5727f..0fba187c75 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py @@ -46,11 +46,8 @@ def add_derived_unit( :type unit_type: str :param userdefinedtype: The user defined type in case of choosing USERDEFINED, or None for no user defined type. - :type userdefinedtype: str or None :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: The newly created IfcDerivedUnit - :rtype: ifcopenshell.entity_instance Example: @@ -69,15 +66,13 @@ def add_derived_unit( #12=IfcDerivedUnit((#10,#11),.LINEARVELOCITY.,$) """ - settings = {"unit_type": unit_type, "attributes": attributes} - derive_unit_elements = [] - for named_unit in settings["attributes"]: + for named_unit in attributes: derive_unit_elements.append( - file.create_entity("IfcDerivedUnitElement", Unit=named_unit, Exponent=settings["attributes"][named_unit]) + file.create_entity("IfcDerivedUnitElement", Unit=named_unit, Exponent=attributes[named_unit]) ) return file.create_entity( - "IfcDerivedUnit", Elements=derive_unit_elements, UnitType=settings["unit_type"], UserDefinedType=userdefinedtype + "IfcDerivedUnit", Elements=derive_unit_elements, UnitType=unit_type, UserDefinedType=userdefinedtype ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_monetary_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_monetary_unit.py index 8d4a3130f0..6c5a90e12d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_monetary_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_monetary_unit.py @@ -26,9 +26,7 @@ def add_monetary_unit(file: ifcopenshell.file, currency: str = "DOLLARYDOO") -> USD, GBP, AUD, MYR, etc. :param currency: The currency code - :type currency: str :return: The newly created IfcMonetaryUnit - :rtype: ifcopenshell.entity_instance Example: @@ -41,6 +39,4 @@ def add_monetary_unit(file: ifcopenshell.file, currency: str = "DOLLARYDOO") -> # Make it our default currency ifcopenshell.api.unit.assign_unit(model, units=[zwl]) """ - settings = {"currency": currency} - - return file.create_entity("IfcMonetaryUnit", settings["currency"]) + return file.create_entity("IfcMonetaryUnit", currency) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_si_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_si_unit.py index 56cab159ab..50da1c10f2 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_si_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_si_unit.py @@ -40,12 +40,9 @@ def add_si_unit( :param unit_type: A type of unit chosen from the list above. For example, choosing LENGTHUNIT will give you a metre. - :type unit_type: str :param prefix: A prefix chosen from the list above, or None for no prefix. - :type prefix: str,optional :return: The newly created IfcSIUnit - :rtype: ifcopenshell.entity_instance Example: @@ -58,7 +55,5 @@ def add_si_unit( # Make it our default units, if we are doing a metric building ifcopenshell.api.unit.assign_unit(model, units=[length, area]) """ - settings = {"unit_type": unit_type, "prefix": prefix} - - name = ifcopenshell.util.unit.si_type_names.get(settings["unit_type"], None) - return file.create_entity("IfcSIUnit", UnitType=settings["unit_type"], Name=name, Prefix=settings["prefix"]) + name = ifcopenshell.util.unit.si_type_names.get(unit_type, None) + return file.create_entity("IfcSIUnit", UnitType=unit_type, Name=name, Prefix=prefix) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index 4e2dc3f848..d57acbd435 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -18,7 +18,7 @@ import ifcopenshell import ifcopenshell.util.unit -from typing import Optional +from typing import Optional, Any def assign_unit( @@ -75,6 +75,9 @@ def assign_unit( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self): # We're going to refactor this to split unit creation and assignment if self.settings["units"]: diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_derived_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_derived_unit.py index a8b3316dbd..5bc9bbd316 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_derived_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_derived_unit.py @@ -26,13 +26,8 @@ def edit_derived_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instanc IfcDerivedUnit, consult the IFC documentation. :param unit: The IfcDerivedUnit entity you want to edit - :type unit: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None """ - settings = {"unit": unit, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["unit"], name, value) + for name, value in attributes.items(): + setattr(unit, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_monetary_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_monetary_unit.py index 4fdfe6ec29..bccc61afc0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_monetary_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_monetary_unit.py @@ -26,11 +26,8 @@ def edit_monetary_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instan IfcMonetaryUnit, consult the IFC documentation. :param unit: The IfcMonetaryUnit entity you want to edit - :type unit: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -43,7 +40,5 @@ def edit_monetary_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instan # Ah who are we kidding ifcopenshell.api.unit.edit_monetary_unit(model, unit=zwl, attributes={"Currency": "USD"}) """ - settings = {"unit": unit, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): - setattr(settings["unit"], name, value) + for name, value in attributes.items(): + setattr(unit, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_named_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_named_unit.py index e439e2c476..683feb7216 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/edit_named_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/edit_named_unit.py @@ -29,11 +29,8 @@ def edit_named_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance, IfcNamedUnit, consult the IFC documentation. :param unit: The IfcNamedUnit entity you want to edit - :type unit: ifcopenshell.entity_instance :param attributes: a dictionary of attribute names and values. - :type attributes: dict :return: None - :rtype: None Example: @@ -45,15 +42,13 @@ def edit_named_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance, # Uh, crates? Boxes? Whatever. ifcopenshell.api.unit.edit_named_unit(model, unit=unit, attibutes={"Name": "CRATES"}) """ - settings = {"unit": unit, "attributes": attributes or {}} - - for name, value in settings["attributes"].items(): + for name, value in attributes.items(): if name == "Dimensions": - dimensions = settings["unit"].Dimensions - if len(file.get_inverse(dimensions)) > 1: - settings["unit"].Dimensions = file.createIfcDimensionalExponents(*value) + dimensions = unit.Dimensions + if file.get_total_inverses(dimensions) > 1: + unit.Dimensions = file.createIfcDimensionalExponents(*value) else: for i, exponent in enumerate(value): dimensions[i] = exponent continue - setattr(settings["unit"], name, value) + setattr(unit, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py index 88cffbc4a0..a9920e0e5c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py @@ -27,9 +27,7 @@ def remove_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance) -> defined quantities in the model completely lose their meaning. :param unit: The unit element to remove - :type unit: ifcopenshell.entity_instance :return: None - :rtype: None Example: @@ -41,14 +39,12 @@ def remove_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance) -> # Yeah maybe not. ifcopenshell.api.unit.remove_unit(model, unit=unit) """ - settings = {"unit": unit} - unit_assignment = ifcopenshell.util.unit.get_unit_assignment(file) - if unit_assignment and settings["unit"] in unit_assignment.Units: + if unit_assignment and unit in unit_assignment.Units: units = list(unit_assignment.Units) - units.remove(settings["unit"]) + units.remove(unit) if units: unit_assignment.Units = units else: file.remove(unit_assignment) - ifcopenshell.util.element.remove_deep(file, settings["unit"]) + ifcopenshell.util.element.remove_deep(file, unit) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py index 17eee6d4be..9651bfa87e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py @@ -23,9 +23,7 @@ def unassign_unit(file: ifcopenshell.file, units: Optional[list[ifcopenshell.ent """Unassigns units as default units for the project :param units: A list of units to assign as project defaults. - :type units: list[ifcopenshell.entity_instance],optional :return: None - :rtype: None Example: @@ -44,15 +42,13 @@ def unassign_unit(file: ifcopenshell.file, units: Optional[list[ifcopenshell.ent # Actually, we don't need areas. ifcopenshell.api.unit.unassign_unit(model, units=[area]) """ - settings = {"units": units} - - unit_assignment = file.by_type("IfcUnitAssignment") - if not unit_assignment: + unit_assignments = file.by_type("IfcUnitAssignment") + if not unit_assignments: + return + unit_assignment = unit_assignments[0] + units_set = set(unit_assignment.Units or []) + units_set = units_set - set(units or []) + if units_set: + unit_assignment.Units = list(units_set) return - unit_assignment = unit_assignment[0] - units = set(unit_assignment.Units or []) - units = units - set(settings["units"]) - if units: - unit_assignment.Units = list(units) - return unit_assignment file.remove(unit_assignment) diff --git a/src/ifcopenshell-python/ifcopenshell/entity_instance.py b/src/ifcopenshell-python/ifcopenshell/entity_instance.py index dcd3b74cb0..d01c618d52 100644 --- a/src/ifcopenshell-python/ifcopenshell/entity_instance.py +++ b/src/ifcopenshell-python/ifcopenshell/entity_instance.py @@ -248,14 +248,10 @@ class entity_instance: :param f: A callable that takes a single argument and returns a boolean value. It represents the condition. - :type f: Callable :param g: A callable that takes a single argument and returns a transformed value. It represents the transformation. - :type g: Callable :param value: Any object, the input value to be processed - :type value: Any :return: Transformed value - :rtype: Any Example: @@ -304,8 +300,6 @@ class entity_instance: """Return the data type of a positional attribute of the element :param attr: The index or name of the attribute - :type attr: Union[int, str] - :rtype: string """ attr_idx = attr if isinstance(attr, numbers.Integral) else self.wrapped_data.get_argument_index(attr) return self.wrapped_data.get_argument_type(attr_idx) @@ -314,8 +308,6 @@ class entity_instance: """Return the name of a positional attribute of the element :param attr_idx: The index of the attribute - :type attr_idx: int - :rtype: string """ return self.wrapped_data.get_argument_name(attr_idx) @@ -405,9 +397,7 @@ class entity_instance: returned IFC class name should include schema name (e.g. "IFC4.IfcWall" if `True` and "IfcWall" if `False`). If omitted will act as `False`. - :type args: Union[str, bool] :returns: Either the name of the class, or a boolean if it passes the check - :rtype: Union[str, bool] Example: @@ -423,10 +413,7 @@ class entity_instance: return self.wrapped_data.is_a(*args) def id(self) -> int: - """Return the STEP numerical identifier - - :rtype: int - """ + """Return the STEP numerical identifier""" return self.wrapped_data.id() def __eq__(self, other: "entity_instance") -> bool: @@ -626,7 +613,7 @@ class entity_instance: include_identifier: bool = True, recursive: bool = False, return_type: type[dict] = dict, - ignore: Iterable[str] = (), + ignore: Sequence[str] = (), ) -> dict[str, Any]: """More perfomant version of `.get_info()` but with limited arguments values.\n Method has exactly the same signature as `.get_info()` but it doesn't support getting information non-recursively. diff --git a/src/ifcopenshell-python/ifcopenshell/file.py b/src/ifcopenshell-python/ifcopenshell/file.py index 04c6c2ce50..68d939a046 100644 --- a/src/ifcopenshell-python/ifcopenshell/file.py +++ b/src/ifcopenshell-python/ifcopenshell/file.py @@ -24,6 +24,7 @@ import zipfile import functools import ifcopenshell from pathlib import Path +from typing import Any from typing import Callable from typing import Generator from typing import Optional @@ -358,7 +359,6 @@ class file: of those methods. :param type: Case insensitive name of the IFC class - :type type: string :param args: The positional arguments of the IFC class :param kwargs: The keyword arguments of the IFC class :returns: An entity instance @@ -481,12 +481,10 @@ class file: """Return an IFC entity instance filtered by IFC ID. :param id: STEP numerical identifier - :type id: int :raises RuntimeError: If `id` is not found. :returns: An ifcopenshell.entity_instance - :rtype: ifcopenshell.entity_instance """ return self[id] @@ -494,13 +492,10 @@ class file: """Return an IFC entity instance filtered by IFC GUID. :param guid: GlobalId value in 22-character encoded form - :type guid: string :raises RuntimeError: If `guid` is not found. :returns: An ifcopenshell.entity_instance - :rtype: ifcopenshell.entity_instance - """ return self[guid] @@ -509,9 +504,7 @@ class file: If the entity already exists, it is not re-added. Existence of entity is checked by it's `.identity()`. :param inst: The entity instance to add - :type inst: ifcopenshell.entity_instance :returns: An ifcopenshell.entity_instance - :rtype: ifcopenshell.entity_instance """ if self.transaction: @@ -529,14 +522,11 @@ class file: If an IFC type class has subclasses, all entities of those subclasses are also returned. :param type: The case insensitive type of IFC class to return. - :type type: string :param include_subtypes: Whether or not to return subtypes of the IFC class - :type include_subtypes: bool :raises RuntimeError: If `type` is not found in IFC schema. :returns: A list of ifcopenshell.entity_instance objects - :rtype: list[ifcopenshell.entity_instance] """ if include_subtypes: return [entity_instance(e, self) for e in self.wrapped_data.by_type(type)] @@ -624,9 +614,7 @@ class file: significantly faster. :param inst: The entity instance to get inverse relationships - :type inst: ifcopenshell.entity_instance :returns: The total number of references - :rtype: int """ return self.wrapped_data.get_total_inverses(inst.wrapped_data) @@ -638,8 +626,6 @@ class file: the reference to the deleted will be removed from the aggregate. :param inst: The entity instance to delete - :type inst: ifcopenshell.entity_instance - :rtype: None """ if self.transaction: self.transaction.store_delete(inst) @@ -675,9 +661,7 @@ class file: if None. Supported formats : .ifc, .ifcXML, .ifcZIP (equivalent to format=".ifc" with zipped=True) For zipped .ifcXML use format=".ifcXML" with zipped=True - :type format: str :param zipped: zip the file after it is written - :type zipped: bool Example: @@ -691,6 +675,7 @@ class file: """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) + if format == None: format = ifcopenshell.guess_format(path) if format == ".ifcXML": @@ -706,8 +691,7 @@ class file: if format == ".ifcZIP": return self.write(path, ".ifc", zipped=True) self.wrapped_data.write(str(path)) - if not path.exists(): - raise PermissionError(f"Failed to write to '{path}', check folder permissions.") + if zipped: unzipped_path = path.with_suffix(format) path.rename(unzipped_path) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 1f9215cb5c..07d267c06f 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -66,7 +66,9 @@ SETTING = Literal[ "apply-default-materials", "boolean-attempt-2d", "building-local-placement", + "cgal-original-edges", "circle-segments", + "compute-curvature", "context-identifiers", "context-ids", "context-types", @@ -223,6 +225,55 @@ class settings_mixin: else: raise AttributeError("'Settings' object has no attribute '%s'" % k) + def build_parser(self, parser) -> None: + """ + Accepts an argparse.ArgumentParser object, enumerates the settings in this container and + adds argument parser rules for each. + """ + type_factories = { + "bool": bool, + "int": int, + "double": float, + "std::string": str, + "std::set": lambda s: list(map(int, s.split(";"))), + "std::set": lambda s: s.split(";"), + "std::vector": lambda s: list(map(float, s.split(";"))), + "IteratorOutputOptions": int, + "FunctionStepMethod": int, + "OutputDimensionalityTypes": int, + "TriangulationMethod": int, + } + for nm in self.setting_names(): + if nm == "use-python-opencascade": + ty == "bool" + else: + ty = self.get_type(nm) + if ty == "bool": + group = parser.add_mutually_exclusive_group() + group.add_argument( + f"--{nm}", + dest=nm, + action="store_true", + ) + group.add_argument( + f"--no-{nm}", + dest=nm, + action="store_false", + ) + parser.set_defaults(**{nm: None}) + else: + parser.add_argument(f"--{nm}", dest=nm, type=type_factories[ty]) + + def apply_namespace(self, namespace) -> None: + """ + Accepts an argparse.Namespace object, enumerates over the values in this namespace and + writes them to the settings when available + """ + names = set(self.setting_names()) + for k, v in namespace._get_kwargs(): + if k in names and v is not None: + self.set(k, v) + class serializer_settings(settings_mixin, ifcopenshell_wrapper.SerializerSettings): pass @@ -590,7 +641,10 @@ class serializers: hdf5 = ifcopenshell_wrapper.HdfSerializer except: pass - + try: + collada = ifcopenshell_wrapper.ColladaSerializer + except: + pass # ttl is always available since it doesn't depend on any C++ libraries, # just people might be using an outdated binary if hasattr(ifcopenshell_wrapper, "TtlWktSerializer"): @@ -601,3 +655,22 @@ class serializers: ) -> ifcopenshell_wrapper.SvgSerializer: out_filename = transform_string(out_filename) return ifcopenshell_wrapper.TtlWktSerializer(out_filename, geometry_settings, settings) + + @classmethod + def guess_from_extension(cls, filepath: str): + ext = filepath.split(".")[-1] + mapping = { + "glb": "gltf", + "hdf": "hdf5", + "h5": "hdf5", + "hdf5": "hdf5", + "obj": "obj", + "svg": "svg", + "ttl": "ttl", + "xml": "xml", + "dae": "collada", + } + serializer_name = mapping.get(ext) + if not serializer_name: + raise ValueError(f"No serializer available for .{ext} file") + return getattr(cls, serializer_name) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py index 21ef97ac65..eb3fa7ac52 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py @@ -232,7 +232,7 @@ def serialize_shape(shape): def create_shape_from_serialization( - brep_object: Union[ifcopenshell_wrapper.SerializedElement, ifcopenshell_wrapper.Serialization] + brep_object: Union[ifcopenshell_wrapper.SerializedElement, ifcopenshell_wrapper.Serialization], ) -> Union[shape_tuple, TopoDS.TopoDS_Shape]: brep_data, occ_shape, styles, style_ids = None, None, (), () diff --git a/src/ifcopenshell-python/ifcopenshell/util/attribute.py b/src/ifcopenshell-python/ifcopenshell/util/attribute.py index f29f60ed8b..06cd10fcff 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/attribute.py +++ b/src/ifcopenshell-python/ifcopenshell/util/attribute.py @@ -21,7 +21,7 @@ from typing import Union def get_primitive_type( - attribute_or_data_type: Union[ifcopenshell_wrapper.attribute, ifcopenshell_wrapper.parameter_type] + attribute_or_data_type: Union[ifcopenshell_wrapper.attribute, ifcopenshell_wrapper.parameter_type], ) -> Union[str, tuple[str, list[str]]]: if hasattr(attribute_or_data_type, "type_of_attribute"): data_type = str(attribute_or_data_type.type_of_attribute()) diff --git a/src/ifcopenshell-python/ifcopenshell/util/brick.py b/src/ifcopenshell-python/ifcopenshell/util/brick.py index 9070a0054b..a50caeaa05 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/brick.py +++ b/src/ifcopenshell-python/ifcopenshell/util/brick.py @@ -21,6 +21,7 @@ import json import ifcopenshell import ifcopenshell.util.element import ifcopenshell.util.classification +from typing import Union cwd = os.path.dirname(os.path.realpath(__file__)) @@ -31,7 +32,7 @@ with open(os.path.join(cwd, "ifc4_to_brick.json")) as f: ifc4_to_brick_map = json.load(f) -def get_brick_type(element): +def get_brick_type(element: ifcopenshell.entity_instance) -> Union[str, None]: references = ifcopenshell.util.classification.get_references(element) for reference in references: system = ifcopenshell.util.classification.get_classification(reference) @@ -61,10 +62,10 @@ def get_brick_type(element): return f"https://brickschema.org/schema/Brick#System" -def get_element_feeds(element): +def get_element_feeds(element: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]: current_element = element processed_elements = set() - downstream_equipment = set() + downstream_equipment: set[ifcopenshell.entity_instance] = set() # A queue is a list of branches. A branch is a list of elements in # sequence, each one connecting to another element. An element in a diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index b63976a4ed..9c434d6ce3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -280,8 +280,8 @@ def get_cost_values(cost_item: ifcopenshell.entity_instance) -> list[dict[str, s return results -def get_cost_schedule_types(file): - schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(file.schema) +def get_cost_schedule_types(file: ifcopenshell.file) -> list[dict[str, str]]: + schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(file.schema_identifier) results = [] declaration = schema.declaration_by_name("IfcCostSchedule") version = file.schema_identifier diff --git a/src/ifcopenshell-python/ifcopenshell/util/data.py b/src/ifcopenshell-python/ifcopenshell/util/data.py index 365f0df2cb..75fae18ed5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/data.py +++ b/src/ifcopenshell-python/ifcopenshell/util/data.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +from __future__ import annotations import numpy as np import ifcopenshell from typing import Any, Union @@ -30,7 +31,9 @@ class Clipping: operand_type: str = "IfcHalfSpaceSolid" @classmethod - def parse(cls, raw_data: Any) -> Union[ifcopenshell.entity_instance, "Clipping", None]: + def parse( + cls, raw_data: Union[ifcopenshell.entity_instance, Clipping, dict[str, Any]] + ) -> Union[ifcopenshell.entity_instance, Clipping]: """Parse various formats into a clipping object `raw_data` can be either: @@ -63,7 +66,7 @@ class Clipping: raise Exception(f"Unexpected clipping type provided: {raw_data}") def apply( - self, ifc_file: ifcopenshell.file, first_operand: ifcopenshell.entity_instance, unit_scale: float + self, ifc_file: Union[ifcopenshell.file, None], first_operand: ifcopenshell.entity_instance, unit_scale: float ) -> ifcopenshell.entity_instance: """Applies the clipping data as an IfcBooleanClippingResult to an operand @@ -73,6 +76,9 @@ class Clipping: :return: An IfcBooleanClippingResult which uses an IfcHalfSpaceSolid to clip the first operand """ + if not ifc_file: + ifc_file = first_operand.file + location = ifc_file.createIfcCartesianPoint([i / unit_scale for i in self.location]) direction = ifc_file.createIfcDirection(self.normal) diff --git a/src/ifcopenshell-python/ifcopenshell/util/doc.py b/src/ifcopenshell-python/ifcopenshell/util/doc.py index b444966068..0a2dad205b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/doc.py +++ b/src/ifcopenshell-python/ifcopenshell/util/doc.py @@ -113,8 +113,7 @@ def get_schema_by_name(version: str) -> ifcopenshell_wrapper.schema_definition: global schema_by_name version = ifcopenshell.util.schema.get_fallback_schema(version) if not schema_by_name[version]: - schema_name = "IFC4X3_ADD2" if version == "IFC4X3" else version - schema_by_name[version] = ifcopenshell_wrapper.schema_by_name(schema_name) + schema_by_name[version] = ifcopenshell.schema_by_name(version) return schema_by_name[version] diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index fb7bde9a2d..4f21aa8a98 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -20,7 +20,7 @@ import ifcopenshell import ifcopenshell.guid import ifcopenshell.util.element import ifcopenshell.util.representation -from typing import Any, Callable, Optional, Union, Literal, overload, Sequence +from typing import Any, Callable, Optional, Union, Literal, overload, Sequence, Generator from collections import namedtuple @@ -454,7 +454,59 @@ def get_elements_by_pset(pset: ifcopenshell.entity_instance) -> set[ifcopenshell return elements -def get_predefined_type(element: ifcopenshell.entity_instance) -> str: +def get_element_mass_density(element: ifcopenshell.entity_instance) -> Union[float, None]: + """Calculate object mass density based on material's Pset_MaterialCommon.MassDensity. + + :param element: IFC element entity. + :return: ``float`` mass density in project units if calculation was successful, ``None`` if element either + doesn't have a material or this type of material is not supported. + """ + material = ifcopenshell.util.element.get_material(element) + if material is None: + return + + if ( + material.is_a("IfcMaterialLayerSet") + or material.is_a("IfcMaterialProfileSet") + or material.is_a("IfcMaterialConstituentSet") + ): + return + + if material.is_a("IfcMaterial"): + material_mass_density = ifcopenshell.util.element.get_pset(material, "Pset_MaterialCommon", "MassDensity") + return material_mass_density + + if material.is_a("IfcMaterialLayerSetUsage"): + material_layers = material.ForLayerSet.MaterialLayers + densities = [] + thicknesses = [] + obj_mass_density = 0 + for material_layer in material_layers: + material_mass_density = ifcopenshell.util.element.get_pset( + material_layer.Material, "Pset_MaterialCommon", "MassDensity" + ) + if material_mass_density is None: + return + densities.append(material_mass_density) + thickness = material_layer.LayerThickness + thicknesses.append(thickness) + obj_mass_density = obj_mass_density + (material_mass_density * thickness) + total_thickness = sum(thicknesses) + obj_mass_density = obj_mass_density / total_thickness + return obj_mass_density + + if material.is_a("IfcMaterialProfileSetUsage"): + material_profiles = material.ForProfileSet.MaterialProfiles + if len(material_profiles) == 1: + material_mass_density = ifcopenshell.util.element.get_pset( + material_profiles[0].Material, "Pset_MaterialCommon", "MassDensity" + ) + return material_mass_density + else: + return + + +def get_predefined_type(element: ifcopenshell.entity_instance) -> Union[str, None]: """Retrieves the PrefefinedType attribute of an element. If the predefined type is user defined, the custom type (such as object @@ -539,10 +591,16 @@ def get_types(type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_in return [] -def get_shape_aspects(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: +def get_shape_aspects( + element: ifcopenshell.entity_instance, + should_inherit: bool = True, +) -> list[ifcopenshell.entity_instance]: """Get element's shape aspects. :param element: IfcProduct or IfcTypeProduct. + :param should_inherit: If True, the shape aspects of the element's type will be considered. + Useful in cases when IfcShapeAspects are assigned to the type's IfcRepresentationMap + instead of the element's IfcProductDefinitionShape. :return: The associated shape aspects of the element. Example: @@ -555,7 +613,11 @@ def get_shape_aspects(element: ifcopenshell.entity_instance) -> list[ifcopenshel # IfcProduct if (representation := getattr(element, "Representation", ...)) != ...: - return representation.HasShapeAspects + shape_aspects: list[ifcopenshell.entity_instance] = [] + if should_inherit and (element_type := get_type(element)): + shape_aspects.extend(get_shape_aspects(element_type)) + shape_aspects.extend(representation.HasShapeAspects) + return shape_aspects if element.file.schema == "IFC2X3": return [] @@ -689,7 +751,7 @@ def get_styles(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entit # TODO: ifc_file argument is unnecessary for some methods now # since we have entity_instance.file, so we can deprecate it. def get_elements_by_material( - ifc_file: ifcopenshell.file, material: ifcopenshell.entity_instance + ifc_file: Union[ifcopenshell.file, None], material: ifcopenshell.entity_instance ) -> set[ifcopenshell.entity_instance]: """Retrieves the elements related to a material. @@ -707,6 +769,8 @@ def get_elements_by_material( material = file.by_type("IfcMaterial")[0] elements = ifcopenshell.util.element.get_elements_by_material(file, material) """ + if not ifc_file: + ifc_file = material.file results = set() for inverse in ifc_file.get_inverse(material): if inverse.is_a("IfcRelAssociatesMaterial"): @@ -730,7 +794,7 @@ def get_elements_by_material( def get_elements_by_style( - ifc_file: ifcopenshell.file, style: ifcopenshell.entity_instance + ifc_file: Union[ifcopenshell.file, None], style: ifcopenshell.entity_instance ) -> set[ifcopenshell.entity_instance]: """Retrieves the elements whose geometric representation uses a style @@ -745,6 +809,8 @@ def get_elements_by_style( style = file.by_type("IfcSurfaceStyle")[0] elements = ifcopenshell.util.element.get_elements_by_style(file, style) """ + if not ifc_file: + ifc_file = style.file results = set() inverses = list(ifc_file.get_inverse(style)) while inverses: @@ -778,7 +844,7 @@ def get_elements_by_style( def get_elements_by_representation( - ifc_file: ifcopenshell.file, representation: ifcopenshell.entity_instance + ifc_file: Union[ifcopenshell.file, None], representation: ifcopenshell.entity_instance ) -> set[ifcopenshell.entity_instance]: """Gets all elements using a geometric representation @@ -793,6 +859,8 @@ def get_elements_by_representation( representation = file.by_type("IfcShapeRepresentation")[0] elements = ifcopenshell.util.element.get_elements_by_representation(file, representation) """ + if not ifc_file: + ifc_file = representation.file results = set() [results.update(pr.ShapeOfProduct) for pr in representation.OfProductRepresentation] for rep_map in representation.RepresentationMap: @@ -838,7 +906,7 @@ def get_elements_by_profile(profile: ifcopenshell.entity_instance) -> set[ifcope def get_elements_by_layer( - ifc_file: ifcopenshell.file, layer: ifcopenshell.entity_instance + ifc_file: Union[ifcopenshell.file, None], layer: ifcopenshell.entity_instance ) -> set[ifcopenshell.entity_instance]: """Get all the elements that are used by a presentation layer @@ -846,6 +914,8 @@ def get_elements_by_layer( :param layer: The IfcPresentationLayerAssignment layer :return: The elements using the geometric representation """ + if not ifc_file: + ifc_file = layer.file results = set() for item in layer.AssignedItems or []: if item.is_a("IfcShapeRepresentation"): @@ -858,7 +928,7 @@ def get_elements_by_layer( def get_layers( - ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance + ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance ) -> list[ifcopenshell.entity_instance]: """Get the CAD layers that an element is part of @@ -876,6 +946,8 @@ def get_layers( element = ifcopenshell.by_type("IfcWall")[0] layers = ifcopenshell.util.element.get_layers(element) """ + if not ifc_file: + ifc_file = element.file layers = [] representations = [] if representation := getattr(element, "Representation", None): @@ -935,7 +1007,7 @@ def get_container( return container container = get_aggregate(container) elif parent := get_parent(element): - return get_container(parent, should_get_direct) + return get_container(parent, should_get_direct, ifc_class) def get_referenced_structures(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: @@ -1333,7 +1405,7 @@ def get_referenced_elements(reference: ifcopenshell.entity_instance) -> set[ifco def replace_element(element: ifcopenshell.entity_instance, replacement: ifcopenshell.entity_instance) -> None: - for inverse in element.file: + for inverse in element.file.get_inverse(element): replace_attribute(inverse, element, replacement) @@ -1352,12 +1424,14 @@ def has_element_reference(value: Any, element: ifcopenshell.entity_instance) -> return value == element -def remove_deep(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance) -> None: +def remove_deep(ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance) -> None: """Recursively purges a subgraph safely. Do not use, use remove_deep2() instead. """ # @todo maybe some sort of try-finally mechanism. + if not ifc_file: + ifc_file = element.file ifc_file.batch() subgraph = list(ifc_file.traverse(element, breadth_first=True)) subgraph_set = set(subgraph) @@ -1431,10 +1505,10 @@ def unbatch_remove_deep2(ifc_file: ifcopenshell.file) -> ifcopenshell.file: def remove_deep2( - ifc_file: ifcopenshell.file, + ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, also_consider: list[ifcopenshell.entity_instance] = [], - do_not_delete: list[ifcopenshell.entity_instance] = [], + do_not_delete: set[ifcopenshell.entity_instance] = set(), ) -> None: """Recursively purges a subgraph safely, starting at an element @@ -1462,28 +1536,36 @@ def remove_deep2( :param ifc_file: The IFC file object :param also_consider: elements to also consider as a part of a subgraph + Order could matter for perfomance - elements that reference `element` + directly should go first for the better performance. :param do_not_delete: elements to protect from deletion :param element: The starting element that defines the subgraph """ # ifc_file.batch() - also_considered_inverses = 0 + if not ifc_file: + ifc_file = element.file + total_inverses = ifc_file.get_total_inverses(element) + if total_inverses > 0: - def increment_considered_inverses(_): - nonlocal also_considered_inverses - also_considered_inverses += 1 + def are_inverses_contained() -> bool: + also_considered_inverses = 0 - for considered_element in also_consider: - for attribute in considered_element: - considered_element.walk(lambda x: x == element, increment_considered_inverses, attribute) + for considered_element in also_consider: + traverse = ifc_file.traverse(considered_element, max_levels=1) + if element in traverse: + also_considered_inverses += 1 + if total_inverses == also_considered_inverses: + return True + return False - if ifc_file.get_total_inverses(element) > 0 + also_considered_inverses: - return + if not are_inverses_contained(): + return to_delete = set() subgraph = list(ifc_file.traverse(element, breadth_first=True)) subgraph.extend(also_consider) subgraph_set = set(subgraph) - subelement_queue = ifc_file.traverse(element, max_levels=1) + subelement_queue = [element] while subelement_queue: subelement = subelement_queue.pop(0) if ( @@ -1521,7 +1603,9 @@ def remove_deep2( # ifc_file.unbatch() -def copy(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: +def copy( + ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance +) -> ifcopenshell.entity_instance: """ Copy a single element. Any referenced elements are not copied. @@ -1531,6 +1615,8 @@ def copy(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance) -> :param element: The IFC element to copy :return: The newly copied element """ + if not ifc_file: + ifc_file = element.file new = ifc_file.create_entity(element.is_a()) for i, attribute in enumerate(element): if attribute is None: @@ -1543,7 +1629,7 @@ def copy(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance) -> def copy_deep( - ifc_file: ifcopenshell.file, + ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, exclude: Optional[Sequence[str]] = None, exclude_callback: Optional[Callable[[ifcopenshell.entity_instance], bool]] = None, @@ -1566,6 +1652,8 @@ def copy_deep( be left as None. :return: The newly copied element """ + if not ifc_file: + ifc_file = element.file if copied_entities is None: copied_entities = {} else: @@ -1632,3 +1720,27 @@ def has_property(product: ifcopenshell.entity_instance, property_name: str) -> b return True qtos = get_psets(product, qtos_only=True) return any(property_name in quantities.keys() for quantities in qtos.values()) + + +def get_openings(element: ifcopenshell.entity_instance) -> Generator[ifcopenshell.entity_instance, None, None]: + """Get element openings as IfcRelVoidsElements. + + Use `.RelatedOpeningElement` to get the opening element. + + :param element: IfcElement. + :return: Generator of IfcRelVoidsElements. + """ + for element_rel in getattr(element, "HasOpenings", ()): + yield element_rel + + if aggregate := get_aggregate(element): + yield from get_openings(aggregate) + + +def has_openings(element: ifcopenshell.entity_instance) -> bool: + """Check if the element has openings. + + :param element: IfcElement. + :return: True if element has openings. + """ + return bool(next(get_openings(element), False)) diff --git a/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py b/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py index 1de000fa4e..b0bf0cdda5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py +++ b/src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py @@ -126,7 +126,7 @@ class PsetTemplatesGenerator: schema_name = schema_name.upper() self.ifc_file = ifcopenshell.api.project.create_file(version=schema_name) self.units = dict() - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema_name) + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.ifc_file.schema_identifier) self.ifc_derived_unit_enum = ( schema.declaration_by_name("IfcDerivedUnitEnum").as_enumeration_type().enumeration_items() diff --git a/src/ifcopenshell-python/ifcopenshell/util/placement.py b/src/ifcopenshell-python/ifcopenshell/util/placement.py index d6caa1b657..5f8ad154b6 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/placement.py +++ b/src/ifcopenshell-python/ifcopenshell/util/placement.py @@ -137,9 +137,7 @@ def get_cartesiantransformationoperator3d(inst: ifcopenshell.entity_instance) -> ``get_mappeditem_transformation`` instead. :param item: The IfcCartesianTransformationOperator entity - :type item: ifcopenshell.entity_instance :return: A 4x4 numpy transformation matrix - :rtype: MatrixType """ origin = np.array(inst.LocalOrigin.Coordinates) axis1 = np.array((1.0, 0.0, 0.0)) diff --git a/src/ifcopenshell-python/ifcopenshell/util/pset.py b/src/ifcopenshell-python/ifcopenshell/util/pset.py index 08b10996c0..025f9f042c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/pset.py +++ b/src/ifcopenshell-python/ifcopenshell/util/pset.py @@ -24,7 +24,7 @@ import ifcopenshell.util.schema import ifcopenshell.util.type from ifcopenshell.entity_instance import entity_instance from functools import lru_cache -from typing import Optional, Literal +from typing import Optional, Literal, NamedTuple, Union templates: dict[str, "PsetQto"] = {} @@ -46,9 +46,7 @@ class PsetQto: # fmt: on def __init__(self, schema_identifier: str, templates=None) -> None: - if schema_identifier == "IFC4X3": - schema_identifier = "IFC4X3_ADD2" - self.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema_identifier) + self.schema = ifcopenshell.schema_by_name(schema_identifier) if not templates: folder_path = pathlib.Path(__file__).parent.absolute() path = str(folder_path.joinpath("schema", self.templates_path[schema_identifier])) @@ -111,11 +109,17 @@ class PsetQto: template_type: str = "NOTDEFINED", schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", ) -> bool: - """applicables can have multiple possible patterns : - IfcBoilerType (IfcClass) - IfcBoilerType/STEAM (IfcClass/PREDEFINEDTYPE) - IfcBoilerType[PerformanceHistory] (IfcClass[PerformanceHistory]) - IfcBoilerType/STEAM[PerformanceHistory] (IfcClass/PREDEFINEDTYPE[PerformanceHistory]) + """ + + applicables can have multiple possible patterns : + + .. code-block:: text + + IfcBoilerType (IfcClass) + IfcBoilerType/STEAM (IfcClass/PREDEFINEDTYPE) + IfcBoilerType[PerformanceHistory] (IfcClass[PerformanceHistory]) + IfcBoilerType/STEAM[PerformanceHistory] (IfcClass/PREDEFINEDTYPE[PerformanceHistory]) + """ for applicable in applicables.split(","): match = re.match(r"(\w+)(\[\w+\])*/*(\w+)*(\[\w+\])*", applicable) @@ -194,3 +198,37 @@ def get_pset_template_type(pset_template: entity_instance) -> Literal["PSET", "Q pset_type = next(iter(pset_types)) if len(pset_types) == 1 else None return pset_type + + +class ApplicableEntity(NamedTuple): + value: str + ifc_class: str + predefined_type: Union[str, None] + performance_history: bool + + +def parse_applicable_entity(applicable_entity: str) -> list[ApplicableEntity]: + """Parse ApplicableEntity string query to tuples. + + :param applicable_entity: IfcPropertySetTemplate.ApplicableEntity query. + :return: List of ApplicableEntity tuples. + """ + items: list[ApplicableEntity] = [] + for item in applicable_entity.split(","): + value = item + item, predefined_type = parts if len(parts := item.split("/")) > 1 else (item, None) + ifc_class, performance_history = (parts[0], True) if len(parts := item.split("[")) > 1 else (item, False) + items.append(ApplicableEntity(value, ifc_class, predefined_type, performance_history)) + return items + + +def convert_applicable_entities_to_query(applicable_entities: list[ApplicableEntity]) -> str: + """Get query supported by :func:`ifcopenshell.util.selector.filter_elements`.""" + parts: list[str] = [] + for entity in applicable_entities: + # NOTE: selector currently doesn't support checking if element has performance history. + part = entity.ifc_class + if entity.predefined_type: + part += f', PredefinedType="{entity.predefined_type}"' + parts.append(part) + return " + ".join(parts) diff --git a/src/ifcopenshell-python/ifcopenshell/util/representation.py b/src/ifcopenshell-python/ifcopenshell/util/representation.py index 1255621608..94ec3f8579 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/representation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/representation.py @@ -19,6 +19,7 @@ import numpy as np import numpy.typing as npt import ifcopenshell +import ifcopenshell.util.shape import ifcopenshell.util.placement from typing import Optional, Union, TypedDict, Literal, Generator, Sequence @@ -235,12 +236,12 @@ def guess_type(items: Sequence[ifcopenshell.entity_instance]) -> Union[str, None ] ): return "SurfaceModel" - elif all([True if i.is_a("IfcSolidModel") else False for i in items]): - return "SolidModel" elif all( [True if i.is_a() == "IfcExtrudedAreaSolid" or i.is_a() == "IfcRevolvedAreaSolid" else False for i in items] ): return "SweptSolid" + elif all([True if i.is_a("IfcSolidModel") else False for i in items]): + return "SolidModel" elif all( [ ( @@ -343,9 +344,9 @@ def get_prioritised_contexts(ifc_file: ifcopenshell.file) -> list[ifcopenshell.e you may want to prioritise visualising certain contexts over others, determined by the context type, identifier, target view, and target scale. - The default prioritises subcontexts, then contexts. It then prioritises 3D, - then 2D. It then prioritises bodies, then others. It also prioritises model - views, then plan views, then others. + The default prioritises 3D, then 2D. It then prioritises subcontexts, then + contexts. It then prioritises bodies, then others. It also prioritises + model views, then plan views, then others. :param ifc_file: The model containing contexts :return: A list of IfcGeometricRepresentationContext (or SubContext) from @@ -383,14 +384,6 @@ def get_prioritised_contexts(ifc_file: ifcopenshell.file) -> list[ifcopenshell.e def sort_context(context): priority = [] - if context.ContextType in type_priority: - priority.append(len(type_priority) - type_priority.index(context.ContextType)) - else: - priority.append(0) - return tuple(priority) - - def sort_subcontext(context): - priority = [] if context.ContextType in type_priority: priority.append(len(type_priority) - type_priority.index(context.ContextType)) @@ -402,21 +395,16 @@ def get_prioritised_contexts(ifc_file: ifcopenshell.file) -> list[ifcopenshell.e else: priority.append(0) - if context.TargetView in target_view_priority: + if getattr(context, "TargetView", None) in target_view_priority: priority.append(len(target_view_priority) - target_view_priority.index(context.TargetView)) else: priority.append(0) - priority.append(context.TargetScale or 0) # Big then small + priority.append(getattr(context, "TargetScale", None) or 0) # Big then small return tuple(priority) - # Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly - return sorted(ifc_file.by_type("IfcGeometricRepresentationSubContext"), key=sort_subcontext, reverse=True) + sorted( - ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False), - key=sort_context, - reverse=True, - ) + return sorted(ifc_file.by_type("IfcGeometricRepresentationContext"), key=sort_context, reverse=True) def get_part_of_product( @@ -474,3 +462,44 @@ def get_material_style( for style in item.Styles: if style.is_a(ifc_class): return style + + +def get_reference_line(wall: ifcopenshell.entity_instance, fallback_length: float = 1.0) -> list[npt.NDArray]: + """Fetch the reference axis that goes in the +X direction + + A base line will then be offset from this reference line based on the + material usage. From that base line, the layer thicknesses will offset + again, and be extruded to form the body representation. + + :param wall: ifcopenshell.entity_instance + :param fallback_length: If there is no reference axis, assume it starts at + the object placement (i.e. 0.0, 0.0) and extends for this fallback + length along the +X axis. + :return: A list of two 2D coordinates representing the start and end of the + axis. The axis always goes in the +X direction. + """ + if axis := ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW"): + for item in ifcopenshell.util.representation.resolve_representation(axis).Items: + if item.is_a("IfcPolyline"): + points = [p[0] for p in item.Points] + elif item.is_a("IfcIndexedPolyCurve"): + points = item.Points.CoordList + else: + continue + if points[0][0] < points[1][0]: # An axis always goes in the +X direction + return [np.array(points[0]), np.array(points[1])] + return [np.array(points[1]), np.array(points[0])] + elif extrusions := ifcopenshell.util.shape.get_base_extrusions(wall): + for extrusion in extrusions: + profile = extrusion.SweptArea + curve = getattr(profile, "OuterCurve", None) + if not curve: + continue + elif curve.is_a("IfcPolyline"): + x = [p[0][0] for p in curve.Points] + elif curve.is_a("IfcIndexedPolyCurve"): + x = [p[0] for p in curve.Points.CoordList] + else: + continue + return [np.array((min(x), 0.0)), np.array((max(x), 0.0))] + return [np.array((0.0, 0.0)), np.array((fallback_length, 0.0))] diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index f709af6b5a..059312f89d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -30,13 +30,21 @@ cwd = os.path.dirname(os.path.realpath(__file__)) IFC_SCHEMA = Literal["IFC2X3", "IFC4", "IFC4X3"] -def get_fallback_schema(version: str) -> str: - """fallback to the schema version we do have docs and mapping for, - needed to support IFC versions like 4X3_RC1, 4X1 etc""" +def get_fallback_schema(version: str) -> IFC_SCHEMA: + """Fallback to the schema version we do have docs and mapping for. + + Needed to support IFC versions like 4X3_RC1, 4X1 etc. + + :param version: Typically a string from ``ifcopenshell.file.schema_identifier``, e.g. IFC4X3_ADD2 + """ if version.startswith("IFC4X3"): version = "IFC4X3" elif version.startswith("IFC4"): version = "IFC4" + elif version.startswith("IFC2X3"): + version = "IFC2X3" + else: + assert False, f"Unexpected schema version: {version}." return version @@ -80,12 +88,7 @@ def is_a(declaration: ifcopenshell.ifcopenshell_wrapper.entity, ifc_class: str) declaration = ifcopenshell.util.schema.get_declaration(wall) ifcopenshell.util.schema.is_a(declaration, "IfcRoot") # True """ - ifc_class = ifc_class.upper() - if declaration.name_uc() == ifc_class: - return True - if declaration.supertype(): - return is_a(declaration.supertype(), ifc_class) - return False + return declaration._is(ifc_class) def get_supertypes( @@ -145,7 +148,7 @@ def get_subtypes( def reassign_class( - ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, new_class: str + ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str ) -> ifcopenshell.entity_instance: """ Attempts to change the class (entity name) of `element` to `new_class` by @@ -159,13 +162,23 @@ def reassign_class( (such as IfcRelNests) It's unlikely that this affects real-world usage of this function. + + :raises ValueError: If ``new_class`` does not exist in the provided file schema. """ - schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(ifc_file.schema) + if element.is_a() == new_class: + return element + + if not ifc_file: + ifc_file = element.file + + schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) try: declaration = schema.declaration_by_name(new_class) - except: - raise Exception(f"Class of {element} could not be changed to {new_class} as the class does not exist") + except RuntimeError: + raise ValueError( + f"Class of {element} could not be changed to {new_class} as the class does not exist in schema {ifc_file.schema_identifier}." + ) info = element.get_info() diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 37919b9408..22608af9cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -17,7 +17,7 @@ # along with IfcOpenShell. If not, see . import re -import types +import sys import lark import numpy as np import ifcopenshell.api.pset @@ -37,6 +37,11 @@ import ifcopenshell.util.unit from decimal import Decimal from typing import Optional, Any, Union, Iterable +if sys.version_info >= (3, 10): + from types import EllipsisType +else: + EllipsisType = type(...) + filter_elements_grammar = lark.Lark( """start: filter_group @@ -342,6 +347,8 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) - value = ifcopenshell.util.element.get_groups(value) elif key == "system": value = ifcopenshell.util.system.get_element_systems(value) + elif key == "zone": + value = ifcopenshell.util.system.get_element_zones(value) elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(value, "ObjectPlacement"): if getattr(value, "ObjectPlacement", None): matrix = ifcopenshell.util.placement.get_local_placement(value.ObjectPlacement) @@ -659,7 +666,7 @@ def set_element_value( def process_pset_prop_value( pset: ifcopenshell.entity_instance, prop: str, value: Any - ) -> Union[Any, types.EllipsisType]: + ) -> Union[Any, EllipsisType]: """Try to process value for edit_pset. `edit_pset` is expecting a sequence of values @@ -672,7 +679,7 @@ def set_element_value( current_value = element.get(key, ...) # Check if previous value is a list as a fast way to identify enum properties. - if not isinstance(current_value, (types.EllipsisType, list)): + if not isinstance(current_value, (EllipsisType, list)): return value if isinstance(current_value, list): diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape.py b/src/ifcopenshell-python/ifcopenshell/util/shape.py index 77c28ee1ab..08ce7bf25b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape.py @@ -38,6 +38,12 @@ MatrixType = npt.NDArray[np.float64] # NOTE: See IfcGeomRepresentation.h for ShapeType buffer types. +# NOTE: For functions that return a single scalar ensure to use .item() to +# return the Python float instead of numpy float +# as it's less intrusive (doesn't promote numpy arrays on interactions), +# doesn't fail saving to IFC +# and precise enough anyway (internally Python floats are doubles). + def is_x(value: float, x: float, tolerance: Optional[float] = None) -> bool: """Checks whether a value is equivalent to X given a tolerance @@ -89,7 +95,7 @@ def get_x(geometry: ShapeType) -> float: :return: The X dimension """ verts_flat = get_vertices(geometry).ravel() - return np.max(verts_flat[0::3]) - np.min(verts_flat[0::3]) + return (np.max(verts_flat[0::3]) - np.min(verts_flat[0::3])).item() def get_y(geometry: ShapeType) -> float: @@ -99,7 +105,7 @@ def get_y(geometry: ShapeType) -> float: :return: The Y dimension """ verts_flat = get_vertices(geometry).ravel() - return np.max(verts_flat[1::3]) - np.min(verts_flat[1::3]) + return (np.max(verts_flat[1::3]) - np.min(verts_flat[1::3])).item() def get_z(geometry: ShapeType) -> float: @@ -109,7 +115,7 @@ def get_z(geometry: ShapeType) -> float: :return: The Z dimension """ verts_flat = get_vertices(geometry).ravel() - return np.max(verts_flat[2::3]) - np.min(verts_flat[2::3]) + return (np.max(verts_flat[2::3]) - np.min(verts_flat[2::3])).item() def get_max_xy(geometry: ShapeType) -> float: @@ -175,7 +181,7 @@ def get_element_bbox_centroid(element: ifcopenshell.entity_instance, geometry: S """Calculates the element's bounding box centroid The centroid is in global coordinates. Note that if you have the shape, it - is more efficient to use ``get_shape_bbox_centroid``. + is more efficient to use :func:`get_shape_bbox_centroid`. :param element: The element occurrence :param geometry: Geometry output calculated by IfcOpenShell @@ -192,7 +198,7 @@ def get_shape_bbox_centroid(shape: ShapeElementType, geometry: ShapeType) -> npt """Calculates the shape's bounding box centroid The centroid is in global coordinates. Note that if you do not have the - shape, you can use ``get_element_bbox_centroid``. + shape, you can use :func:`get_element_bbox_centroid`. :param shape: Shape output calculated by IfcOpenShell :param geometry: Geometry output calculated by IfcOpenShell @@ -237,7 +243,7 @@ def get_faces(geometry: ShapeType) -> npt.NDArray[np.int32]: """Get all the faces as a numpy array Faces are always triangulated. If the shape is a BRep and you want to get - the original untriangulated output, refer to ``get_edges``. + the original untriangulated output, refer to :func:`get_edges`. Results are a nested numpy array e.g. [[f1v1, f1v2, f1v3], [f2v1, f2v2, f2v3], ...] @@ -312,7 +318,7 @@ def get_shape_vertices(shape: ShapeElementType, geometry: ShapeType) -> npt.NDAr """Get the shape's vertices as a numpy array Vertices are in global coordinates. If you do not have the shape, you can - use ``get_element_vertices``. + use :func:`get_element_vertices`. Results are a nested numpy array e.g. [[v1x, v1y, v1z], [v2x, v2y, v2z], ...] @@ -330,7 +336,7 @@ def get_element_vertices(element: ifcopenshell.entity_instance, geometry: ShapeT """Get the element's vertices as a numpy array Vertices are in global coordinates. Note that if you have the shape, it is - more efficient to use ``get_shape_vertices``. + more efficient to use :func:`get_shape_vertices`. Results are a nested numpy array e.g. [[v1x, v1y, v1z], [v2x, v2y, v2z], ...] @@ -351,8 +357,8 @@ def get_bottom_elevation(geometry: ShapeType) -> float: :param geometry: Geometry output calculated by IfcOpenShell :return: The Z value """ - z_values = [geometry.verts[i + 2] for i in range(0, len(geometry.verts), 3)] - return min(z_values) + verts_flat = get_vertices(geometry).ravel() + return np.min(verts_flat[2::3]).item() def get_top_elevation(geometry: ShapeType) -> float: @@ -362,13 +368,13 @@ def get_top_elevation(geometry: ShapeType) -> float: :return: The Z value """ verts_flat = get_vertices(geometry).ravel() - return np.max(verts_flat[2::3]) + return np.max(verts_flat[2::3]).item() def get_shape_bottom_elevation(shape: ShapeType, geometry: ShapeType) -> float: """Gets the lowest global Z ordinate of the shape - If you do not have the shape, you can use ``get_element_bottom_elevation`` + If you do not have the shape, you can use :func:`get_element_bottom_elevation` instead. :param shape: Shape output calculated by IfcOpenShell @@ -381,7 +387,7 @@ def get_shape_bottom_elevation(shape: ShapeType, geometry: ShapeType) -> float: def get_shape_top_elevation(shape: ShapeType, geometry: ShapeType) -> float: """Gets the highest global Z ordinate of the shape - If you do not have the shape, you can use ``get_element_top_elevation`` + If you do not have the shape, you can use :func:`get_element_top_elevation` instead. :param shape: Shape output calculated by IfcOpenShell @@ -395,7 +401,7 @@ def get_element_bottom_elevation(element: ifcopenshell.entity_instance, geometry """Gets the lowest global Z ordinate of the element Note that if you have the shape, it is more efficient to use - ``get_shape_bottom_elevation``. + :func:`get_shape_bottom_elevation`. :param element: The element occurrence :param geometry: Geometry output calculated by IfcOpenShell @@ -408,7 +414,7 @@ def get_element_top_elevation(element: ifcopenshell.entity_instance, geometry: S """Gets the highest global Z ordinate of the element Note that if you have the shape, it is more efficient to use - ``get_shape_top_elevation``. + :func:`get_shape_top_elevation`. :param element: The element occurrence :param geometry: Geometry output calculated by IfcOpenShell @@ -447,7 +453,7 @@ def get_area_vf(vertices: npt.NDArray[np.float64], faces: npt.NDArray[np.int32]) # Sum up the areas to get the total area of the mesh mesh_area = np.sum(triangle_areas) - return mesh_area + return mesh_area.item() def get_area(geometry: ShapeType) -> float: @@ -478,7 +484,7 @@ def get_side_area( axis. Note that this calculates the actual area, not the projected 2D area. If - you want the projected area, use ``get_footprint_area``. + you want the projected area, use :func:`get_footprint_area`. :param geometry: Geometry output calculated by IfcOpenShell :param axis: Either X, Y, or Z. Defaults to Y, which is used for standard @@ -543,7 +549,7 @@ def get_footprint_area( axis. Note that this calculates the 2D projected area, not the actual surface - area. If you want the actual area, use ``get_side_area``. + area. If you want the actual area, use :func:`get_side_area`. :param geometry: Geometry output calculated by IfcOpenShell :param axis: Either X, Y, or Z. Defaults to Z. @@ -674,7 +680,7 @@ def get_footprint_perimeter(geometry: ShapeType) -> float: else: all_edges.add(edge) - return sum([np.linalg.norm(vertices[e[0]] - vertices[e[1]]) for e in (all_edges - shared_edges)]) + return np.sum([np.linalg.norm(vertices[e[0]] - vertices[e[1]]) for e in (all_edges - shared_edges)]).item() def get_profiles(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: @@ -715,6 +721,26 @@ def get_extrusions(element: ifcopenshell.entity_instance) -> Union[list[ifcopens return extrusions +def get_base_extrusions(element: ifcopenshell.entity_instance) -> Union[list[ifcopenshell.entity_instance], None]: + """Gets all base extrusions used to define an element's model body geometry + + A base extrusion is assumed to be an extrusion prior to all boolean + results. + + :param element: The element occurrence + :return: A list of extrusion representation items or `None` if element has no representation. + """ + if not (rep := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")): + return + extrusions = [] + for item in ifcopenshell.util.representation.resolve_representation(rep).Items: + while item.is_a("IfcBooleanResult"): + item = item.FirstOperand + if item.is_a("IfcExtrudedAreaSolid"): + extrusions.append(item) + return extrusions + + def get_total_edge_length(geometry: ShapeType) -> float: """Calculates the total length of edges in a given geometry. @@ -723,4 +749,4 @@ def get_total_edge_length(geometry: ShapeType) -> float: """ vertices = get_vertices(geometry) vertices = vertices[get_edges(geometry)] - return np.linalg.norm(vertices[:, 1] - vertices[:, 0], axis=1).sum() + return np.linalg.norm(vertices[:, 1] - vertices[:, 0], axis=1).sum().item() diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 81203c288f..47926c7e0a 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -249,6 +249,23 @@ def np_intersect_line_line( return point_on_line1, point_on_line2 +def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]: + """Intersect a line defined by 2 points to a horizontal line defined by y + + Useful for axis-aligned intersection checks. + + :param p1: First 2D point of the line, order doesn't matter + :param p2: Second 2D point of the line, order doesn't matter + :param y: Intersect at this y value (i.e. defaults to y=0) + """ + x1, y1 = p1 + x2, y2 = p2 + if is_x(y1, y2): # Parallel + return + t = (y - y1) / (y2 - y1) + return x1 + t * (x2 - x1) + + # Note: using ShapeBuilder try not to reuse IFC elements in the process # otherwise you might run into situation where builder.mirror or other operation # is applied twice during one run to the same element @@ -743,9 +760,7 @@ class ShapeBuilder: """ if matrix is None: matrix = np.eye(4, dtype=float) - return self.create_axis2_placement_3d( - position=matrix[:, 3][:3].tolist(), z_axis=matrix[:, 2][:3].tolist(), x_axis=matrix[:, 0][:3].tolist() - ) + return self.create_axis2_placement_3d(position=matrix[:3, 3], z_axis=matrix[:3, 2], x_axis=matrix[:3, 0]) def create_axis2_placement_2d( self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None @@ -1305,6 +1320,22 @@ class ShapeBuilder: ] return self.file.createIfcFacetedBrep(self.file.createIfcClosedShell(faces)) + def triangulated_face_set( + self, points: SequenceOfVectors, faces: Sequence[Sequence[int]] + ) -> ifcopenshell.entity_instance: + """ + Generate an IfcTriangulatedFaceSet + + Note that this is not available in IFC2X3. + + :param points: list of 3d coordinates + :param faces: list of triangles consisted of point indices (points indices starting from 0) + :return: IfcTriangulatedFaceSet + """ + ifc_points = self.file.createIfcCartesianPointList3D(ifc_safe_vector_type(points)) + ifc_faces = [[i + 1 for i in face][:3] for face in faces] + return self.file.createIfcTriangulatedFaceSet(Coordinates=ifc_points, CoordIndex=ifc_faces) + def polygonal_face_set( self, points: SequenceOfVectors, faces: Sequence[Sequence[int]] ) -> ifcopenshell.entity_instance: @@ -1347,7 +1378,7 @@ class ShapeBuilder: # prevent mutating arguments, deepcopy doesn't work start_points = np.array(points) - if offset: + if offset is not None and offset.any(): start_points += offset extrusion_offset = np.multiply(extrusion_vector, magnitude) end_points = start_points + extrusion_offset @@ -1542,7 +1573,7 @@ class ShapeBuilder: circle_points += end_extrusion_offset # circle verts are 0-15, rect verts are 16-19 - points = circle_points + rect_points + points = np.concatenate((circle_points, rect_points)) transition_faces = [ (0, 19, 16), # base (0, 16, 1), diff --git a/src/ifcopenshell-python/ifcopenshell/util/stationing.py b/src/ifcopenshell-python/ifcopenshell/util/stationing.py new file mode 100644 index 0000000000..1e0517f177 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/util/stationing.py @@ -0,0 +1,50 @@ +# 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 math + + +def station_as_string(station: float, plus_seperator=3, accuracy=3): + """ + Returns a stringized version of a station. Example 100.0 is 1+00.00 as a stationing string + @param station: the station to be stringized + @param plus_seperator: location of the '+' symbol relative to the decimal place (typically 2 for US units and 3 for SI units) + @param accuracy: number of digits following the decimal place + """ + value = math.fabs(station) + + shifter = math.pow(10.0, plus_seperator) + v1 = math.floor(value / shifter) + v2 = value - v1 * shifter + + # Check to make sure that v2 is not basically the same as shifter + # If station = 69500.00000, we sometimes get 694+100.00 instead of 695+00.00 + if math.isclose(v2 - shifter, 5.0 * math.pow(10.0, -(accuracy + 1))): + v2 = 0.0 + v1 += 1 + + v1 = -1 * v1 if station < 0 else v1 + + station_string = "{:d}+{:0{}.{}f}".format(v1, v2, plus_seperator + accuracy + 1, accuracy) + + # special case when v1 is 0 and station is negative, the string above doesn't get the leading + # negative sign. this snippet fixes that + if v1 == 0 and station < 0: + station_string = "-" + station_string + + return station_string diff --git a/src/ifcopenshell-python/ifcopenshell/util/system.py b/src/ifcopenshell-python/ifcopenshell/util/system.py index 3a5be9693f..833d85a941 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/system.py +++ b/src/ifcopenshell-python/ifcopenshell/util/system.py @@ -39,6 +39,12 @@ group_types: dict[str, tuple[str, ...]] = { "IfcSystem": ("IfcProduct",), "IfcGroup": ("IfcObjectDefinition",), } +# Subclasses. +group_types["IfcDistributionCircuit"] = group_types["IfcDistributionSystem"] +# Replaced by IfcDistributionCircuit in IFC4, though it wasn't limited to IfcDistributionElements: +# "Usage of IfcElectricalCircuit is as for the supertype IfcSystem". +group_types["IfcElectricalCircuit"] = group_types["IfcSystem"] + FLOW_DIRECTION = Literal["SINK", "SOURCE", "SOURCEANDSINK", "NOTEDEFINED"] @@ -60,13 +66,24 @@ def get_system_elements(system: ifcopenshell.entity_instance) -> list[ifcopenshe def get_element_systems(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: results = [] for rel in element.HasAssignments: - if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.is_a() in ( - "IfcSystem", - "IfcDistributionSystem", - "IfcBuildingSystem", - "IfcZone", - ): - results.append(rel.RelatingGroup) + if not rel.is_a("IfcRelAssignsToGroup"): + continue + group = rel.RelatingGroup + if not group.is_a("IfcSystem") or group.is_a() in ("IfcStructuralAnalysisModel", "IfcZone"): + continue + results.append(group) + return results + + +def get_element_zones(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: + results = [] + for rel in element.HasAssignments: + if rel.is_a("IfcRelAssignsToGroup"): + continue + group = rel.RelatingGroup + if not group.is_a("IfcZone"): + continue + results.append(group) return results diff --git a/src/ifcopenshell-python/ifcopenshell/util/type.py b/src/ifcopenshell-python/ifcopenshell/util/type.py index 8ff0fe1a3c..ab58814267 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/type.py +++ b/src/ifcopenshell-python/ifcopenshell/util/type.py @@ -43,7 +43,18 @@ for schema in mapped_schemas: type_to_entity_map[schema].setdefault(element_type, []).append(element) if schema == "IFC2X3": + # Prioritize IfcBuildingElementProxyType if it's available as it seems to be the most generic type. + # Otherwise classes that don't have a special type in IFC2X3 (e.g. IfcBuildingElementPart, IfcRoof) + # have IfcBeamType as their first matching type, which can be confusing. + for occurrence_type, element_types in entity_to_type_map[schema].items(): + if "IfcBuildingElementProxyType" in element_types: + element_types.sort(key=lambda x: x == "IfcBuildingElementProxyType", reverse=True) + # There is no official mapping for IFC2X3 but this method gets us something that looks correct + # + # NOTE: currently `type_to_entity_map` in IFC2X3 doesn't completely match `entity_to_type_map`, + # e.g. `get_applicabl_types(IfcRoof)` returns `[IfcBuildingElementProxyType, IfcBeamType, ...]` + # but `get_applicable_entities(IfcBuildingElementProxyType)` returns `[IfcBuildingElementProxy`]. for element_type, elements in type_to_entity_map[schema].items(): # need to take both Type (4 symbols) and Style (5 symbols) into account guessed_element = element_type[:-5] if element_type.endswith("Style") else element_type[:-4] diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index b19b8154c9..78a0117fa5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -20,10 +20,10 @@ from fractions import Fraction from math import pi from typing import Any from typing import Dict -from typing import Iterable from typing import Literal from typing import Optional from typing import Union +from typing import Generator import ifcopenshell import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper @@ -456,7 +456,7 @@ def get_project_unit( def get_property_unit( - prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file, use_cache: bool = False + prop: ifcopenshell.entity_instance, ifc_file: Union[ifcopenshell.file, None], use_cache: bool = False ) -> Union[ifcopenshell.entity_instance, None]: """Gets the unit definition of a property or quantity @@ -499,11 +499,13 @@ def get_property_unit( measure_class = value.is_a() if measure_class and (unit_type := get_measure_unit_type(measure_class)): + if not ifc_file: + ifc_file = prop.file return get_project_unit(ifc_file, unit_type, use_cache=use_cache) def get_property_table_unit( - prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file, use_cache: bool = False + prop: ifcopenshell.entity_instance, ifc_file: Union[ifcopenshell.file, None], use_cache: bool = False ) -> Dict[str, Union[ifcopenshell.entity_instance, None]]: """ Gets the unit definition of a property table @@ -523,6 +525,8 @@ def get_property_table_unit( If a unit-entity is missing, the value associated to the key is `null`. """ + if not ifc_file: + ifc_file = prop.file defining_unit = None if unit := prop.DefiningUnit: defining_unit = unit @@ -803,15 +807,25 @@ def is_attr_type( return None -def iter_element_and_attributes_per_type( - ifc_file: ifcopenshell.file, attr_type_name: str -) -> Iterable[tuple[ifcopenshell.entity_instance, ifcopenshell_wrapper.attribute, Any]]: +FloatOrSequenceOfFloats = Union[float, tuple["FloatOrSequenceOfFloats", ...]] + + +def iter_element_and_attributes_per_type(ifc_file: ifcopenshell.file, attr_type_name: str) -> Generator[ + tuple[ + ifcopenshell.entity_instance, + ifcopenshell_wrapper.attribute, + Union[FloatOrSequenceOfFloats, ifcopenshell.entity_instance], + ], + None, + None, +]: schema: ifcopenshell_wrapper.schema_definition = ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) for element in ifc_file: entity = schema.declaration_by_name(element.is_a()) attrs = entity.all_attributes() - for attr, val, is_derived in zip(attrs, list(element), entity.derived()): + attrs_derived: tuple[bool, ...] = entity.derived() + for attr, val, is_derived in zip(attrs, list(element), attrs_derived): if is_derived: continue @@ -826,6 +840,17 @@ def iter_element_and_attributes_per_type( if isinstance(val, ifcopenshell.entity_instance) and not val.is_a(attr_type_name): continue + elif isinstance(val, tuple): + if not val: + continue + val_ = val[0] + # If it's a tuple of entities, just yield the entities we need to edit. + if isinstance(val_, ifcopenshell.entity_instance): + for val_ in val: + if not val_.is_a(attr_type_name): + continue + yield element, attr, val_ + continue yield element, attr, val @@ -835,6 +860,7 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = " import ifcopenshell.util.element import ifcopenshell.util.geolocation import ifcopenshell.api.georeference + import ifcopenshell.api.unit prefix = get_prefix(target_units) si_unit = get_unit_name(target_units) @@ -855,16 +881,17 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = " new_length = ifcopenshell.api.unit.add_conversion_based_unit(file_patched, name=target_units) # support tuple of tuples, as in IfcCartesianPointList3D.CoordList - def convert_value(value): + def convert_value(value: FloatOrSequenceOfFloats) -> FloatOrSequenceOfFloats: if not isinstance(value, tuple): return convert_unit(value, old_length, new_length) return tuple(convert_value(v) for v in value) # Traverse all elements and their nested attributes in the file and convert them for element, attr, val in iter_element_and_attributes_per_type(file_patched, "IfcLengthMeasure"): + # NOTE: There is no risk of editing same entities twice as they're all recreated + # after file is reloaded as `file_patched`. if isinstance(val, ifcopenshell.entity_instance): - new_value = convert_value(val.wrappedValue) - getattr(element, attr.name()).wrappedValue = new_value + val.wrappedValue = convert_value(val.wrappedValue) else: new_value = convert_value(val) setattr(element, attr.name(), new_value) diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 4632e090c9..8f2d4d22ef 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -25,11 +25,21 @@ Can be used to run validation on IFC file from the command line: python -m ifcopenshell.validate /path/to/model.ifc --rules -Available flags: +``` +$ python -m ifcopenshell.validate -h +usage: validate.py [-h] [--rules] [--json] [--fields] [--spf] files [files ...] + +positional arguments: + files The IFC file to validate. + +options: + -h, --help show this help message and exit + --rules Run express rules. + --json Output in JSON format. + --fields Output more detailed information about failed entities (only with --json). + --spf Output entities in SPF format (only with --json). +``` -- ``--rules``: Also check express rules. -- ``--json``: Produce JSON output. -- ``--fields``: Output more detailed information about failed entities (available only with ``--json``). """ import os @@ -37,6 +47,7 @@ import sys import json import functools import types +import argparse from collections import namedtuple from typing import Union, Iterator, Any, Optional @@ -647,13 +658,24 @@ if __name__ == "__main__": sys.excepthook = handle_exception - filenames = [x for x in sys.argv[1:] if not x.startswith("--")] - flags = set(x for x in sys.argv[1:] if x.startswith("--")) + parser = argparse.ArgumentParser() + parser.add_argument("files", nargs="+", help="The IFC file to validate.") + parser.add_argument("--rules", action="store_true", help="Run express rules.") + parser.add_argument("--json", action="store_true", help="Output in JSON format.") + parser.add_argument( + "--fields", + action="store_true", + help="Output more detailed information about failed entities (only with --json).", + ) + parser.add_argument("--spf", action="store_true", help="Output entities in SPF format (only with --json).") + args = parser.parse_args() + + filenames: list[str] = args.files some_file_is_invalid = False for fn in filenames: handler = None - if "--json" in flags: + if args.json: logger = json_logger() else: logger = logging.getLogger("validate") @@ -662,14 +684,14 @@ if __name__ == "__main__": logger.setLevel(logging.DEBUG) print("Validating", fn, file=sys.stderr) - validate(fn, logger, "--rules" in flags) + validate(fn, logger, args.rules) - if "--json" in flags: + if args.json: sys.stdout.reconfigure(encoding="utf-8") conv = str - if "--spf" in flags: + if args.spf: conv = lambda x: x.to_string() if isinstance(x, ifcopenshell.entity_instance) else str(x) - if "--fields" in flags: + if args.fields: def conv(x): if isinstance(x, ifcopenshell.entity_instance): diff --git a/src/ifcopenshell-python/pyproject.toml b/src/ifcopenshell-python/pyproject.toml index ce752e4720..02fcf321cc 100644 --- a/src/ifcopenshell-python/pyproject.toml +++ b/src/ifcopenshell-python/pyproject.toml @@ -25,9 +25,6 @@ dependencies = [ ] [project.optional-dependencies] -# mathutils is broken on Python < 3.10. -# See: https://gitlab.com/ideasman42/blender-mathutils/-/merge_requests/3 -geometry = ["mathutils"] dev = ["pytest"] [project.urls] diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_curve.py b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_curve.py new file mode 100644 index 0000000000..8098f7b51b --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_curve.py @@ -0,0 +1,72 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_add_segment_to_curve(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + circular_arc = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((4084.115884, 3889.462938)), + file.createIfcDirection((0.224530986099614, 0.974466949814685)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(-1848.115835), + ParentCurve=file.createIfcCircle( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), file.createIfcDirection((1.0, 0.0)) + ), + Radius=1250.0, + ), + ) + + line = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((5469.395067, 4847.56631)), + file.createIfcDirection((0.991014275066766, -0.133756146078947)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(1564.635765), + ParentCurve=file.createIfcLine( + Pnt=file.createIfcCartesianPoint((0.0, 0.0)), + Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0), + ), + ) + + composite_curve = file.createIfcCompositeCurve(SelfIntersect=False) + + ifcopenshell.api.alignment.add_segment_to_curve(file, circular_arc, composite_curve) + assert circular_arc.UsingCurves[0] == composite_curve + assert composite_curve.Segments[-1] == circular_arc + + ifcopenshell.api.alignment.add_segment_to_curve(file, line, composite_curve) + assert line.UsingCurves[0] == composite_curve + assert composite_curve.Segments[-1] == line diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py new file mode 100644 index 0000000000..96ad157f60 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py @@ -0,0 +1,75 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_add_segment_to_layout(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + horizontal_alignment = file.create_entity( + type="IfcAlignmentHorizontal", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + ) + + design_parameters = file.create_entity( + type="IfcAlignmentHorizontalSegment", + StartTag=None, + EndTag=None, + StartPoint=file.createIfcCartesianPoint(Coordinates=((0.0, 0.0))), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + GravityCenterLineHeight=None, + PredefinedType="LINE", + ) + alignment_segment = file.create_entity( + type="IfcAlignmentSegment", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=None, + Description=None, + ObjectType=None, + ObjectPlacement=None, + Representation=None, + DesignParameters=design_parameters, + ) + + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal_alignment, alignment_segment) + + assert len(horizontal_alignment.IsNestedBy) == 1 + assert len(horizontal_alignment.IsNestedBy[0].RelatedObjects) == 1 + assert horizontal_alignment.IsNestedBy[0].RelatedObjects[0] == alignment_segment diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py new file mode 100644 index 0000000000..e559653618 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py @@ -0,0 +1,56 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_add_stationing_to_alignment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + + ifcopenshell.api.alignment.add_stationing_to_alignment(file, alignment, 2000.0) + + for rel in alignment.IsNestedBy: + for referent in rel.RelatedObjects: + if referent.is_a("IfcReferent"): + assert referent.PredefinedType == "STATION" + assert referent.Name == "2+000.000" + assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing") + assert ( + ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") + == 2000.0 + ) diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_by_pi_method.py new file mode 100644 index 0000000000..53a8f7c0df --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_by_pi_method.py @@ -0,0 +1,74 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_add_vertical_by_pi_method(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + # single horizontal alignment + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method(file, "TestAlignment", coordinates, radii) + + assert len(alignment.IsDecomposedBy) == 0 # no child alignments + assert len(alignment.IsNestedBy) == 1 # nesting IfcAlignemtHorizontal + assert len(alignment.IsNestedBy[0].RelatedObjects) == 1 # nesting one IfcAlignmentHorizontal + assert alignment.IsNestedBy[0].RelatedObjects[0].is_a("IfcAlignmentHorizontal") + assert ( + len(alignment.IsNestedBy[0].RelatedObjects[0].IsNestedBy) == 1 + ) # nesting of segments beneath IfcAlignmentHorizontal + assert len(alignment.IsNestedBy[0].RelatedObjects[0].IsNestedBy[0].RelatedObjects) == 8 # segments + + # add first vertical + ifcopenshell.api.alignment.add_vertical_alignment_by_pi_method(file, alignment, vpoints, lengths) + assert len(alignment.IsDecomposedBy) == 0 # no child alignments + assert len(alignment.IsNestedBy) == 1 # 1 nesting relationsip for the alignments + assert len(alignment.IsNestedBy[0].RelatedObjects) == 2 # nesting IfcAlignmentHorizontal and IfcAlignmentVertical + assert alignment.IsNestedBy[0].RelatedObjects[0].is_a("IfcAlignmentHorizontal") + assert alignment.IsNestedBy[0].RelatedObjects[1].is_a("IfcAlignmentVertical") + + # add second vertical + ifcopenshell.api.alignment.add_vertical_alignment_by_pi_method(file, alignment, vpoints, lengths) + assert len(alignment.IsDecomposedBy) == 1 # 1 IfcRelAggreates relationship for the child algiments + assert ( + len(alignment.IsDecomposedBy[0].RelatedObjects) == 2 + ) # two child alignments, one for the first vertical and one for the vertical just added + for child_alignment in alignment.IsDecomposedBy[0].RelatedObjects: + assert child_alignment.is_a("IfcAlignment") + assert len(child_alignment.IsNestedBy) == 1 # one nesting relationship for the IfcAlignmentVertical + assert len(child_alignment.IsNestedBy[0].RelatedObjects) == 1 # The IfcAlignmentVertical + assert child_alignment.IsNestedBy[0].RelatedObjects[0].is_a("IfcAlignmentVertical") + assert len(alignment.IsNestedBy) == 1 # 1 nesting relationsip for the alignments + assert len(alignment.IsNestedBy[0].RelatedObjects) == 1 # nesting one IfcAlignmentHorizontal + assert alignment.IsNestedBy[0].RelatedObjects[0].is_a("IfcAlignmentHorizontal") diff --git a/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py b/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py new file mode 100644 index 0000000000..fa42765c56 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py @@ -0,0 +1,55 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_add_stationing_to_alignment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + + # test alignment without stationing referent + assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 500.0) == pytest.approx(500.0) + + # add stationing referent + ifcopenshell.api.alignment.add_stationing_to_alignment(file, alignment, 10000.0) + + # Station 138+83.96 + assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 13883.96) == pytest.approx(3883.96) + + # Station 175+25.36 + assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36) diff --git a/src/ifcopenshell-python/test/api/alignment/test_get_basis_curve.py b/src/ifcopenshell-python/test/api/alignment/test_get_basis_curve.py new file mode 100644 index 0000000000..ee1a65af19 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_get_basis_curve.py @@ -0,0 +1,70 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest + +# import test.bootstrap +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +# class TestGetBasisCurve(test.bootstrap.IFC4X3): +def test_horizontal(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method(file, "TestAlignment", coordinates, radii) + ifcopenshell.api.alignment.create_geometric_representation(file, alignment) + basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + assert basis_curve.is_a("IfcCompositeCurve") + + +def test_horizontal_and_vertical(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + ifcopenshell.api.alignment.create_geometric_representation(file, alignment) + basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + assert basis_curve.is_a("IfcCompositeCurve") diff --git a/src/ifcopenshell-python/test/api/alignment/test_get_curve.py b/src/ifcopenshell-python/test/api/alignment/test_get_curve.py new file mode 100644 index 0000000000..a00104d1b6 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_get_curve.py @@ -0,0 +1,70 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest + +# import test.bootstrap +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +# class TestGetCurve(test.bootstrap.IFC4X3): +def test_horizontal(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method(file, "TestAlignment", coordinates, radii) + ifcopenshell.api.alignment.create_geometric_representation(file, alignment) + curve = ifcopenshell.api.alignment.get_curve(alignment) + assert curve.is_a("IfcCompositeCurve") + + +def test_horizontal_and_vertical(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + ifcopenshell.api.alignment.create_geometric_representation(file, alignment) + curve = ifcopenshell.api.alignment.get_curve(alignment) + assert curve.is_a("IfcGradientCurve") diff --git a/src/ifcopenshell-python/test/api/alignment/test_has_zero_length_segment.py b/src/ifcopenshell-python/test/api/alignment/test_has_zero_length_segment.py new file mode 100644 index 0000000000..b70002baa5 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_has_zero_length_segment.py @@ -0,0 +1,123 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.alignment.has_zero_length_segment +import ifcopenshell.api.alignment.remove_zero_length_segment +import ifcopenshell.api.context +import ifcopenshell.guid +import ifcopenshell.api.nest + + +def _test_business_definition(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + horizontal = file.createIfcAlignmentHorizontal("Horizontal Alignment") + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters) + ifcopenshell.api.nest.assign_object( + file, + related_objects=[ + segment, + ], + relating_object=horizontal, + ) + + assert False == ifcopenshell.api.alignment.has_zero_length_segment(horizontal) + + ifcopenshell.api.alignment.add_zero_length_segment(file, horizontal) + assert len(horizontal.IsNestedBy[0].RelatedObjects) == 2 + + assert True == ifcopenshell.api.alignment.has_zero_length_segment(horizontal) + + zero_length_segment = ifcopenshell.api.alignment.remove_zero_length_segment(file, horizontal) + assert len(horizontal.IsNestedBy[0].RelatedObjects) == 1 + assert False == ifcopenshell.api.alignment.has_zero_length_segment(horizontal) + + ifcopenshell.api.alignment.add_segment_to_layout(file, horizontal, zero_length_segment) + assert len(horizontal.IsNestedBy[0].RelatedObjects) == 2 + assert True == ifcopenshell.api.alignment.has_zero_length_segment(horizontal) + + +def _test_geometric_definition(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + circular_arc = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((4084.115884, 3889.462938)), + file.createIfcDirection((0.224530986099614, 0.974466949814685)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(-1848.115835), + ParentCurve=file.createIfcCircle( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), file.createIfcDirection((1.0, 0.0)) + ), + Radius=1250.0, + ), + ) + + composite_curve = file.createIfcCompositeCurve(Segments=(circular_arc,), SelfIntersect=False) + + assert False == ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) + + ifcopenshell.api.alignment.add_zero_length_segment(file, composite_curve) + + assert True == ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) + + assert len(composite_curve.Segments) == 2 + + zero_length_segment = ifcopenshell.api.alignment.remove_zero_length_segment(file, composite_curve) + assert len(composite_curve.Segments) == 1 + assert False == ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) + + ifcopenshell.api.alignment.add_segment_to_curve(file, zero_length_segment, composite_curve) + assert len(composite_curve.Segments) == 2 + assert True == ifcopenshell.api.alignment.has_zero_length_segment(composite_curve) + + segment = composite_curve.Segments[-1] + assert segment.Placement.Location.Coordinates == (5469.394535876198, 4847.567078630914) + assert segment.Placement.RefDirection.DirectionRatios == (0.9910142986043448, -0.13375597168627318) + + +def test_has_zero_length_segment(): + _test_business_definition() + _test_geometric_definition() diff --git a/src/ifcopenshell-python/test/api/alignment/test_map_alignment_cant_segment.py b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_cant_segment.py new file mode 100644 index 0000000000..755fe3e13c --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_cant_segment.py @@ -0,0 +1,26 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_map_alignment_cant_segment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + # create tests as the mapping functions are implemented diff --git a/src/ifcopenshell-python/test/api/alignment/test_map_alignment_horizontal_segment.py b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_horizontal_segment.py new file mode 100644 index 0000000000..0d8e55293d --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_horizontal_segment.py @@ -0,0 +1,2101 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 . + +# These are test cases generated from https://github.com/bSI-RailwayRoom/IFC-Rail-Unit-Test-Reference-Code/tree/master/alignment_testset/IFC-WithGeneratedGeometry +# for horizontal alignment. + +import pytest +import ifcopenshell.api.alignment + + +def _BlossCurve_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(120.989673502444) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-112.624788044361) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + + +def _BlossCurve_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(-120.989673502444) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(112.624788044361) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + + +def _BlossCurve_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(110.668191970032) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + + +def _BlossCurve_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(-110.668191970032) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(100.0) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + + +def _BlossCurve_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(-120.989673502444) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(112.624788044361) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(1000.0) + + +def _BlossCurve_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(120.989673502444) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-112.624788044361) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-1000.0) + + +def _BlossCurve_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(-110.668191970032) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(100.0) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + + +def _BlossCurve_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="BLOSSCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcThirdOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CubicTerm == pytest.approx(110.668191970032) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + + +def _CircularArc_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(1000.0) + + +def _CircularArc_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _CircularArc_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(300.0) + + +def _Clothoid_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-142.857142857143) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(-207.019667802706) + + +def _Clothoid_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-142.857142857143) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(207.019667802706) + + +def _Clothoid_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(-173.205080756888) + + +def _Clothoid_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(173.205080756888) + + +def _Clothoid_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(42.8571428571429) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(207.019667802706) + + +def _Clothoid_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(42.8571428571429) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(-207.019667802706) + + +def _Clothoid_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(173.205080756888) + + +def _Clothoid_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CLOTHOID", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcClothoid") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.ClothoidConstant == pytest.approx(-173.205080756888) + + +def _CosineCurve_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(857.142857142857) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(461.538461538462) + + +def _CosineCurve_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(-857.142857142857) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-461.538461538462) + + +def _CosineCurve_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(600.0) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(600.0) + + +def _CosineCurve_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(-600.0) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-600.0) + + +def _CosineCurve_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(-857.142857142857) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(461.538461538462) + + +def _CosineCurve_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(857.142857142857) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-461.538461538462) + + +def _CosineCurve_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(-600.0) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(600.0) + + +def _CosineCurve_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="COSINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcCosineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CosineTerm == pytest.approx(600.0) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-600.0) + + +def _Cubic_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-142.857142857143) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, -3.88888888888889e-06)) + + +def _Cubic_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-142.857142857143) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, 3.88888888888889e-06)) + + +def _Cubic_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, -5.55555555555556e-06)) + + +def _Cubic_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(-100.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, 5.55555555555556e-06)) + + +def _Cubic_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(42.8571428571429) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, 3.88888888888889e-06)) + + +def _Cubic_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(42.8571428571429) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, -3.88888888888889e-06)) + + +def _Cubic_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, 5.55555555555556e-06)) + + +def _Cubic_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="CUBIC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((0.0, 0.0, 0.0, -5.55555555555556e-06)) + + +def _HelmertCurve_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.7998035122387, 3.91603145329256)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.9892460407218963, 0.146260968532457) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-0.009321141429516372, 0.46831933573745577) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9992574637140321, -0.03852948496670688) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-103.509833901353) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(176.470588235294) + + +def _HelmertCurve_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.7998035122387, -3.91603145329256)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.9892460407218963, -0.146260968532457) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-0.009321141429516372, -0.46831933573745577) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9992574637140321, 0.03852948496670688) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(103.509833901353) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-176.470588235294) + + +def _HelmertCurve_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.8122545525202, 3.81263503030693)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.9904138664989948, 0.1381317235341378) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-0.010305467756443198, 0.6738837916692928) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9984794480380026, -0.05512523782919828) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-86.6025403784439) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(150.0) + + +def _HelmertCurve_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.8122545525202, -3.81263503030693)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.9904138664989948, -0.1381317235341378) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-0.010305467756443198, -0.6738837916692928) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9984794480380026, 0.05512523782919828) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(86.6025403784439) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-150.0) + + +def _HelmertCurve_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(1000.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.9681012468824, 1.49252747074135)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.997594495159641, 0.0693197174487962) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (0.010408767953926904, -0.4828832446956578) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9992463304688143, 0.03881714884698913) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(103.509833901353) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-750.0) + + +def _HelmertCurve_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-1000.0) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.9681012468824, -1.49252747074135)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.997594495159641, -0.0693197174487962) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (0.010408767953926904, 0.4828832446956578) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9992463304688143, -0.03881714884698913) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(128.92319893893) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-103.509833901353) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(750.0) + + +def _HelmertCurve_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.9972443634885, 0.347204361427475)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.999614222337484, 0.027769614722351524) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (0.011625841243773832, -0.6968669147609581) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9984543318840984, 0.05557829739996359) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(86.6025403784439) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + + +def _HelmertCurve_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(-114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(None) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + mapped_segment = mapped_segments[1] + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((49.9972443634885, -0.347204361427475)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.999614222337484, -0.027769614722351524) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(50.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(50.0) + assert mapped_segment.ParentCurve.is_a("IfcSecondOrderPolynomialSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (0.011625841243773832, 0.6968669147609581) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx( + (0.9984543318840984, -0.05557829739996359) + ) + assert mapped_segment.ParentCurve.QuadraticTerm == pytest.approx(114.471424255333) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-86.6025403784439) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + + +def _Line_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _Line_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _SineCurve_100_0_300_1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(2692.79370307697) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-207.019667802706) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + + +def _SineCurve_100_0__300__1000_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=-1000.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(-2692.79370307697) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(207.019667802706) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + + +def _SineCurve_100_0_300_inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(1884.95559215388) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-173.205080756888) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(300.0) + + +def _SineCurve_100_0__300__inf_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-300.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(-1884.95559215388) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(173.205080756888) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-300.0) + + +def _SineCurve_100_0_1000_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=1000.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(-2692.79370307697) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(207.019667802706) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(1000.0) + + +def _SineCurve_100_0__1000__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=-1000.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(2692.79370307697) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-207.019667802706) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(-1000.0) + + +def _SineCurve_100_0_inf_300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=300.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(-1884.95559215388) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(173.205080756888) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + + +def _SineCurve_100_0__inf__300_1_Meter(file): + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=-300.0, + SegmentLength=100.0, + PredefinedType="SINECURVE", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcSineSpiral") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.SineTerm == pytest.approx(1884.95559215388) + assert mapped_segment.ParentCurve.LinearTerm == pytest.approx(-173.205080756888) + assert mapped_segment.ParentCurve.ConstantTerm == pytest.approx(None) + + +def test_map_alignment_horizontal_segment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + _BlossCurve_100_0_300_1000_1_Meter(file) + _BlossCurve_100_0__300__1000_1_Meter(file) + _BlossCurve_100_0_300_inf_1_Meter(file) + _BlossCurve_100_0__300__inf_1_Meter(file) + _BlossCurve_100_0_1000_300_1_Meter(file) + _BlossCurve_100_0__1000__300_1_Meter(file) + _BlossCurve_100_0_inf_300_1_Meter(file) + _BlossCurve_100_0__inf__300_1_Meter(file) + _CircularArc_100_0_300_1000_1_Meter(file) + _CircularArc_100_0__300__1000_1_Meter(file) + _CircularArc_100_0_300_inf_1_Meter(file) + _CircularArc_100_0__300__inf_1_Meter(file) + _CircularArc_100_0_1000_300_1_Meter(file) + _CircularArc_100_0__1000__300_1_Meter(file) + _CircularArc_100_0_inf_300_1_Meter(file) + _CircularArc_100_0__inf__300_1_Meter(file) + _Clothoid_100_0_300_1000_1_Meter(file) + _Clothoid_100_0__300__1000_1_Meter(file) + _Clothoid_100_0_300_inf_1_Meter(file) + _Clothoid_100_0__300__inf_1_Meter(file) + _Clothoid_100_0_1000_300_1_Meter(file) + _Clothoid_100_0__1000__300_1_Meter(file) + _Clothoid_100_0_inf_300_1_Meter(file) + _Clothoid_100_0__inf__300_1_Meter(file) + _CosineCurve_100_0_300_1000_1_Meter(file) + _CosineCurve_100_0__300__1000_1_Meter(file) + _CosineCurve_100_0_300_inf_1_Meter(file) + _CosineCurve_100_0__300__inf_1_Meter(file) + _CosineCurve_100_0_1000_300_1_Meter(file) + _CosineCurve_100_0__1000__300_1_Meter(file) + _CosineCurve_100_0_inf_300_1_Meter(file) + _CosineCurve_100_0__inf__300_1_Meter(file) + _Cubic_100_0_300_1000_1_Meter(file) + _Cubic_100_0__300__1000_1_Meter(file) + _Cubic_100_0_300_inf_1_Meter(file) + _Cubic_100_0__300__inf_1_Meter(file) + _Cubic_100_0_1000_300_1_Meter(file) + _Cubic_100_0__1000__300_1_Meter(file) + _Cubic_100_0_inf_300_1_Meter(file) + _Cubic_100_0__inf__300_1_Meter(file) + _HelmertCurve_100_0_300_1000_1_Meter(file) + _HelmertCurve_100_0__300__1000_1_Meter(file) + _HelmertCurve_100_0_300_inf_1_Meter(file) + _HelmertCurve_100_0__300__inf_1_Meter(file) + _HelmertCurve_100_0_1000_300_1_Meter(file) + _HelmertCurve_100_0__1000__300_1_Meter(file) + _HelmertCurve_100_0_inf_300_1_Meter(file) + _HelmertCurve_100_0__inf__300_1_Meter(file) + _Line_100_0_300_1000_1_Meter(file) + _Line_100_0__300__1000_1_Meter(file) + _Line_100_0_300_inf_1_Meter(file) + _Line_100_0__300__inf_1_Meter(file) + _Line_100_0_1000_300_1_Meter(file) + _Line_100_0__1000__300_1_Meter(file) + _Line_100_0_inf_300_1_Meter(file) + _Line_100_0__inf__300_1_Meter(file) + _SineCurve_100_0_300_1000_1_Meter(file) + _SineCurve_100_0__300__1000_1_Meter(file) + _SineCurve_100_0_300_inf_1_Meter(file) + _SineCurve_100_0__300__inf_1_Meter(file) + _SineCurve_100_0_1000_300_1_Meter(file) + _SineCurve_100_0__1000__300_1_Meter(file) + _SineCurve_100_0_inf_300_1_Meter(file) + _SineCurve_100_0__inf__300_1_Meter(file) + + # VIENESSE BEND NOT IMPLEMENTED diff --git a/src/ifcopenshell-python/test/api/alignment/test_map_alignment_segments.py b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_segments.py new file mode 100644 index 0000000000..1c9d04bc24 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_segments.py @@ -0,0 +1,102 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_map_alignment_horizontal_segment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + + horizontal_alignment = alignment.IsNestedBy[0].RelatedObjects[0] + assert horizontal_alignment.is_a("IfcAlignmentHorizontal") + + composite_curve = file.create_entity( + type="IfcCompositeCurve", + Segments=[], + SelfIntersect=False, + ) + + ifcopenshell.api.alignment.map_alignment_segments(file, horizontal_alignment, composite_curve) + assert len(composite_curve.Segments) == 8 + assert composite_curve.Segments[0].ParentCurve.is_a("IfcLine") + assert composite_curve.Segments[0].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[1].ParentCurve.is_a("IfcCircle") + assert composite_curve.Segments[1].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[2].ParentCurve.is_a("IfcLine") + assert composite_curve.Segments[2].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[3].ParentCurve.is_a("IfcCircle") + assert composite_curve.Segments[3].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[4].ParentCurve.is_a("IfcLine") + assert composite_curve.Segments[4].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[5].ParentCurve.is_a("IfcCircle") + assert composite_curve.Segments[5].Transition == "CONTSAMEGRADIENT" + assert composite_curve.Segments[6].ParentCurve.is_a("IfcLine") + assert composite_curve.Segments[6].Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert composite_curve.Segments[7].ParentCurve.is_a("IfcLine") + assert composite_curve.Segments[7].Transition == "DISCONTINUOUS" + + vertical_alignment = alignment.IsNestedBy[0].RelatedObjects[1] + assert vertical_alignment.is_a("IfcAlignmentVertical") + + gradient_curve = file.create_entity( + type="IfcGradientCurve", Segments=[], SelfIntersect=False, BaseCurve=composite_curve, EndPoint=None + ) + + ifcopenshell.api.alignment.map_alignment_segments(file, vertical_alignment, gradient_curve) + assert len(gradient_curve.Segments) == 10 + + assert gradient_curve.Segments[0].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[0].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[1].ParentCurve.is_a("IfcPolynomialCurve") + assert gradient_curve.Segments[1].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[2].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[2].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[3].ParentCurve.is_a("IfcPolynomialCurve") + assert gradient_curve.Segments[3].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[4].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[4].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[5].ParentCurve.is_a("IfcPolynomialCurve") + assert gradient_curve.Segments[5].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[6].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[6].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[7].ParentCurve.is_a("IfcPolynomialCurve") + assert gradient_curve.Segments[7].Transition == "CONTSAMEGRADIENT" + assert gradient_curve.Segments[8].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[8].Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert gradient_curve.Segments[9].ParentCurve.is_a("IfcLine") + assert gradient_curve.Segments[9].Transition == "DISCONTINUOUS" diff --git a/src/ifcopenshell-python/test/api/alignment/test_map_alignment_vertical_segment.py b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_vertical_segment.py new file mode 100644 index 0000000000..867183d369 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_map_alignment_vertical_segment.py @@ -0,0 +1,795 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 . + +# These are test cases generated from https://github.com/bSI-RailwayRoom/IFC-Rail-Unit-Test-Reference-Code/tree/master/alignment_testset/IFC-WithGeneratedGeometry +# for vertical alignment. + +import pytest +import ifcopenshell.api.alignment + + +def _CircularArc_100_0_10_0_0_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=0.5, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(1053.72220965611) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(103.674757133105) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((-0.0, 223.606797749979)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(223.606797749979) + + +def _CircularArc_100_0_10_0_0_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=-0.5, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(351.240736552036) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-103.674757133105) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-1.36919674566051e-14, -223.606797749979) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(223.606797749979) + + +def _CircularArc_100_0_10_0_0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=0.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(454.915493685141) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-103.674757133105) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((100.0, -200.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(223.606797749979) + + +def _CircularArc_100_0_10_0__0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=0.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(950.047452523004) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(103.674757133105) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((100.0, 200.0)) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(223.606797749979) + + +def _CircularArc_100_0_10_0_0_5_1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=1.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(1991.60150186753) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(123.801073716741) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-172.075922005613, 344.151844011225) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(384.773458895502) + + +def _CircularArc_100_0_10_0__0_5__1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=-1.0, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(426.001441657352) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-123.801073716741) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (-172.075922005613, -344.151844011225) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(384.773458895502) + + +def _CircularArc_100_0_10_0_1_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=1.0, + EndGradient=0.5, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, 0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(906.601103821832) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(-123.801073716741) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (272.075922005613, -272.075922005613) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(384.773458895502) + + +def _CircularArc_100_0_10_0__1_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-1.0, + EndGradient=-0.5, + PredefinedType="CIRCULARARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, -0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(1511.00183970305) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(123.801073716741) + assert mapped_segment.ParentCurve.is_a("IfcCircle") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx( + (272.075922005613, 272.075922005613) + ) + assert mapped_segment.ParentCurve.Position.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Radius == pytest.approx(384.773458895502) + + +def _ConstantGradient_100_0_10_0_0_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=0.5, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0_0_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=-0.5, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(100.0) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0_0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=0.0, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(111.803398874989) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0__0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=0.0, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(111.803398874989) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0_0_5_1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=1.0, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(111.803398874989) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0__0_5__1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=-1.0, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(111.803398874989) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0_1_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=1.0, + EndGradient=0.5, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, 0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(141.42135623731) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ConstantGradient_100_0_10_0__1_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-1.0, + EndGradient=-0.5, + PredefinedType="CONSTANTGRADIENT", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, -0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(141.42135623731) + assert mapped_segment.ParentCurve.is_a("IfcLine") + assert mapped_segment.ParentCurve.Pnt.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Orientation.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.ParentCurve.Dir.Magnitude == pytest.approx(1.0) + + +def _ParabolicArc_100_0_10_0_0_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=0.5, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(104.02288238772185) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, 0.0, 0.0025)) + + +def _ParabolicArc_100_0_10_0_0_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.0, + EndGradient=-0.5, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx((1.0, 0.0)) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(104.02288238772185) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, 0.0, -0.0025)) + + +def _ParabolicArc_100_0_10_0_0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=0.0, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(104.02288238772185) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, 0.5, -0.0025)) + + +def _ParabolicArc_100_0_10_0__0_5_0_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=0.0, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(104.02288238772185) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, -0.5, 0.0025)) + + +def _ParabolicArc_100_0_10_0_0_5_1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=0.5, + EndGradient=1.0, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, 0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(125.53583325398947) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, 0.5, 0.0025)) + + +def _ParabolicArc_100_0_10_0__0_5__1_0_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-0.5, + EndGradient=-1.0, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.894427190999916, -0.447213595499958) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(125.53583325398947) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, -0.5, -0.0025)) + + +def _ParabolicArc_100_0_10_0_1_0_0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=1.0, + EndGradient=0.5, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, 0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(125.53583325398947) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, 1.0, -0.0025)) + + +def _ParabolicArc_100_0_10_0__1_0__0_5_1_Meter(file): + design_parameters = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=10.0, + StartGradient=-1.0, + EndGradient=-0.5, + PredefinedType="PARABOLICARC", + ) + + alignment_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters + ) + + mapped_segments = ifcopenshell.api.alignment.map_alignment_segment(file, alignment_segment) + mapped_segment = mapped_segments[0] + assert len(mapped_segments) == 2 + assert mapped_segments[1] == None + assert "DISCONTINUOUS" == mapped_segment.Transition + assert mapped_segment.Placement.Location.Coordinates == pytest.approx((0.0, 10.0)) + assert mapped_segment.Placement.RefDirection.DirectionRatios == pytest.approx( + (0.707106781186547, -0.707106781186547) + ) + assert mapped_segment.SegmentStart.wrappedValue == pytest.approx(0.0) + assert mapped_segment.SegmentLength.wrappedValue == pytest.approx(125.53583325398947) + assert mapped_segment.ParentCurve.is_a("IfcPolynomialCurve") + assert mapped_segment.ParentCurve.Position.Location.Coordinates == pytest.approx((0.0, 0.0)) + assert mapped_segment.ParentCurve.CoefficientsX == pytest.approx((0.0, 1.0)) + assert mapped_segment.ParentCurve.CoefficientsY == pytest.approx((10.0, -1.0, 0.0025)) + + +def test_map_alignment_vertical_segment(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + _CircularArc_100_0_10_0_0_0_0_5_1_Meter(file) + _CircularArc_100_0_10_0_0_0__0_5_1_Meter(file) + _CircularArc_100_0_10_0_0_5_0_0_1_Meter(file) + _CircularArc_100_0_10_0__0_5_0_0_1_Meter(file) + _CircularArc_100_0_10_0_0_5_1_0_1_Meter(file) + _CircularArc_100_0_10_0__0_5__1_0_1_Meter(file) + _CircularArc_100_0_10_0_1_0_0_5_1_Meter(file) + _CircularArc_100_0_10_0__1_0__0_5_1_Meter(file) + _ConstantGradient_100_0_10_0_0_0_0_5_1_Meter(file) + _ConstantGradient_100_0_10_0_0_0__0_5_1_Meter(file) + _ConstantGradient_100_0_10_0_0_5_0_0_1_Meter(file) + _ConstantGradient_100_0_10_0__0_5_0_0_1_Meter(file) + _ConstantGradient_100_0_10_0_0_5_1_0_1_Meter(file) + _ConstantGradient_100_0_10_0__0_5__1_0_1_Meter(file) + _ConstantGradient_100_0_10_0_1_0_0_5_1_Meter(file) + _ConstantGradient_100_0_10_0__1_0__0_5_1_Meter(file) + _ParabolicArc_100_0_10_0_0_0_0_5_1_Meter(file) + _ParabolicArc_100_0_10_0_0_0__0_5_1_Meter(file) + _ParabolicArc_100_0_10_0_0_5_0_0_1_Meter(file) + _ParabolicArc_100_0_10_0__0_5_0_0_1_Meter(file) + _ParabolicArc_100_0_10_0_0_5_1_0_1_Meter(file) + _ParabolicArc_100_0_10_0__0_5__1_0_1_Meter(file) + _ParabolicArc_100_0_10_0_1_0_0_5_1_Meter(file) + _ParabolicArc_100_0_10_0__1_0__0_5_1_Meter(file) + + # VERTICAL CLOTHOID NOT IMPLEMENTED diff --git a/src/ifcopenshell-python/test/api/alignment/test_name_segments.py b/src/ifcopenshell-python/test/api/alignment/test_name_segments.py new file mode 100644 index 0000000000..4ad01063d5 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_name_segments.py @@ -0,0 +1,53 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def test_name_segments(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_alignment_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + + for rel in alignment.IsNestedBy: + for a in rel.RelatedObjects: + if a.is_a("IfcLinearElement"): + ifcopenshell.api.alignment.name_segments("Q", a) + i = 1 + for sr in a.IsNestedBy: + for s in sr.RelatedObjects: + assert f"Q{i}" == s.Name + i += 1 diff --git a/src/ifcopenshell-python/test/api/alignment/test_update_curve_segment_transition_code.py b/src/ifcopenshell-python/test/api/alignment/test_update_curve_segment_transition_code.py new file mode 100644 index 0000000000..fcfe6cb2af --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_update_curve_segment_transition_code.py @@ -0,0 +1,248 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 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 pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context + + +def _test1(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + # 26=IFCCARTESIANPOINT((4084.115884,3889.462938)); + # 70=IFCDIRECTION((0.224530986099614,0.974466949814685)); + # 71=IFCAXIS2PLACEMENT2D(#26,#70); + # 72=IFCCARTESIANPOINT((0.,0.)); + # 73=IFCDIRECTION((1.,0.)); + # 74=IFCAXIS2PLACEMENT2D(#72,#73); + # 75=IFCCIRCLE(#74,1250.); + # 76=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#71,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(-1848.115835),#75); + circular_arc = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((4084.115884, 3889.462938)), + file.createIfcDirection((0.224530986099614, 0.974466949814685)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(-1848.115835), + ParentCurve=file.createIfcCircle( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), file.createIfcDirection((1.0, 0.0)) + ), + Radius=1250.0, + ), + ) + + # 27=IFCCARTESIANPOINT((5469.395067,4847.56631)); + # 77=IFCDIRECTION((0.991014275066766,-0.133756146078947)); + # 78=IFCAXIS2PLACEMENT2D(#27,#77); + # 79=IFCCARTESIANPOINT((0.,0.)); + # 80=IFCDIRECTION((1.,0.)); + # 81=IFCVECTOR(#80,1.); + # 82=IFCLINE(#79,#81); + # 83=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#78,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(1564.635765),#82); + line = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((5469.395067, 4847.56631)), + file.createIfcDirection((0.991014275066766, -0.133756146078947)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(1564.635765), + ParentCurve=file.createIfcLine( + Pnt=file.createIfcCartesianPoint((0.0, 0.0)), + Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0), + ), + ) + + composite_curve = file.createIfcCompositeCurve(Segments=(circular_arc, line), SelfIntersect=False) + + ifcopenshell.api.alignment.update_curve_segment_transition_code(circular_arc, line) + assert circular_arc.Transition == "CONTSAMEGRADIENT" + + +def _test2(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(Name="Test") + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + # 30=IFCCARTESIANPOINT((0.,0.)); + # 31=IFCALIGNMENTHORIZONTALSEGMENT($,$,#30,0.523598775598299,0.,0.,27.8843513637174,$,.LINE.); + # 32=IFCALIGNMENTSEGMENT('3$jiMaOgfAoujgvRyMLw0X',$,'H1',$,$,#111,#113,#31); + # 33=IFCDIRECTION((0.866025403784439,0.5)); + # 34=IFCAXIS2PLACEMENT2D(#30,#33); + # 35=IFCCARTESIANPOINT((0.,0.)); + # 36=IFCDIRECTION((1.,0.)); + # 37=IFCVECTOR(#36,1.); + # 38=IFCLINE(#35,#37); + # 39=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#34,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(27.8843513637174),#38); + + line1 = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), + file.createIfcDirection((0.866025403784439, 0.5)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(27.8843513637174), + ParentCurve=file.createIfcLine( + Pnt=file.createIfcCartesianPoint((0.0, 0.0)), + Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0), + ), + ) + + # 40=IFCCARTESIANPOINT((24.1485566490305,13.9421756818587)); + # 41=IFCALIGNMENTHORIZONTALSEGMENT($,$,#40,0.523598775598299,0.,1524.,152.4,$,.CLOTHOID.); + # 42=IFCALIGNMENTSEGMENT('0Rd38fCkHF1Q11ppiqdMP6',$,'H2',$,$,#111,#115,#41); + # 43=IFCDIRECTION((0.866025403784439,0.5)); + # 44=IFCAXIS2PLACEMENT2D(#40,#43); + # 45=IFCCARTESIANPOINT((0.,0.)); + # 46=IFCDIRECTION((1.,0.)); + # 47=IFCAXIS2PLACEMENT2D(#45,#46); + # 48=IFCCLOTHOID(#47,481.931115409661); + # 49=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#44,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(152.4),#48); + + clothoid1 = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((24.1485566490305, 13.9421756818587)), + file.createIfcDirection((0.866025403784439, 0.5)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(152.4), + ParentCurve=file.createIfcClothoid( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0)) + ), + ClothoidConstant=481.931115409661, + ), + ) + + # 50=IFCCARTESIANPOINT((154.828063204281,92.32243963907)); + # 51=IFCALIGNMENTHORIZONTALSEGMENT($,$,#50,0.573598775598299,1524.,1524.,246.582267005904,$,.CIRCULARARC.); + # 52=IFCALIGNMENTSEGMENT('2OGYY2lQjCnRlzxKU9vtdu',$,'H3',$,$,#111,#117,#51); + # 53=IFCDIRECTION((0.839953512903025,0.542658360445933)); + # 54=IFCAXIS2PLACEMENT2D(#50,#53); + # 55=IFCCARTESIANPOINT((0.,0.)); + # 56=IFCDIRECTION((1.,0.)); + # 57=IFCAXIS2PLACEMENT2D(#55,#56); + # 58=IFCCIRCLE(#57,1524.); + # 59=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#54,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(246.582267005904),#58); + + circular_arc = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((154.828063204281, 92.32243963907)), + file.createIfcDirection((0.839953512903025, 0.542658360445933)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(246.582267005904), + ParentCurve=file.createIfcCircle( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), file.createIfcDirection((1.0, 0.0)) + ), + Radius=1524.0, + ), + ) + + # 60=IFCCARTESIANPOINT((350.24160971216,242.268527691248)); + # 61=IFCALIGNMENTHORIZONTALSEGMENT($,$,#60,0.735398163397447,1524.,0.,152.4,$,.CLOTHOID.); + # 62=IFCALIGNMENTSEGMENT('13CGRzUN9CAfNVKnf0Pxza',$,'H4',$,$,#111,#119,#61); + # 63=IFCDIRECTION((0.741563691346478,0.670882472327743)); + # 64=IFCAXIS2PLACEMENT2D(#60,#63); + # 65=IFCCARTESIANPOINT((0.,0.)); + # 66=IFCDIRECTION((1.,0.)); + # 67=IFCAXIS2PLACEMENT2D(#65,#66); + # 68=IFCCLOTHOID(#67,-481.931115409661); + # 69=IFCCURVESEGMENT(.CONTSAMEGRADIENT.,#64,IFCLENGTHMEASURE(-152.4),IFCLENGTHMEASURE(152.4),#68); + + clothoid2 = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((350.24160971216, 242.268527691248)), + file.createIfcDirection((0.741563691346478, 0.670882472327743)), + ), + SegmentStart=file.createIfcLengthMeasure(-152.4), + SegmentLength=file.createIfcLengthMeasure(152.4), + ParentCurve=file.createIfcClothoid( + Position=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0)) + ), + ClothoidConstant=-481.931115409661, + ), + ) + + # 70=IFCCARTESIANPOINT((459.773476040884,348.208932967387)); + # 71=IFCALIGNMENTHORIZONTALSEGMENT($,$,#70,0.785398163397448,0.,0.,0.,$,.LINE.); + # 72=IFCALIGNMENTSEGMENT('0FlcTrOfT5YBi0fqVhTMyc',$,'H5',$,$,#111,#121,#71); + # 73=IFCDIRECTION((0.707106781186548,0.707106781186548)); + # 74=IFCAXIS2PLACEMENT2D(#70,#73); + # 75=IFCCARTESIANPOINT((0.,0.)); + # 76=IFCDIRECTION((1.,0.)); + # 77=IFCVECTOR(#76,1.); + # 78=IFCLINE(#75,#77); + # 79=IFCCURVESEGMENT(.DISCONTINUOUS.,#74,IFCLENGTHMEASURE(0.),IFCLENGTHMEASURE(0.),#78); + + line2 = file.createIfcCurveSegment( + Placement=file.createIfcAxis2Placement2d( + file.createIfcCartesianPoint((459.773476040884, 348.208932967387)), + file.createIfcDirection((0.707106781186548, 0.707106781186548)), + ), + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(0.0), + ParentCurve=file.createIfcLine( + Pnt=file.createIfcCartesianPoint((0.0, 0.0)), + Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0), + ), + ) + + composite_curve = file.createIfcCompositeCurve(Segments=[], SelfIntersect=False) + + # add_segment_to_curve calls update_curve_segment_transition_code + ifcopenshell.api.alignment.add_segment_to_curve(file, line1, composite_curve) + assert line1.Transition == "DISCONTINUOUS" + + ifcopenshell.api.alignment.add_segment_to_curve(file, clothoid1, composite_curve) + assert line1.Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert clothoid1.Transition == "DISCONTINUOUS" + + ifcopenshell.api.alignment.add_segment_to_curve(file, circular_arc, composite_curve) + assert clothoid1.Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert circular_arc.Transition == "DISCONTINUOUS" + + ifcopenshell.api.alignment.add_segment_to_curve(file, clothoid2, composite_curve) + assert circular_arc.Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert clothoid2.Transition == "DISCONTINUOUS" + + ifcopenshell.api.alignment.add_segment_to_curve(file, line2, composite_curve) + assert clothoid2.Transition == "CONTSAMEGRADIENTSAMECURVATURE" + assert line2.Transition == "DISCONTINUOUS" + + +def test_update_curve_segment_transition_code(): + _test1() + _test2() diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py index ba8385fa3e..07a4400f5a 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py @@ -105,7 +105,7 @@ class TestAddBoolean(test.bootstrap.IFC4): assert len(booleans) == 1 assert len(rep.Items) == 2 - assert len(self.file.get_inverse(first1)) == 1 + assert self.file.get_total_inverses(first1) == 1 result = list(self.file.get_inverse(first1))[0] assert result.FirstOperand == first1 assert result.SecondOperand == second1 @@ -114,7 +114,7 @@ class TestAddBoolean(test.bootstrap.IFC4): # Second2 is now used twice. Reusing is OK (albeit confusing), so long as things don't get recursive. assert result2.SecondOperand == second2 - assert len(self.file.get_inverse(first2)) == 1 + assert self.file.get_total_inverses(first2) == 1 result3 = list(self.file.get_inverse(first2))[0] assert result3.FirstOperand == first2 assert result3.SecondOperand == second2 diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_profile_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_profile_representation.py new file mode 100644 index 0000000000..39ac9aa928 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_add_profile_representation.py @@ -0,0 +1,75 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Dion Moult +# +# 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 test.bootstrap +import ifcopenshell.api.unit +import ifcopenshell.api.root +import ifcopenshell.api.context +import ifcopenshell.api.geometry +from ifcopenshell.util.shape_builder import ShapeBuilder + + +class TestAddProfileRepresentation(test.bootstrap.IFC4): + def setup_profile(self) -> None: + builder = ShapeBuilder(self.file) + + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + # In IFC2X3 the unit is required. + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix=None) + ifcopenshell.api.unit.assign_unit(self.file, [unit]) + + model_context = ifcopenshell.api.context.add_context(self.file, context_type="Model") + self.body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_context + ) + rectangle = builder.rectangle((100, 100)) + self.profile = builder.profile(rectangle) + + def test_run(self): + self.setup_profile() + representation_string_cardinal_point = ifcopenshell.api.geometry.add_profile_representation( + self.file, + context=self.body, + profile=self.profile, + depth=1000, + cardinal_point="bottom left", + ) + representation_numeric_cardinal_point = ifcopenshell.api.geometry.add_profile_representation( + self.file, + context=self.body, + profile=self.profile, + depth=1000, + cardinal_point=1, + ) + representations = [representation_string_cardinal_point, representation_numeric_cardinal_point] + for representation in representations: + assert representation.is_a("IfcShapeRepresentation") + item = representation.Items[0] + assert item.is_a("IfcExtrudedAreaSolid") + assert item.SweptArea == self.profile + assert item.ExtrudedDirection.DirectionRatios == (0.0, 0.0, 1.0) + assert item.Depth == 1000 + assert item.Position.Location.Coordinates == (-50.0, 50.0, 0.0) + + +class TestAddProfileRepresentationIFC2X3(test.bootstrap.IFC2X3, TestAddProfileRepresentation): + pass + + +class TestAddProfileRepresentationIFC4X3(test.bootstrap.IFC4X3, TestAddProfileRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py b/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py index 1ee1b3ac87..c827f1e33a 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_shape_aspect.py @@ -20,6 +20,7 @@ import test.bootstrap import ifcopenshell.api.root import ifcopenshell.api.context import ifcopenshell.api.geometry +import ifcopenshell.util.shape_builder class TestAddShapeAspect(test.bootstrap.IFC4): diff --git a/src/ifcopenshell-python/test/api/georeference/test_edit_true_north.py b/src/ifcopenshell-python/test/api/georeference/test_edit_true_north.py index 5076b46d67..9f8f2dd07a 100644 --- a/src/ifcopenshell-python/test/api/georeference/test_edit_true_north.py +++ b/src/ifcopenshell-python/test/api/georeference/test_edit_true_north.py @@ -30,7 +30,7 @@ class TestEditTrueNorth(test.bootstrap.IFC4): model = ifcopenshell.api.context.add_context(self.file, "Model") plan = ifcopenshell.api.context.add_context(self.file, "Plan") ifcopenshell.api.georeference.edit_true_north(self.file, true_north=[0.0, 1.0]) - assert model.TrueNorth[0] == (0.0, 1.0, 0.0) + assert model.TrueNorth[0] == (0.0, 1.0) assert plan.TrueNorth[0] == (0.0, 1.0) ifcopenshell.api.georeference.edit_true_north(self.file, true_north=[-0.5, 0.8660254]) assert np.isclose(ifcopenshell.util.geolocation.get_true_north(self.file), 30) diff --git a/src/ifcopenshell-python/test/api/material/test_edit_profile_usage.py b/src/ifcopenshell-python/test/api/material/test_edit_profile_usage.py new file mode 100644 index 0000000000..3a029482c1 --- /dev/null +++ b/src/ifcopenshell-python/test/api/material/test_edit_profile_usage.py @@ -0,0 +1,67 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# 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 test.bootstrap +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.material +import ifcopenshell.api.root +import ifcopenshell.util.placement +from ifcopenshell.util.shape_builder import ShapeBuilder + + +# IfcMaterialProfileSetUsage added in IFC4. +class TestEditProfileUsageIFC4(test.bootstrap.IFC4): + def test_update_cardinal_point(self): + model = self.file + builder = ShapeBuilder(model) + + ifcopenshell.api.root.create_entity(model, ifc_class="IfcProject") + model_context = ifcopenshell.api.context.add_context(model, context_type="Model") + body = ifcopenshell.api.context.add_context( + model, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_context + ) + + material_set = ifcopenshell.api.material.add_material_set(model, name="B1", set_type="IfcMaterialProfileSet") + steel = ifcopenshell.api.material.add_material(model, name="ST01", category="steel") + rectangle = builder.rectangle((100, 100)) + profile = builder.profile(rectangle) + ifcopenshell.api.material.add_profile(model, profile_set=material_set, material=steel, profile=profile) + beam = ifcopenshell.api.root.create_entity(model, ifc_class="IfcBeam", name="B1.01") + rel = ifcopenshell.api.material.assign_material( + model, material=material_set, products=[beam], type="IfcMaterialProfileSetUsage" + ) + assert isinstance(rel, ifcopenshell.entity_instance) + usage = rel.RelatingMaterial + assert usage.CardinalPoint is None + + representation = ifcopenshell.api.geometry.add_profile_representation( + model, + context=body, + profile=profile, + depth=1000, + cardinal_point=5, + ) + assert representation.Items[0].Position.Location.Coordinates == (0.0, 0.0, 0.0) + ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation) + ifcopenshell.api.material.edit_profile_usage(model, usage=rel.RelatingMaterial, attributes={"CardinalPoint": 1}) + assert representation.Items[0].Position.Location.Coordinates == (-50.0, 50.0, 0.0) + + +class TestEditProfileUsageIFC4X3(test.bootstrap.IFC4X3, TestEditProfileUsageIFC4): + pass diff --git a/src/ifcopenshell-python/test/api/project/test_append_asset.py b/src/ifcopenshell-python/test/api/project/test_append_asset.py index c2718f5612..9251f10592 100644 --- a/src/ifcopenshell-python/test/api/project/test_append_asset.py +++ b/src/ifcopenshell-python/test/api/project/test_append_asset.py @@ -611,18 +611,27 @@ class TestAppendAssetIFC4(test.bootstrap.IFC4, TestAppendAssetIFC2X3): assert appended_item.is_a("IfcCostItem") assert appended_item.IsNestedBy[0].RelatedObjects[0].is_a("IfcCostItem") - def test_not_duplicate_profiles_and_materials_based_on_name(self): + def test_not_duplicate_profiles_materials_styles_based_on_name(self): + # Setup library. library = ifcopenshell.api.project.create_file(version=self.file.schema) + ifcopenshell.api.root.create_entity(library, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(library, context_type="Model") + body = ifcopenshell.api.context.add_context( + library, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) column_type = ifcopenshell.api.root.create_entity(library, "IfcColumnType") library_profile = ifcopenshell.api.profile.add_parameterized_profile(library, "IfcCircleProfileDef") library_profile.ProfileName = "TestProfile" material_set = ifcopenshell.api.material.add_material_set(library, set_type="IfcMaterialProfileSet") material = ifcopenshell.api.material.add_material(library, "TestMaterial") + style = ifcopenshell.api.style.add_style(library, "TestStyle", ifc_class="IfcSurfaceStyle") + ifcopenshell.api.style.assign_material_style(library, material, style, body) ifcopenshell.api.material.add_profile(library, material_set, material, library_profile) ifcopenshell.api.material.assign_material( library, [column_type], material=material_set, type="IfcMaterialProfileSet" ) + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") # Test adding a profile with existing name. profile = ifcopenshell.api.profile.add_parameterized_profile(self.file, "IfcCircleProfileDef") profile.ProfileName = "TestProfile" @@ -636,10 +645,18 @@ class TestAppendAssetIFC4(test.bootstrap.IFC4, TestAppendAssetIFC2X3): materials = self.file.by_type("IfcMaterial") assert len(materials) == 1 and materials[0].Name == "TestMaterial" - # Test implicitly adding profile+material with existing names. + # Test adding a style with existing name. + style = ifcopenshell.api.style.add_style(self.file, "TestStyle", ifc_class="IfcSurfaceStyle") + ifcopenshell.api.project.append_asset(self.file, library, style) + styles = self.file.by_type("IfcSurfaceStyle") + assert len(styles) == 1 and styles[0].Name == "TestStyle" + + # Test implicitly adding profile+material+style with existing names. ifcopenshell.api.project.append_asset(self.file, library, column_type) assert len(self.file.by_type("IfcColumnType")) == 1 profiles = self.file.by_type("IfcProfileDef") assert len(profiles) == 1 and profiles[0].ProfileName == "TestProfile" materials = self.file.by_type("IfcMaterial") assert len(materials) == 1 and materials[0].Name == "TestMaterial" + styles = self.file.by_type("IfcSurfaceStyle") + assert len(styles) == 1 and styles[0].Name == "TestStyle" diff --git a/src/ifcopenshell-python/test/api/root/test_reassign_class.py b/src/ifcopenshell-python/test/api/root/test_reassign_class.py index f7b1024b6b..f8be13225e 100644 --- a/src/ifcopenshell-python/test/api/root/test_reassign_class.py +++ b/src/ifcopenshell-python/test/api/root/test_reassign_class.py @@ -197,5 +197,30 @@ class TestReassignClass(test.bootstrap.IFC4): assert len(self.file.by_type("IfcSlab")) == 1 -class TestReassignClassIFC2X3(test.bootstrap.IFC2X3, TestReassignClass): +class TestReassignClassIFC4X3(test.bootstrap.IFC4X3, TestReassignClass): pass + + +class TestReassignClassIFC2X3(test.bootstrap.IFC2X3, TestReassignClass): + def test_providing_occurrence_class(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element1], relating_type=element_type) + element2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element2], relating_type=element_type) + + new_element_type = ifcopenshell.api.root.reassign_class( + self.file, + product=element_type, + ifc_class="IfcBuildingElementProxyType", + occurrence_class="IfcRoof", + ) + assert new_element_type.is_a("IfcBuildingElementProxyType") + occurrences = ifcopenshell.util.element.get_types(new_element_type) + assert len(occurrences) == 2 + # Assign IfcRoof instead of IfcBuildingElementProxy. + assert all(o.is_a("IfcRoof") for o in occurrences) + + # original clases are gone + assert len(self.file.by_type("IfcWall")) == 0 + assert len(self.file.by_type("IfcWallType")) == 0 diff --git a/src/ifcopenshell-python/test/api/structural/test_edit_structural_analysis_model.py b/src/ifcopenshell-python/test/api/structural/test_edit_structural_analysis_model.py index 18f389f488..fddb8a9993 100644 --- a/src/ifcopenshell-python/test/api/structural/test_edit_structural_analysis_model.py +++ b/src/ifcopenshell-python/test/api/structural/test_edit_structural_analysis_model.py @@ -23,7 +23,7 @@ import ifcopenshell.api.structural class TestEditStructuralAnalysisModel(test.bootstrap.IFC4): def test_editing_a_structural_analysis_model(self): subject = ifcopenshell.api.structural.add_structural_analysis_model(self.file) - subject = ifcopenshell.api.structural.edit_structural_analysis_model( + ifcopenshell.api.structural.edit_structural_analysis_model( self.file, structural_analysis_model=subject, attributes={"Name": "My edited model", "Description": "Description of my model"}, diff --git a/src/ifcopenshell-python/test/geom/original_edges.py b/src/ifcopenshell-python/test/geom/original_edges.py new file mode 100644 index 0000000000..41fd6b8aff --- /dev/null +++ b/src/ifcopenshell-python/test/geom/original_edges.py @@ -0,0 +1,98 @@ +import ifcopenshell +import ifcopenshell.geom + +contents = """ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('untitled.ifc','2025-02-17T08:13:34+11:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.1-alpha250208-8f261be','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('3IbgqFUY99IejU9tnNSVCj',$,'My Project',$,$,$,$,(#14,#26),#9); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#6=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#7=IFCMEASUREWITHUNIT(IFCREAL(0.0174532925199433),#6); +#8=IFCCONVERSIONBASEDUNIT(#5,.PLANEANGLEUNIT.,'degree',#7); +#9=IFCUNITASSIGNMENT((#3,#4,#2,#8)); +#10=IFCCARTESIANPOINT((0.,0.,0.)); +#11=IFCDIRECTION((0.,0.,1.)); +#12=IFCDIRECTION((1.,0.,0.)); +#13=IFCAXIS2PLACEMENT3D(#10,#11,#12); +#14=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#13,$); +#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#14,$,.GRAPH_VIEW.,$); +#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.SECTION_VIEW.,$); +#19=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.ELEVATION_VIEW.,$); +#20=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.MODEL_VIEW.,$); +#21=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#14,$,.PLAN_VIEW.,$); +#22=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#14,$,.ELEVATION_VIEW.,$); +#23=IFCCARTESIANPOINT((0.,0.)); +#24=IFCDIRECTION((1.,0.)); +#25=IFCAXIS2PLACEMENT2D(#23,#24); +#26=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#25,$); +#27=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#26,$,.GRAPH_VIEW.,$); +#28=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#26,$,.PLAN_VIEW.,$); +#29=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#26,$,.PLAN_VIEW.,$); +#30=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#26,$,.REFLECTED_PLAN_VIEW.,$); +#31=IFCSITE('0RWRVKEyv0w8rD30dnxZgC',$,'My Site',$,$,#54,$,$,$,$,$,$,$,$); +#37=IFCBUILDING('39X8AnBtP4gvIIEtolio1r',$,'My Building',$,$,#60,$,$,$,$,$,$); +#43=IFCBUILDINGSTOREY('3u3kxyF4r8PPWfqNxA77Mg',$,'My Storey',$,$,#66,$,$,$,$); +#49=IFCRELAGGREGATES('254xcZnL5DnQg9572XWoGw',$,$,$,#1,(#31)); +#50=IFCCARTESIANPOINT((0.,0.,0.)); +#51=IFCDIRECTION((0.,0.,1.)); +#52=IFCDIRECTION((1.,0.,0.)); +#53=IFCAXIS2PLACEMENT3D(#50,#51,#52); +#54=IFCLOCALPLACEMENT($,#53); +#55=IFCRELAGGREGATES('3mOs7xn1P8ZutyBJCrBHy6',$,$,$,#31,(#37)); +#56=IFCCARTESIANPOINT((0.,0.,0.)); +#57=IFCDIRECTION((0.,0.,1.)); +#58=IFCDIRECTION((1.,0.,0.)); +#59=IFCAXIS2PLACEMENT3D(#56,#57,#58); +#60=IFCLOCALPLACEMENT(#54,#59); +#61=IFCRELAGGREGATES('061R4J$_v02e5$uJ_Zcpwu',$,$,$,#37,(#43)); +#62=IFCCARTESIANPOINT((0.,0.,0.)); +#63=IFCDIRECTION((0.,0.,1.)); +#64=IFCDIRECTION((1.,0.,0.)); +#65=IFCAXIS2PLACEMENT3D(#62,#63,#64); +#66=IFCLOCALPLACEMENT(#60,#65); +#67=IFCACTUATOR('3XPXM$jnf6gQHxVSs1SU0h',$,'Cube',$,$,#83,#78,$,.ELECTRICACTUATOR.); +#68=IFCRELCONTAINEDINSPATIALSTRUCTURE('2uCE41OAzCIh07v_z205CZ',$,$,$,(#67),#43); +#77=IFCSHAPEREPRESENTATION(#15,'Body','Tessellation',(#95)); +#78=IFCPRODUCTDEFINITIONSHAPE($,$,(#77)); +#79=IFCCARTESIANPOINT((0.,0.,0.)); +#80=IFCDIRECTION((0.,0.,1.)); +#81=IFCDIRECTION((1.,0.,0.)); +#82=IFCAXIS2PLACEMENT3D(#79,#80,#81); +#83=IFCLOCALPLACEMENT(#66,#82); +#84=IFCCARTESIANPOINTLIST3D(((-1.,1.,-1.),(-1.,-1.,-1.),(-1.,-1.,1.),(1.,1.,-1.),(-1.,1.,1.),(1.,-1.,-1.),(1.,1.,1.),(1.,-1.,1.),(1.,1.,2.),(1.,-1.,2.),(-1.,1.,2.),(-1.,-1.,2.))); +#85=IFCINDEXEDPOLYGONALFACE((5,7,4,1)); +#86=IFCINDEXEDPOLYGONALFACE((7,8,6,4)); +#87=IFCINDEXEDPOLYGONALFACE((8,3,2,6)); +#88=IFCINDEXEDPOLYGONALFACE((4,6,2,1)); +#89=IFCINDEXEDPOLYGONALFACE((5,3,12,11)); +#90=IFCINDEXEDPOLYGONALFACE((3,5,1,2)); +#91=IFCINDEXEDPOLYGONALFACE((11,12,10,9)); +#92=IFCINDEXEDPOLYGONALFACE((8,7,9,10)); +#93=IFCINDEXEDPOLYGONALFACE((3,8,10,12)); +#94=IFCINDEXEDPOLYGONALFACE((7,5,11,9)); +#95=IFCPOLYGONALFACESET(#84,$,(#85,#86,#87,#88,#89,#90,#91,#92,#93,#94),$); +ENDSEC; +END-ISO-10303-21; +""" + + +def test_original_edges(): + ifc_file = ifcopenshell.file.from_string(contents) + element = ifc_file.by_id(95) + settings = ifcopenshell.geom.settings() + shape = ifcopenshell.geom.create_shape(settings, element, geometry_library="opencascade") + assert (len(shape.edges) // 2) == 20 + shape = ifcopenshell.geom.create_shape(settings, element, geometry_library="cgal") + assert (len(shape.edges) // 2) == 16 + settings.set("cgal-original-edges", True) + shape = ifcopenshell.geom.create_shape(settings, element, geometry_library="cgal") + assert (len(shape.edges) // 2) == 20 diff --git a/src/ifcopenshell-python/test/test_sql.py b/src/ifcopenshell-python/test/test_sql.py index 8b4323a3c8..22fba621e1 100644 --- a/src/ifcopenshell-python/test/test_sql.py +++ b/src/ifcopenshell-python/test/test_sql.py @@ -29,14 +29,14 @@ def get_ifc_sqlite() -> ifcopenshell.sqlite: global SQLITE_PATH if SQLITE_PATH is None: tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ifcsqlite") - SQLITE_PATH = ifcpatch.execute( + ifcpatch.execute( { "file": ifcopenshell.open(TEST_FILE), "recipe": "Ifc2Sql", "arguments": ["sqlite", None, None, None, tmp.name], } ) - assert isinstance(SQLITE_PATH, str) + SQLITE_PATH = tmp.name ifc_sqlite = ifcopenshell.open(SQLITE_PATH) assert isinstance(ifc_sqlite, ifcopenshell.sqlite) return ifc_sqlite diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 1ab002f7b0..54e4db0c58 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -387,6 +387,25 @@ class TestGetShapeAspects(test.bootstrap.IFC4): shape_aspect.PartOfProductDefinitionShape = product_shape assert tuple(subject.get_shape_aspects(element)) == (shape_aspect,) + def test_getting_the_shape_aspects_of_a_product_with_inheritance(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + + # Setup type shape aspect. + type_shape_aspect = self.file.create_entity("IfcShapeAspect") + representation_map = self.file.create_entity("IfcRepresentationMap") + element_type.RepresentationMaps = (representation_map,) + type_shape_aspect.PartOfProductDefinitionShape = representation_map + + # Setup occurrence shape aspect. + occurrence_shape_aspect = self.file.create_entity("IfcShapeAspect") + product_shape = self.file.create_entity("IfcProductDefinitionShape") + element.Representation = product_shape + occurrence_shape_aspect.PartOfProductDefinitionShape = product_shape + + assert subject.get_shape_aspects(element) == [type_shape_aspect, occurrence_shape_aspect] + class TestGetMaterial(test.bootstrap.IFC4): def test_getting_the_material_of_a_product(self): @@ -793,6 +812,26 @@ class TestGetContainerIFC4(test.bootstrap.IFC4): ifcopenshell.api.aggregate.assign_object(self.file, products=[subelement], relating_object=element) assert subject.get_container(subelement, should_get_direct=True) is None + def test_getting_the_specific_spatial_container_of_an_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.aggregate.assign_object(self.file, products=[storey], relating_object=building) + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=storey) + assert subject.get_container(element, ifc_class="IfcBuilding") == building + assert subject.get_container(element, ifc_class="IfcSite") == None + + def test_getting_the_specific_spatial_container_of_an_element_indirectly(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcElementAssembly") + subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.aggregate.assign_object(self.file, products=[subelement], relating_object=element) + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.aggregate.assign_object(self.file, products=[storey], relating_object=building) + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=storey) + assert subject.get_container(subelement, ifc_class="IfcBuilding") == building + assert subject.get_container(subelement, ifc_class="IfcSite") == None + class TestGetReferencedStructures(test.bootstrap.IFC4): def test_getting_references_of_an_element(self): diff --git a/src/ifcopenshell-python/test/util/test_pset.py b/src/ifcopenshell-python/test/util/test_pset.py index 90b90f6e35..c99a0b86a6 100644 --- a/src/ifcopenshell-python/test/util/test_pset.py +++ b/src/ifcopenshell-python/test/util/test_pset.py @@ -19,6 +19,7 @@ """Run this test from src/ifcopenshell-python folder: pytest --durations=0 ifcopenshell/util/test_pset.py""" from ifcopenshell.util import pset from ifcopenshell import util +from ifcopenshell.util.pset import ApplicableEntity class TestPsetQto: @@ -66,3 +67,72 @@ class TestPsetQto: assert "Pset_MaterialConcrete" not in names names = self.pset_qto.get_applicable_names("IfcMaterial", "concrete") assert "Pset_MaterialConcrete" in names + + +class TestParseApplicableEntity: + def test_run(self): + assert pset.parse_applicable_entity("IfcBoilerType") == [ + ApplicableEntity("IfcBoilerType", "IfcBoilerType", None, False) + ] + + def test_two_entities(self): + assert pset.parse_applicable_entity("IfcBoilerType,IfcWallType") == [ + ApplicableEntity("IfcBoilerType", "IfcBoilerType", None, False), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + + def test_two_entities_with_performance_history(self): + assert pset.parse_applicable_entity("IfcBoilerType[PerformanceHistory],IfcWallType") == [ + ApplicableEntity("IfcBoilerType[PerformanceHistory]", "IfcBoilerType", None, True), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + + def test_two_entities_with_predefined_type(self): + assert pset.parse_applicable_entity("IfcBoilerType/STEAM,IfcWallType") == [ + ApplicableEntity("IfcBoilerType/STEAM", "IfcBoilerType", "STEAM", False), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + + def test_two_entities_with_predefined_type_and_performance_history(self): + assert pset.parse_applicable_entity("IfcBoilerType[PerformanceHistory]/STEAM,IfcWallType") == [ + ApplicableEntity("IfcBoilerType[PerformanceHistory]/STEAM", "IfcBoilerType", "STEAM", True), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + + +class TestConvertApplicableEntitiesToQuery: + def test_run(self): + entities = [ApplicableEntity("IfcBoilerType", "IfcBoilerType", None, False)] + assert pset.convert_applicable_entities_to_query(entities) == "IfcBoilerType" + + def test_two_entities(self): + entities = [ + ApplicableEntity("IfcBoilerType", "IfcBoilerType", None, False), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + assert pset.convert_applicable_entities_to_query(entities) == "IfcBoilerType + IfcWallType" + + def test_two_entities_with_performance_history(self): + entities = [ + ApplicableEntity("IfcBoilerType[PerformanceHistory]", "IfcBoilerType", None, True), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + assert pset.convert_applicable_entities_to_query(entities) == "IfcBoilerType + IfcWallType" + + def test_two_entities_with_predefined_type(self): + entities = [ + ApplicableEntity("IfcBoilerType/STEAM", "IfcBoilerType", "STEAM", False), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + assert ( + pset.convert_applicable_entities_to_query(entities) == 'IfcBoilerType, PredefinedType="STEAM" + IfcWallType' + ) + + def test_two_entities_with_predefined_type_and_performance_history(self): + entities = [ + ApplicableEntity("IfcBoilerType[PerformanceHistory]/STEAM", "IfcBoilerType", "STEAM", True), + ApplicableEntity("IfcWallType", "IfcWallType", None, False), + ] + assert ( + pset.convert_applicable_entities_to_query(entities) == 'IfcBoilerType, PredefinedType="STEAM" + IfcWallType' + ) diff --git a/src/ifcopenshell-python/test/util/test_stationing.py b/src/ifcopenshell-python/test/util/test_stationing.py new file mode 100644 index 0000000000..a72a1fb6c1 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_stationing.py @@ -0,0 +1,46 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# 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 ifcopenshell.util.stationing as sta + + +def test_station_as_string(): + # test with a bunch of "random" station values + s = sta.station_as_string(0.0) + assert s == "0+000.000" + + s = sta.station_as_string(0.0, 2, 2) + assert s == "0+00.00" + + s = sta.station_as_string(0.0, 2) + assert s == "0+00.000" + + s = sta.station_as_string(100.00) + assert s == "0+100.000" + + s = sta.station_as_string(-100.00) + assert s == "-0+100.000" + + s = sta.station_as_string(123456.789, 2, 2) + assert s == "1234+56.79" + + s = sta.station_as_string(-123456.789, 2, 2) + assert s == "-1234+56.79" + + s = sta.station_as_string(123456.789, 3, 4) + assert s == "123+456.7890" diff --git a/src/ifcopenshell-python/test/util/test_system.py b/src/ifcopenshell-python/test/util/test_system.py index 2796c3c3d2..892d9e2fdf 100644 --- a/src/ifcopenshell-python/test/util/test_system.py +++ b/src/ifcopenshell-python/test/util/test_system.py @@ -21,7 +21,23 @@ import test.bootstrap import ifcopenshell.api import ifcopenshell.api.root import ifcopenshell.api.system +import ifcopenshell.util.schema import ifcopenshell.util.system as subject +from typing import get_args + + +class TestValidateGroupTypes: + def test_run(self): + ifcsystem_classes = set() + for schema_name in get_args(ifcopenshell.util.schema.IFC_SCHEMA): + schema = ifcopenshell.schema_by_name(schema_name) + declaration = schema.declaration_by_name("IfcSystem") + declarations = ifcopenshell.util.schema.get_subtypes(declaration) + ifcsystem_classes.update(d.name() for d in declarations) + + used_classes = set(subject.group_types) + unused_classes = ifcsystem_classes - used_classes + assert not unused_classes class TestIsAssignable(test.bootstrap.IFC4): diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index 9e554d175b..b5f1fc537e 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -17,11 +17,16 @@ # along with IfcOpenShell. If not, see . import test.bootstrap +import numpy as np +import ifcopenshell.api.context import ifcopenshell.api.unit import ifcopenshell.api.root import ifcopenshell.api.georeference +import ifcopenshell.api.pset +import ifcopenshell.util.element import ifcopenshell.util.geolocation import ifcopenshell.util.unit as subject +from ifcopenshell.util.shape_builder import ShapeBuilder from math import pi @@ -229,7 +234,7 @@ class TestIsAttrType(test.bootstrap.IFC4): assert not subject.is_attr_type(nominal_value, "IfcLengthMeasure", include_select_types=False) -class TestConvertFileLengthUnits(test.bootstrap.IFC4): +class TestConvertFileLengthUnits(test.bootstrap.IFC2X3): def test_run(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") @@ -237,6 +242,66 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC4): output = subject.convert_file_length_units(self.file, target_units="METER") assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" + def test_attribute_conversion(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") + + builder = ShapeBuilder(self.file) + rectangle = builder.rectangle((100, 100)) + extrusion = builder.extrude(rectangle, 1000) + + # IfcLengthMeasure entities. + product = ifcopenshell.api.root.create_entity(self.file, "IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product, "TestPset") + # Consider weird case when same entity is reused. + length_measure = self.file.create_entity("IfcLengthMeasure", 50.0) + enum_property = self.file.create_entity( + "IfcPropertyEnumeratedValue", + Name="Enum", + # Not entirely sure if there are real life cases when mixed typed entities used in the list + # but just to be safe. + EnumerationValues=[ + length_measure, + self.file.create_entity("IfcLabel", "TEXT"), + self.file.create_entity("IfcLengthMeasure", 250.0), + length_measure, + ], + ) + ifcopenshell.api.pset.edit_pset( + self.file, + pset, + properties={ + "Length": self.file.create_entity("IfcLengthMeasure", 25.0), + "Enum": enum_property, + }, + ) + + ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) + output = subject.convert_file_length_units(self.file, target_units="METER") + assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" + extrusion = output.by_type("IfcExtrudedAreaSolid")[0] + + # Simple float attribute. + assert extrusion.Depth == 1 + + # List of floats in IFC2X3 and list of lists of floats in IFC4+. + rectangle = extrusion.SweptArea.OuterCurve + expected_points = [(0.0, 0.0), (0.1, 0.0), (0.1, 0.1), (0.0, 0.1)] + if self.file.schema == "IFC2X3": + # IfcPolyline. + points = [p.Coordinates for p in rectangle.Points] + expected_points += expected_points[:1] + else: + # IfcIndexedPolyCurve. + points = rectangle.Points.CoordList + assert np.allclose(points, expected_points) + + # IfcLengthMeasure entities. + product = output.by_type("IfcWall")[0] + pset_data = ifcopenshell.util.element.get_pset(product, "TestPset") + assert pset_data["Length"] == 0.025 + assert pset_data["Enum"] == [0.05, "TEXT", 0.25, 0.05] + def test_converting_map_conversion_if_there_is_no_map_unit(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") @@ -246,7 +311,12 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC4): ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) output = subject.convert_file_length_units(self.file, target_units="METER") assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" - assert output.by_type("IfcMapConversion")[0].Eastings == 10 + if self.file.schema == "IFC2X3": + parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output) + assert parameters + assert parameters.e == 10 + else: + assert output.by_type("IfcMapConversion")[0].Eastings == 10 def test_preserving_enh_if_there_is_a_map_unit(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -254,67 +324,67 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC4): meter = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") ifcopenshell.api.context.add_context(self.file, "Model") ifcopenshell.api.georeference.add_georeferencing(self.file) + map_unit = subject.get_full_unit_name(meter) if self.file.schema == "IFC2X3" else meter ifcopenshell.api.georeference.edit_georeferencing( - self.file, projected_crs={"MapUnit": meter}, coordinate_operation={"Eastings": 10, "Scale": 0.001} + self.file, + projected_crs={"MapUnit": map_unit}, + coordinate_operation={"Eastings": 10, "Scale": 0.001}, ) ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) output = subject.convert_file_length_units(self.file, target_units="METER") assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" - assert output.by_type("IfcMapConversion")[0].Eastings == 10 - assert output.by_type("IfcMapConversion")[0].Northings == 0 - assert output.by_type("IfcMapConversion")[0].Scale == 1 - assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE" + if self.file.schema == "IFC2X3": + parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output) + assert parameters + assert parameters.e == 10 + assert parameters.n == 0 + assert parameters.scale == 1 + crs = ifcopenshell.util.element.get_pset(output.by_type("IfcProject")[0], name="ePSet_ProjectedCRS") + assert crs["MapUnit"] == "METRE" + else: + map_conversion = output.by_type("IfcMapConversion")[0] + assert map_conversion.Eastings == 10 + assert map_conversion.Northings == 0 + assert map_conversion.Scale == 1 + assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE" def test_preserving_enh_if_there_is_a_map_unit_which_is_also_the_project_default(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") meter = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") ifcopenshell.api.context.add_context(self.file, "Model") ifcopenshell.api.georeference.add_georeferencing(self.file) + map_unit = subject.get_full_unit_name(meter) if self.file.schema == "IFC2X3" else meter ifcopenshell.api.georeference.edit_georeferencing( - self.file, projected_crs={"MapUnit": meter}, coordinate_operation={"Eastings": 10, "Scale": 1} + self.file, + projected_crs={"MapUnit": map_unit}, + coordinate_operation={"Eastings": 10, "Scale": 1}, ) ifcopenshell.api.unit.assign_unit(self.file, units=[meter]) output = subject.convert_file_length_units(self.file, target_units="MILLIMETER") assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "MILLIMETRE" - assert output.by_type("IfcMapConversion")[0].Eastings == 10 - assert output.by_type("IfcMapConversion")[0].Northings == 0 - assert output.by_type("IfcMapConversion")[0].Scale == 0.001 - assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE" + if self.file.schema == "IFC2X3": + parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output) + assert parameters + assert parameters.e == 10 + assert parameters.n == 0 + assert parameters.scale == 1 + crs = ifcopenshell.util.element.get_pset(output.by_type("IfcProject")[0], name="ePSet_ProjectedCRS") + assert crs["MapUnit"] == "METRE" + else: + map_conversion = output.by_type("IfcMapConversion")[0] + assert map_conversion.Eastings == 10 + assert map_conversion.Northings == 0 + assert map_conversion.Scale == 0.001 + assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE" unit_assignment = subject.get_unit_assignment(output) + assert unit_assignment assert len(unit_assignment.Units) == 1 -class TestConvertFileLengthUnitsIFC2X3(test.bootstrap.IFC2X3): - def test_converting_map_conversion_if_there_is_no_map_unit(self): - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") - ifcopenshell.api.context.add_context(self.file, "Model") - ifcopenshell.api.georeference.add_georeferencing(self.file) - ifcopenshell.api.georeference.edit_georeferencing(self.file, coordinate_operation={"Eastings": 10000}) - ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) - output = subject.convert_file_length_units(self.file, target_units="METER") - assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" - parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output) - assert parameters.e == 10 +class TestConvertFileLengthUnitsIFC4(test.bootstrap.IFC4, TestConvertFileLengthUnits): + pass - def test_preserving_enh_if_there_is_a_map_unit(self): - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") - meter = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") - ifcopenshell.api.context.add_context(self.file, "Model") - ifcopenshell.api.georeference.add_georeferencing(self.file) - ifcopenshell.api.georeference.edit_georeferencing( - self.file, - projected_crs={"MapUnit": subject.get_full_unit_name(meter)}, - coordinate_operation={"Eastings": 10, "Scale": 0.001}, - ) - ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) - output = subject.convert_file_length_units(self.file, target_units="METER") - assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE" - parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output) - assert parameters.e == 10 - assert parameters.n == 0 - assert parameters.scale == 1 - crs = ifcopenshell.util.element.get_pset(output.by_type("IfcProject")[0], name="ePSet_ProjectedCRS") - assert crs["MapUnit"] == "METRE" + +class TestConvertFileLengthUnitsIFC4X3(test.bootstrap.IFC4X3, TestConvertFileLengthUnits): + pass diff --git a/src/ifcparse/IfcAlignmentHelper.cpp b/src/ifcparse/IfcAlignmentHelper.cpp index 8e26375ab5..b5936561a5 100644 --- a/src/ifcparse/IfcAlignmentHelper.cpp +++ b/src/ifcparse/IfcAlignmentHelper.cpp @@ -30,6 +30,7 @@ // @todo use std::numbers::pi when upgrading to C++ 20 static const double PI = boost::math::constants::pi(); +#include #ifdef HAS_SCHEMA_4x3_add2 @@ -207,7 +208,7 @@ Ifc4x3_add2::IfcAlignment* addHorizontalAlignment(IfcHierarchyHelper::ptr alignment_representations(new aggregate_of()); alignment_representations->push(footprint_shape_representation); // 2D footprint @@ -243,21 +244,20 @@ std::tuple::ptr, typenam // back gradient auto dxBG = xPVI - xPBG; auto dyBG = yPVI - yPBG; - auto start_slope = atan2(dyBG, dxBG); + auto start_slope = tan(atan2(dyBG,dxBG)); // forward gradient point_iter++; std::tie(xPFG, yPFG) = *point_iter; auto dxFG = xPFG - xPVI; auto dyFG = yPFG - yPVI; - auto end_slope = atan2(dyFG, dxFG); + auto end_slope = tan(atan2(dyFG,dxFG)); double xEVC = xPVI + length / 2; double yEVC = yPVI + end_slope * length / 2; // create gradient { - file.addDoublet(xPBG, yPBG); auto gradient_length = dxBG - length/2; auto design_parameters = new Ifc4x3_add2::IfcAlignmentVerticalSegment(boost::none, boost::none, xPBG, gradient_length, yPBG, start_slope, start_slope, boost::none, Ifc4x3_add2::IfcAlignmentVerticalSegmentTypeEnum::IfcAlignmentVerticalSegmentType_CONSTANTGRADIENT); auto alignment_segment = new Ifc4x3_add2::IfcAlignmentSegment(IfcParse::IfcGlobalId(), nullptr, boost::none, boost::none, boost::none, nullptr, nullptr, design_parameters); @@ -273,8 +273,6 @@ std::tuple::ptr, typenam double xBVC = xPVI - length / 2; double yBVC = yPVI - start_slope * length / 2; - file.addDoublet(xBVC, yBVC); - auto design_parameters = new Ifc4x3_add2::IfcAlignmentVerticalSegment(boost::none, boost::none, xBVC, length, yBVC, start_slope, end_slope, 1 / k, Ifc4x3_add2::IfcAlignmentVerticalSegmentTypeEnum::IfcAlignmentVerticalSegmentType_PARABOLICARC); auto alignment_segment = new Ifc4x3_add2::IfcAlignmentSegment(IfcParse::IfcGlobalId(), nullptr, boost::none, boost::none, boost::none, nullptr, nullptr, design_parameters); vertical_segments->push(alignment_segment); @@ -292,9 +290,9 @@ std::tuple::ptr, typenam // create last tangent run auto dx = xPVI - xPBG; auto dy = yPVI - yPBG; - auto slope = atan2(dy, dx); - auto gradient_length = sqrt(dx * dx + dy * dy); - file.addDoublet(xPBG, yPBG); + auto slope = tan(atan2(dy,dx)); + auto gradient_length = dx; + auto design_parameters = new Ifc4x3_add2::IfcAlignmentVerticalSegment(boost::none, boost::none, xPBG, gradient_length, yPBG, slope, slope, boost::none, Ifc4x3_add2::IfcAlignmentVerticalSegmentTypeEnum::IfcAlignmentVerticalSegmentType_CONSTANTGRADIENT); auto alignment_segment = new Ifc4x3_add2::IfcAlignmentSegment(IfcParse::IfcGlobalId(), nullptr, boost::none, boost::none, boost::none, nullptr, nullptr, design_parameters); vertical_segments->push(alignment_segment); @@ -303,7 +301,6 @@ std::tuple::ptr, typenam } // create zero length terminator segment - file.addDoublet(xPVI, yPVI); design_parameters = new Ifc4x3_add2::IfcAlignmentVerticalSegment(boost::none, boost::none, xPVI, 0.0, yPVI, slope, slope, boost::none, Ifc4x3_add2::IfcAlignmentVerticalSegmentTypeEnum::IfcAlignmentVerticalSegmentType_CONSTANTGRADIENT); alignment_segment = new Ifc4x3_add2::IfcAlignmentSegment(IfcParse::IfcGlobalId(), nullptr, boost::none, boost::none, boost::none, nullptr, nullptr, design_parameters); vertical_segments->push(alignment_segment); @@ -356,14 +353,14 @@ Ifc4x3_add2::IfcAlignment* addAlignment(IfcHierarchyHelper& file, c typename aggregate_of::ptr alignment_representation_items(new aggregate_of()); alignment_representation_items->push(composite_curve); - // create the footprint representation - auto footprint_shape_representation = new Ifc4x3_add2::IfcShapeRepresentation(axis_model_representation_subcontext, std::string("FootPrint"), std::string("Curve2D"), alignment_representation_items); - file.addEntity(footprint_shape_representation); - // the gradient curve is a representation item typename aggregate_of::ptr profile_representation_items(new aggregate_of()); profile_representation_items->push(gradient_curve); + // create footprint representation + auto footprint_shape_representation = new Ifc4x3_add2::IfcShapeRepresentation(axis_model_representation_subcontext, std::string("FootPrint"), std::string("Curve2D"), alignment_representation_items); + file.addEntity(footprint_shape_representation); + // create the axis representation auto axis3d_shape_representation = new Ifc4x3_add2::IfcShapeRepresentation(axis_model_representation_subcontext, std::string("Axis"), std::string("Curve3D"), profile_representation_items); file.addEntity(axis3d_shape_representation); @@ -373,10 +370,10 @@ Ifc4x3_add2::IfcAlignment* addAlignment(IfcHierarchyHelper& file, c _createSegmentRepresentations(file, placement, axis_model_representation_subcontext, horizontal_curve_segments, horizontal_segments); _createSegmentRepresentations(file, placement, axis_model_representation_subcontext, vertical_curve_segments, vertical_segments); - // the alignment has two representations, a plan view footprint and a 3d curve + // the alignment has a 3d curve representation typename aggregate_of::ptr alignment_representations(new aggregate_of()); - alignment_representations->push(footprint_shape_representation); // 2D footprint - alignment_representations->push(axis3d_shape_representation); // 3D curve + alignment_representations->push(footprint_shape_representation); // 2D curve + alignment_representations->push(axis3d_shape_representation); // 3D curve // create the alignment product definition product_definition_shape = new Ifc4x3_add2::IfcProductDefinitionShape(std::string("Alignment Product Definition Shape"), boost::none, alignment_representations); @@ -688,13 +685,18 @@ std::pair mapAlign new Ifc4x3_add2::IfcCartesianPoint(std::vector({0, 0})), new Ifc4x3_add2::IfcVector(new Ifc4x3_add2::IfcDirection(std::vector{1, 0}), 1.0)); + // IfcCurveSegment.SegmentLength is the length of the curve segment, not the horizontal length. + auto dx = cos(atan(start_gradient)); + auto dy = sin(atan(start_gradient)); + auto segment_curve_length = horizontal_length / dx; + auto curve_segment = new Ifc4x3_add2::IfcCurveSegment( Ifc4x3_add2::IfcTransitionCode::IfcTransitionCode_CONTSAMEGRADIENT, new Ifc4x3_add2::IfcAxis2Placement2D( new Ifc4x3_add2::IfcCartesianPoint({start_distance_along, start_height}), - new Ifc4x3_add2::IfcDirection({sqrt(1.0 - start_gradient * start_gradient), start_gradient})), + new Ifc4x3_add2::IfcDirection({dx,dy})), new Ifc4x3_add2::IfcLengthMeasure(0.0), // start - new Ifc4x3_add2::IfcLengthMeasure(horizontal_length), + new Ifc4x3_add2::IfcLengthMeasure(segment_curve_length), parent_curve); result.first = curve_segment; @@ -710,11 +712,22 @@ std::pair mapAlign std::vector{A, B, C}, boost::none); + // IfcCurveSegment.SegmentLength is the length of the curve segment, not the horizontal length. + // The curve length is calculated by integrating the differential curve length equation sqrt(1 + (dy/dx)^2) from 0 to horizontal_length. + // y = A + Bx + Cx^2 + // dy/dx = B + 2Cx + auto dx = cos(atan(start_gradient)); + auto dy = sin(atan(start_gradient)); + auto curve_length_fn = [B, C](double x) { return sqrt(1 + pow(B + 2*C * x, 2)); }; + auto segment_curve_length = boost::math::quadrature::trapezoidal(curve_length_fn, 0.0, horizontal_length); + auto curve_segment = new Ifc4x3_add2::IfcCurveSegment( Ifc4x3_add2::IfcTransitionCode::IfcTransitionCode_CONTSAMEGRADIENT, - new Ifc4x3_add2::IfcAxis2Placement2D(new Ifc4x3_add2::IfcCartesianPoint({start_distance_along, start_height}), new Ifc4x3_add2::IfcDirection({sqrt(1.0 - start_gradient * start_gradient), start_gradient})), + new Ifc4x3_add2::IfcAxis2Placement2D( + new Ifc4x3_add2::IfcCartesianPoint({start_distance_along, start_height}), + new Ifc4x3_add2::IfcDirection({dx,dy})), new Ifc4x3_add2::IfcLengthMeasure(0.0), - new Ifc4x3_add2::IfcLengthMeasure(horizontal_length), + new Ifc4x3_add2::IfcLengthMeasure(segment_curve_length), parent_curve); result.first = curve_segment; @@ -735,11 +748,14 @@ std::pair mapAlign new Ifc4x3_add2::IfcDirection(std::vector{1, 0})), radius); + + auto segment_curve_length = radius * fabs(end_angle - start_angle); + Ifc4x3_add2::IfcCurveSegment* curve_segment = new Ifc4x3_add2::IfcCurveSegment( Ifc4x3_add2::IfcTransitionCode::IfcTransitionCode_CONTSAMEGRADIENT, new Ifc4x3_add2::IfcAxis2Placement2D(new Ifc4x3_add2::IfcCartesianPoint({start_distance_along, start_height}), new Ifc4x3_add2::IfcDirection({1.0, 0.0})), new Ifc4x3_add2::IfcLengthMeasure(0.0), - new Ifc4x3_add2::IfcLengthMeasure(horizontal_length), + new Ifc4x3_add2::IfcLengthMeasure(segment_curve_length), parent_curve); result.first = curve_segment; diff --git a/src/ifcparse/IfcFile.cpp b/src/ifcparse/IfcFile.cpp index f0e6b328d6..291217a302 100644 --- a/src/ifcparse/IfcFile.cpp +++ b/src/ifcparse/IfcFile.cpp @@ -46,19 +46,14 @@ namespace { template constexpr bool is_type_in_variant_v = is_type_in_variant::value; - struct InstanceReference { - int v; - operator int() const { - return v; - } - }; - template void dispatch_token(int instance_id, int attribute_id, IfcParse::Token t, IfcParse::declaration* decl, Fn fn) { if (t.type == IfcParse::Token_BINARY) { fn(IfcParse::TokenFunc::asBinary(t)); - } else if (t.type == IfcParse::Token_BOOL) { + } else if (IfcParse::TokenFunc::isBool(t)) { fn(IfcParse::TokenFunc::asBool(t)); + } else if (IfcParse::TokenFunc::isLogical(t)) { + fn(IfcParse::TokenFunc::asLogical(t)); } else if (t.type == IfcParse::Token_ENUMERATION) { auto& s = IfcParse::TokenFunc::asStringRef(t); if (decl && decl->as_enumeration_type()) { @@ -73,7 +68,7 @@ namespace { } else if (t.type == IfcParse::Token_FLOAT) { fn(IfcParse::TokenFunc::asFloat(t)); } else if (t.type == IfcParse::Token_IDENTIFIER) { - fn(IfcParse::reference_or_simple_type{ InstanceReference{ IfcParse::TokenFunc::asIdentifier(t) } }); + fn(IfcParse::reference_or_simple_type{ IfcParse::InstanceReference{ IfcParse::TokenFunc::asIdentifier(t), t.startPos } }); } else if (t.type == IfcParse::Token_INT) { fn(IfcParse::TokenFunc::asInt(t)); } else if (t.type == IfcParse::Token_STRING) { diff --git a/src/ifcparse/IfcFile.h b/src/ifcparse/IfcFile.h index 1a03e78784..b9684ac363 100644 --- a/src/ifcparse/IfcFile.h +++ b/src/ifcparse/IfcFile.h @@ -106,7 +106,15 @@ class IFC_PARSE_API file_open_status { } }; -typedef boost::variant reference_or_simple_type; +struct InstanceReference { + int v; + size_t file_offset; + operator int() const { + return v; + } +}; + +typedef boost::variant reference_or_simple_type; typedef std::list, std::vector>>>> unresolved_references; struct parse_context { diff --git a/src/ifcparse/IfcHierarchyHelper.h b/src/ifcparse/IfcHierarchyHelper.h index 4393cb9089..fefc296249 100644 --- a/src/ifcparse/IfcHierarchyHelper.h +++ b/src/ifcparse/IfcHierarchyHelper.h @@ -404,47 +404,76 @@ class IFC_PARSE_API IfcHierarchyHelper : public IfcParse::IfcFile { template void addRelatedObject(typename Schema::IfcObjectDefinition* relating_object, typename Schema::IfcObjectDefinition* related_object, - typename Schema::IfcOwnerHistory* owner_hist = 0) { - typename T::list::ptr li = instances_by_type(); - bool found = false; - for (typename T::list::it i = li->begin(); i != li->end(); ++i) { - T* rel = *i; - try { - if (get_parent_of_relation(rel) == relating_object) { - aggregate_of_instance::ptr products = get_children_of_relation(rel); - products->push(related_object); - set_children_of_relation(rel, products); + typename Schema::IfcOwnerHistory* owner_hist = 0) + { + if constexpr (std::is_same_v) { + typename Schema::IfcRelDefinesByType::list::ptr li = instances_by_type(); + bool found = false; + for (typename Schema::IfcRelDefinesByType::list::it i = li->begin(); i != li->end(); ++i) { + typename Schema::IfcRelDefinesByType* rel = *i; + if (rel->RelatingType() == relating_object) { + typename Schema::IfcObject::list::ptr objects = rel->RelatedObjects(); + objects->push(addEntity(related_object)->template as()); + rel->setRelatedObjects(objects); found = true; break; } - } catch (std::exception& e) { - Logger::Error(e); - } catch (...) { - Logger::Error("Unknown error in addRelatedObject()"); - } - } - if (!found) { - if (!owner_hist) { - owner_hist = getSingle(); - } - if (!owner_hist) { - owner_hist = addOwnerHistory(); } + if (!found) { + if (!owner_hist) { + owner_hist = getSingle(); + } + if (!owner_hist) { + owner_hist = addOwnerHistory(); + } + typename Schema::IfcObject::list::ptr related_objects(new aggregate_of()); + related_objects->push(related_object->template as()); + typename Schema::IfcRelDefinesByType* t = new typename Schema::IfcRelDefinesByType(IfcParse::IfcGlobalId(), owner_hist, boost::none, boost::none, related_objects, relating_object->template as()); - aggregate_of_instance::ptr related_objects(new aggregate_of_instance); - related_objects->push(related_object); - - T* t = create(&T::Class())->as(); - t->set_attribute_value(0, (std::string)IfcParse::IfcGlobalId()); - t->set_attribute_value(1, owner_hist); - int relating_index = 4; - int related_index = 5; - if (T::Class().name() == "IfcRelContainedInSpatialStructure" || std::is_base_of::value) { - // some classes have attributes reversed. - std::swap(relating_index, related_index); + addEntity(t); + } + } else { + typename T::list::ptr li = instances_by_type(); + bool found = false; + for (typename T::list::it i = li->begin(); i != li->end(); ++i) { + T* rel = *i; + try { + if (get_parent_of_relation(rel) == relating_object) { + aggregate_of_instance::ptr products = get_children_of_relation(rel); + products->push(addEntity(related_object)); + set_children_of_relation(rel, products); + found = true; + break; + } + } catch (std::exception& e) { + Logger::Error(e); + } catch (...) { + Logger::Error("Unknown error in addRelatedObject()"); + } + } + if (!found) { + if (!owner_hist) { + owner_hist = getSingle(); + } + if (!owner_hist) { + owner_hist = addOwnerHistory(); + } + + aggregate_of_instance::ptr related_objects(new aggregate_of_instance); + related_objects->push(related_object); + + T* t = create(&T::Class())->as(); + t->set_attribute_value(0, (std::string)IfcParse::IfcGlobalId()); + t->set_attribute_value(1, owner_hist); + int relating_index = 4; + int related_index = 5; + if (T::Class().name() == "IfcRelContainedInSpatialStructure" || std::is_base_of::value) { + // some classes have attributes reversed. + std::swap(relating_index, related_index); + } + t->set_attribute_value(relating_index, relating_object); + t->set_attribute_value(related_index, related_objects); } - t->set_attribute_value(relating_index, relating_object); - t->set_attribute_value(related_index, related_objects); } } @@ -586,70 +615,4 @@ IFC_PARSE_API void setSurfaceColour(IfcHierarchyHelper& file, Ifc4x IFC_PARSE_API void setSurfaceColour(IfcHierarchyHelper& file, Ifc4x3_add1::IfcRepresentation* shape, Ifc4x3_add1::IfcPresentationStyle* style); #endif -/* -template <> -inline void IfcHierarchyHelper::addRelatedObject (typename Schema::IfcObjectDefinition* relating_structure, - typename Schema::IfcObjectDefinition* related_object, typename Schema::IfcOwnerHistory* owner_hist) -{ - typename Schema::IfcRelContainedInSpatialStructure::list::ptr li = instances_by_type(); - bool found = false; - for (typename Schema::IfcRelContainedInSpatialStructure::list::it i = li->begin(); i != li->end(); ++i) { - typename Schema::IfcRelContainedInSpatialStructure* rel = *i; - if (rel->RelatingStructure() == relating_structure) { - typename Schema::IfcProduct::list::ptr products = rel->RelatedElements(); - products->push((typename Schema::IfcProduct*)related_object); - rel->setRelatedElements(products); - found = true; - break; - } - } - if (! found) { - if (! owner_hist) { - owner_hist = getSingle(); - } - if (! owner_hist) { - owner_hist = addOwnerHistory(); - } - typename Schema::IfcProduct::list::ptr related_objects (new aggregate_of()); - related_objects->push((typename Schema::IfcProduct*)related_object); - typename Schema::IfcRelContainedInSpatialStructure* t = new typename Schema::IfcRelContainedInSpatialStructure(IfcParse::IfcGlobalId(), owner_hist, - boost::none, boost::none, related_objects, (typename Schema::IfcSpatialStructureElement*)relating_structure); - - addEntity(t); - } -} - -template <> -inline void IfcHierarchyHelper::addRelatedObject (typename Schema::IfcObjectDefinition* relating_type, - typename Schema::IfcObjectDefinition* related_object, typename Schema::IfcOwnerHistory* owner_hist) -{ - typename Schema::IfcRelDefinesByType::list::ptr li = instances_by_type(); - bool found = false; - for (typename Schema::IfcRelDefinesByType::list::it i = li->begin(); i != li->end(); ++i) { - typename Schema::IfcRelDefinesByType* rel = *i; - if (rel->RelatingType() == relating_type) { - typename Schema::IfcObject::list::ptr objects = rel->RelatedObjects(); - objects->push((typename Schema::IfcObject*)related_object); - rel->setRelatedObjects(objects); - found = true; - break; - } - } - if (! found) { - if (! owner_hist) { - owner_hist = getSingle(); - } - if (! owner_hist) { - owner_hist = addOwnerHistory(); - } - typename Schema::IfcObject::list::ptr related_objects (new aggregate_of()); - related_objects->push((typename Schema::IfcObject*)related_object); - typename Schema::IfcRelDefinesByType* t = new typename Schema::IfcRelDefinesByType(IfcParse::IfcGlobalId(), owner_hist, - boost::none, boost::none, related_objects, (typename Schema::IfcTypeObject*)relating_type); - - addEntity(t); - } -} -*/ - #endif diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index b8242d5caf..a0ef386fc5 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -1226,6 +1226,23 @@ class apply_individual_instance_visitor { template void IfcUtil::IfcBaseClass::set_attribute_value(size_t i, const T& t) { + if constexpr (std::is_same_v, double>) { + if (!std::isfinite(t)) { + throw IfcParse::IfcException("Only finite values are allowed"); + } + } + if constexpr (std::is_same_v, std::vector>) { + if (std::any_of(t.begin(), t.end(), [](double d) { return !std::isfinite(d); })) { + throw IfcParse::IfcException("Only finite values are allowed"); + } + } + if constexpr (std::is_same_v, std::vector>>) { + for (auto& tt : t) { + if (std::any_of(tt.begin(), tt.end(), [](double d) { return !std::isfinite(d); })) { + throw IfcParse::IfcException("Only finite values are allowed"); + } + } + } auto current_attribute = get_attribute_value(i); if (file_ != nullptr) { @@ -1567,10 +1584,10 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::IfcSpfSt const auto& ref = p.first.name_; const auto& refattr = p.first.index_; if (auto* v = boost::get(&p.second)) { - if (auto* name = boost::get(v)) { + if (auto* name = boost::get(v)) { auto it = byid_.find(*name); if (it == byid_.end()) { - Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found"); + Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found at offset " + std::to_string(name->file_offset)); } else { byid_[p.first.name_]->data().set_attribute_value(nullptr, nullptr, 0, p.first.index_, it->second); } @@ -1581,10 +1598,10 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::IfcSpfSt aggregate_of_instance::ptr instances(new aggregate_of_instance); instances->reserve(v->size()); for (const auto& vi : *v) { - if (auto* name = boost::get(&vi)) { + if (auto* name = boost::get(&vi)) { auto it = byid_.find(*name); if (it == byid_.end()) { - Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found"); + Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found at offset " + std::to_string(name->file_offset)); } else { instances->push(it->second); } @@ -1598,10 +1615,10 @@ void IfcParse::impl::in_memory_file_storage::read_from_stream(IfcParse::IfcSpfSt for (const auto& vi : *v) { std::vector inner; for (const auto& vii : vi) { - if (auto* name = boost::get(&vii)) { + if (auto* name = boost::get(&vii)) { auto it = byid_.find(*name); if (it == byid_.end()) { - Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found"); + Logger::Error("Instance reference #" + std::to_string(*name) + " used by instance #" + std::to_string(ref) + " at attribute index " + std::to_string(refattr) + " not found at offset " + std::to_string(name->file_offset)); } else { inner.push_back(it->second); } diff --git a/src/ifcpatch/ifcpatch/__init__.py b/src/ifcpatch/ifcpatch/__init__.py index e949379a03..3fb6573b2b 100644 --- a/src/ifcpatch/ifcpatch/__init__.py +++ b/src/ifcpatch/ifcpatch/__init__.py @@ -28,6 +28,7 @@ import inspect import collections import importlib import importlib.util +import re from typing import Union, Iterable, Optional, Any, TypedDict, Literal, Sequence from typing_extensions import NotRequired @@ -132,8 +133,8 @@ def write(output: Union[ifcopenshell.file, str], filepath: str) -> None: def extract_docs( - submodule_name: str, cls_name: str, method_name: str = "__init__", boilerplate_args: Optional[Iterable[str]] = None -) -> Union[dict[str, Any], None]: + submodule_name: str, cls_name: str, method_name: str = "__init__", boilerplate_args: Optional[Sequence[str]] = None +) -> Union["PatcherDoc", None]: """Extract class docstrings and method arguments :param submodule_name: Submodule from which to extract the class @@ -155,10 +156,26 @@ def extract_docs( print(f"Error : IFCPatch {str(submodule)} could not load because : {str(e)}") -def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[str], None]) -> dict[str, Any]: - inputs = collections.OrderedDict() +class PatcherDoc(TypedDict): + class_: type + description: str + output: Union[str, None] + inputs: dict[str, "InputDoc"] + + +class InputDoc(TypedDict): + name: str + description: str + type: Union[str, list[str]] + default: NotRequired[Any] + generic_type: NotRequired[str] + enum_items: NotRequired[list[str]] + filter_glob: NotRequired[str] + + +def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Sequence[str], None]) -> PatcherDoc: + inputs: dict[str, InputDoc] = {} method = getattr(cls, method_name) - docs: dict[str, Any] = {"class": cls} if boilerplate_args is None: boilerplate_args = [] @@ -166,9 +183,10 @@ def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[ for name, parameter in signature.parameters.items(): if name == "self" or name in boilerplate_args: continue - inputs[name] = {"name": name} + input_doc: InputDoc = {"name": name} + inputs[name] = input_doc if isinstance(parameter.default, (str, float, int, bool)): - inputs[name]["default"] = parameter.default + input_doc["default"] = parameter.default # Parse data from type hints. type_hints = typing.get_type_hints(method) @@ -193,28 +211,122 @@ def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[ # Parse the docstring. description = "" - doc = method.__doc__ - if doc is not None: - for i, line in enumerate(doc.split("\n")): - line = line.strip() - if i == 0: - docs["name"] = line - elif line.startswith(":return:"): - docs["output"] = {"name": line.split(":")[2].strip(), "description": line.split(":")[3].strip()} - elif line.startswith(":param"): - param_name = line.split(":")[1].strip().replace("param ", "") - if param_name in inputs: - inputs[param_name]["description"] = line.split(":")[2].strip() - # :filter_glob is our special doc-tag. - elif line.startswith(":filter_glob"): - param_name = line.split(":")[1].strip().replace("filter_glob ", "") - if param_name in inputs: - inputs[param_name]["filter_glob"] = line.split(":")[2].strip() - elif i == 2: - description += line - elif i > 2: - description += "\n" + line + # `getdoc` instead of `__doc__` for sane indentation. + doc = inspect.getdoc(method) - docs["description"] = description.strip() - docs["inputs"] = inputs + def is_valid_param_name(param_name: str) -> bool: + assert ( + param_name in inputs + ), f"Unexpected param name '{param_name}' in {cls.__name__} docstring (missing from signature)." + return True + + def is_valid_filter_glob(filter_glob: str) -> bool: + # e.g. '*.ifc;*.ifczip;*.ifcxml' + if len(filter_glob) < 3: + return False + for pattern in filter_glob.split(";"): + if not re.fullmatch(r"\*\.\w+", pattern): + return False + return True + + if doc is None: + doc_description = "" + doc_output = None + else: + docstring_data = parse_docstring(doc) + doc_description = docstring_data["description"] + doc_output = docstring_data["output"] + + for param_name in docstring_data["param"]: + if not is_valid_param_name(param_name): + continue + inputs[param_name]["description"] = docstring_data["param"][param_name] + + for param_name in docstring_data["filter_glob"]: + if not is_valid_param_name(param_name): + continue + filter_glob = docstring_data["filter_glob"][param_name] + assert is_valid_filter_glob(filter_glob), f"Invalid filter_glob pattern: '{filter_glob}'." + inputs[param_name]["filter_glob"] = filter_glob + + for param_name in inputs: + if "description" not in inputs[param_name]: + inputs[param_name]["description"] = "Undocumented" + + docs = PatcherDoc( + class_=cls, + description=doc_description, + output=doc_output, + inputs=inputs, + ) return docs + + +class DocstringData(TypedDict): + name: str + description: str + param: dict[str, str] + filter_glob: dict[str, str] + output: Union[str, None] + + +def parse_docstring(docstring: str) -> DocstringData: + # Keep left indentation to recognize the sections. + lines = docstring.split("\n") + result = DocstringData( + name=lines[0].strip(), + description="", + param={}, + filter_glob={}, + output=None, + ) + + current_section = None + last_param = None + + PREFIXES = ("param", "filter_glob") + + for line in lines[1:]: + if line.startswith(":"): + line = line[1:] + if line.startswith(PREFIXES): + prefix = line.split(" ")[0] + current_section = prefix + match_ = re.match(rf"{prefix}\s+(\w+):\s+(.*)", line) + assert match_, f"Invalid line: '{line}'." + param_name, param_desc = match_.groups() + result[prefix][param_name] = param_desc + last_param = param_name + continue + elif line.startswith("return:"): + current_section = "output" + match_ = re.match(r"return:\s+(.*)", line) + assert match_ + result["output"] = match_.groups()[0] + continue + elif line.startswith("type"): + # Ignore types in favor of signature annotations. + continue + elif line.startswith("Example:"): + # Ignore code example at the end of the docstring. + break + + # Multiline sections start with indentation. + if line.startswith(" ") and current_section: + line = line.lstrip() + if current_section == "output": + assert result["output"] + result["output"] += f"\n{line}" + elif current_section in PREFIXES: + assert last_param + result[current_section][last_param] += f"\n{line}" + continue + + line = line.lstrip() + result["description"] += f"\n{line}" + current_section = None + last_param = None + + result["description"] = result["description"].strip() + + return result diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index e49f1b0af4..0218d68a30 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -55,7 +55,7 @@ class Patcher: def patch(self): self.contained_ins: dict[str, set[ifcopenshell.entity_instance]] = {} self.aggregates: dict[str, set[ifcopenshell.entity_instance]] = {} - self.new = ifcopenshell.file(schema=self.file.wrapped_data.schema) + self.new = ifcopenshell.file(schema_version=self.file.schema_version) self.owner_history = None self.reuse_identities: dict[int, ifcopenshell.entity_instance] = {} for owner_history in self.file.by_type("IfcOwnerHistory"): diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index a025709674..be7fac9d71 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -71,7 +71,6 @@ class Patcher: import bonsai.tool as tool import ifcopenshell import ifcopenshell.util.element - from bonsai.bim.ifc import IfcStore from mathutils import Vector, Matrix if len(bpy.data.objects) > 0: @@ -114,8 +113,8 @@ class Patcher: bpy.ops.bim.update_representation( ifc_representation_class="IfcExtrudedAreaSolid/IfcArbitraryProfileDefWithVoids" ) - for context in IfcStore.get_file().by_type("IfcGeometricRepresentationContext", include_subtypes=False): + for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False): if context.Precision: context.Precision = 10 - self.file = IfcStore.get_file() + self.file = tool.Ifc.get() diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py new file mode 100644 index 0000000000..f6f375ea23 --- /dev/null +++ b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py @@ -0,0 +1,540 @@ +# IfcPatch - IFC patching utiliy +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcPatch. +# +# IfcPatch 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. +# +# IfcPatch 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 IfcPatch. If not, see . + + +import ifcopenshell +import logging +from typing import Optional + + +class Patcher: + def __init__( + self, file: None, logger: logging.Logger, filepath: str, is_solid: bool = True, should_create_edges: bool = True + ): + """Fix missing or spot-coordinate bugged TINs loading in Revit + + TINs exported from 12D or Civil 3D may contain dense or highly obtuse + triangles. Although these will load in Revit, you will not be able to + use Revit's Spot Coordinate or Spot Elevation tool. + + See bug: https://github.com/Autodesk/revit-ifc/issues/511 + + If `is_solid` is enabled, we assume the surface is represented as a + solid (e.g. I have come across surfaces which are extruded by 1mm from + Civil 3D). The solution will delete any faces with a Z normal less + than 0.5. In case the mesh has any side faces or thickness, this should + leave only the top surface which is relevant for spot coordinates and + elevations. + + Vertices closer than 10mm will also be merged to prevent dense + portions of the TIN at a minor sacrifice of surveying accuracy. It will + also triangulate all meshes to prevent non-coplanar surfaces, and + delete any obtuse triangles where one of their XY angles is less than + 0.3 degrees. Therefore the result will contain some minor "holes" in + the TIN, but these holes will only be in dense triangles that Revit + can't handle anyway and won't affect most coordination tasks. + + After that, it will: + + 1. Reassign everything to an IfcGeographicElement + 2. Detect boundary edges and create an edge-only IfcVirtualElement. + Good for clean viz in Revit. + 3. Create a copy of the object which has no sharp faces. This will + allow Revit's spot coordinate tool to work on any arbitrary face + surface. Note that Revit cannot snap to edges or vertices on this + object. + 4. Create a copy of the obejct which has one artifically injected sharp + face. This trick allows Revit's spot coordinate tool to snap to + edges and points. However, Revit cannot sample an arbitrary + surface. By combining this object with the previous object, you get + the best of both worlds. + + If you're thinking that this overlapping, Z-fighting, duplication of + objects with arbitrary almost-degenerate triangles being added is a + horrific abomination in the world of software workarounds, you are + absolutely correct. + + This is a variation of FixRevitTINs which has been tested on Revit <= + 2023. I've tested this one on Revit 2025 (the behaviour has changed). + + Note that you may may want to run other tools like + OffsetObjectPlacements or ResetAbsoluteCoordinates to fix large + coordinates as these can also cause issues in Revit (such as inaccuracy + or inability to use the Spot Coordinate / Elevation tool). + + This patch is designed to work on any TIN-like export, typically coming + from civil software. It also requires you to run it using Blender, as + the geometric modification uses the Blender geometry engine. + + `filepath` argument is required for this recipe, `file` argument is + ignored. + + :param filepath: The filepath of the IFC model. This is required to + load into Bonsai. + :param is_solid: If true, assume a thickness and delete anything that + isn't the top face. + :param should_create_edges: If true, a new IfcVirtualElement is created + representing the perimeter of the objects. This allows you to to + hide regular surface edges in Revit and only use the perimeter edge + for visualisation. + :filter_glob filepath: *.ifc;*.ifczip;*.ifcxml + + Example: + + .. code:: python + + ifcpatch.execute({"input": "input.ifc", "recipe": "FixRevit2025TINs", "arguments": []}) + """ + self.file = file + self.filepath = filepath + self.logger = logger + self.is_solid = is_solid + self.should_create_edges = should_create_edges + + def patch(self) -> None: + import bpy + import bmesh + import bonsai.tool as tool + import ifcopenshell.util.shape_builder + + bpy.context.scene.BIMProjectProperties.should_use_native_meshes = True + bpy.ops.bim.load_project(filepath=self.filepath) + + old_history_size = tool.Ifc.get().history_size + old_undo_steps = bpy.context.preferences.edit.undo_steps + tool.Ifc.get().history_size = 0 + bpy.context.preferences.edit.undo_steps = 0 + + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + for obj in bpy.data.objects: + if not obj.BIMObjectProperties.ifc_definition_id or not obj.data: + continue + if not obj.data.polygons: + continue + element = tool.Ifc.get_entity(obj) + element.PredefinedType = "USERDEFINED" + element.ObjectType = "TIN" + element = ifcopenshell.util.schema.reassign_class(tool.Ifc.get(), element, "IfcGeographicElement") + + bm = bmesh.new() + bm.from_mesh(obj.data) + faces_to_delete = [] + if self.is_solid: + for face in bm.faces: + global_normal = obj.matrix_world.to_3x3() @ face.normal + if global_normal.z < 0.5: + faces_to_delete.append(face) + bmesh.ops.delete(bm, geom=faces_to_delete, context="FACES_ONLY") + bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY", ngon_method="BEAUTY") + bm.to_mesh(obj.data) + bm.free() + obj.data.update() + + if self.should_create_edges: + self.create_edges(obj) + + self.create_face_sampleable_object(obj) + self.create_edge_sampleable_object(obj) + + tool.Ifc.get().history_size = old_history_size + bpy.context.preferences.edit.undo_steps = old_undo_steps + + self.file = tool.Ifc.get() + + def create_edges(self, obj): + import bpy + import bmesh + import bonsai.tool as tool + import ifcopenshell.util.element + import ifcopenshell.util.representation + import ifcopenshell.api.root + import ifcopenshell.api.type + import ifcopenshell.api.spatial + import ifcopenshell.api.geometry + + element = tool.Ifc.get_entity(obj) + data = obj.data + bm = bmesh.new() + bm.from_mesh(data) + + bm.faces.ensure_lookup_table() + + if self.is_solid: + faces_to_delete = [] + for face in bm.faces: + if face.normal.z < 0.5: + faces_to_delete.append(face) + bmesh.ops.delete(bm, geom=faces_to_delete, context="FACES_ONLY") + + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.01) + + edges_to_delete = [] + bm.faces.ensure_lookup_table() + for edge in bm.edges: + if len(edge.link_faces) != 1: + edges_to_delete.append(edge) + + bmesh.ops.delete(bm, geom=edges_to_delete, context="EDGES_FACES") + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) + + bm.verts.ensure_lookup_table() + for vert in bm.verts: + vert.co.z += 0.003 + + mesh = bpy.data.meshes.new("Mesh") + bm.to_mesh(mesh) + + obj = bpy.data.objects.new("Perimeter", mesh) + bpy.context.scene.collection.objects.link(obj) + with bpy.context.temp_override(**tool.Blender.get_viewport_context()): + tool.Blender.select_and_activate_single_object(bpy.context, obj) + bpy.ops.object.convert(target="CURVE") + + context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + + if self.file.schema == "IFC2X3": + curves = self.create_curves_from_curve_ifc2x3(is_2d=False, curve_object_data=obj.data) + else: + curves = self.create_curves_from_curve(is_2d=False, curve_object_data=obj.data) + + representation = tool.Ifc.get().createIfcShapeRepresentation( + context, context.ContextIdentifier, "Curve3D", curves + ) + + element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element) + element2.Name += "-boundary" + element2.ObjectType = "TINBOUNDARY" + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation) + ifcopenshell.util.schema.reassign_class(tool.Ifc.get(), element2, "IfcVirtualElement") + bm.free() + + def create_face_sampleable_object(self, obj): + # No sharp faces + import bpy + import bmesh + import bonsai.tool as tool + import ifcopenshell.util.element + import ifcopenshell.util.representation + import ifcopenshell.api.root + import ifcopenshell.api.type + import ifcopenshell.api.spatial + import ifcopenshell.api.geometry + from math import degrees + + print("working on ", obj.name) + element = tool.Ifc.get_entity(obj) + data = obj.data + bm = bmesh.new() + bm.from_mesh(data) + + bm.faces.ensure_lookup_table() + + angle_threshold = 0.3 + for polygon in bm.faces: + try: + v1, v2, v3 = [v.co for v in polygon.verts] + d1 = degrees((v2 - v1).angle(v3 - v1)) + d2 = degrees((v3 - v2).angle(v1 - v2)) + d3 = degrees((v1 - v3).angle(v2 - v3)) + if d1 < angle_threshold or d2 < angle_threshold or d3 < angle_threshold: + print("removing", d1, d2, d3, polygon) + bm.faces.remove(polygon) + except: + print("removing", polygon) + bm.faces.remove(polygon) + + context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + verts = [v.co / self.unit_scale for v in bm.verts] + faces = [[v.index for v in p.verts] for p in bm.faces] + item = builder.mesh(verts, faces) + representation = builder.get_representation(context, [item]) + element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element) + element2.Name += "-face-sample" + print("new", element2, element) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation) + + bm.free() + + def create_edge_sampleable_object(self, obj): + # This is crazy but we need a sharp face per island + import bpy + import bmesh + import bonsai.tool as tool + import ifcopenshell.util.element + import ifcopenshell.util.representation + import ifcopenshell.api.root + import ifcopenshell.api.type + import ifcopenshell.api.spatial + import ifcopenshell.api.geometry + from math import degrees, radians, sin + from mathutils import Matrix + + # Get the active object (assumed to have a mesh) + mesh = obj.data + + # Create a BMesh representation + bm = bmesh.new() + bm.from_mesh(mesh) + + # First purge existing sharp edges (don't ask me why, really don't) + bm.faces.ensure_lookup_table() + angle_threshold = 0.3 + for polygon in bm.faces: + try: + # v1, v2, v3 = [v.co.to_2d() for v in polygon.verts] + v1, v2, v3 = [v.co for v in polygon.verts] + d1 = degrees((v2 - v1).angle(v3 - v1)) + d2 = degrees((v3 - v2).angle(v1 - v2)) + d3 = degrees((v1 - v3).angle(v2 - v3)) + if d1 < angle_threshold or d2 < angle_threshold or d3 < angle_threshold: + print("removing", d1, d2, d3, polygon) + bm.faces.remove(polygon) + except: + print("removing", polygon) + bm.faces.remove(polygon) + bm.faces.ensure_lookup_table() + + # Now we add our own. + + # A set to mark faces that have been visited + visited_faces = set() + + def get_island(start_face): + """Return the connected set of faces (a 'mesh island') starting from start_face.""" + island = set() + stack = [start_face] + while stack: + f = stack.pop() + if f in island: + continue + island.add(f) + for edge in f.edges: + # For every face sharing this edge, add to the stack + for f2 in edge.link_faces: + if f2 not in island: + stack.append(f2) + return island + + # Loop over all faces and process each island once + islands_count = 0 + for face in bm.faces: + if face in visited_faces: + continue + + # Get the connected component (island) containing this face + island = get_island(face) + visited_faces |= island # mark all island faces as visited + islands_count += 1 + + boundary_edge = None + for face in island: + for edge in face.edges: + # Count how many faces in the island use this edge. + count = sum(1 for f in edge.link_faces if f in island) + if count == 1: + boundary_edge = edge + break + if boundary_edge: + break + + # If no boundary edge is found, skip this island. + if boundary_edge is None: + continue + + # Use the two vertices of the boundary edge as A and B. + A, B = boundary_edge.verts[0], boundary_edge.verts[1] + + # THIRD ATTEMPT + + # Use the normal from the boundary face (i.e. the single linked face of the boundary edge) + base_face = boundary_edge.link_faces[0] + plane_normal = ( + base_face.normal.copy() + ) # This normal defines the plane in which we'll construct the triangle + + # Compute the edge AB vector and its length. + AB_vec = B.co - A.co + d = AB_vec.length + if d == 0: + continue # degenerate edge + + # --- Desired angles for the new triangle (in degrees) --- + # This is the crazy degenerate triangle + angle_A_deg = 0.004 # angle at vertex A + angle_B_deg = 1.146 # angle at vertex B + angle_C_deg = 178.85 # angle at vertex C (note: 180 - (0.004 + 1.146) = 178.85) + + # Convert angles to radians. + angle_A = radians(angle_A_deg) + angle_B = radians(angle_B_deg) + angle_C = radians(angle_C_deg) + + # --- Use the law of sines to compute the new triangle's side lengths --- + # In triangle ABC, with AB opposite angle C: + # AB / sin(angle_C) = AC / sin(angle_B) = BC / sin(angle_A) + # We'll compute AC (from A) as it is needed to place the new vertex. + AC_length = d * sin(angle_B) / sin(angle_C) + + # --- Determine the direction for AC in the triangle's plane --- + # Starting at A, the direction of AB is our baseline. + u = AB_vec.normalized() + + # To get the direction for AC, rotate u by angle_A about the plane_normal. + rot_mat = Matrix.Rotation(angle_A, 3, plane_normal) + dA = u.copy() + dA.rotate(rot_mat) + + # Compute the position for the new vertex C. + C_co = A.co + AC_length * dA + + # --- Create the new vertex and triangle face in the BMesh --- + new_vert = bm.verts.new(C_co) + bm.verts.index_update() # update indices if needed + + # Create the new triangle face from vertices A, B, and new_vert. + # (The order of vertices may be adjusted if you need a specific winding.) + try: + new_face = bm.faces.new((A, B, new_vert)) + visited_faces.add(new_face) + print("added new face") + except ValueError: + # Face already exists or some error occurred + print("Could not create face on island", islands_count) + + context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + verts = [v.co / self.unit_scale for v in bm.verts] + faces = [[v.index for v in p.verts] for p in bm.faces] + item = builder.mesh(verts, faces) + representation = builder.get_representation(context, [item]) + element = tool.Ifc.get_entity(obj) + element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element) + element2.Name += "-edge-sample" + print("new", element2, element) + ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation) + + bm.free() + + print("Added a triangle to", islands_count, "mesh island(s).") + return + + def create_curves_from_curve_ifc2x3( + self, is_2d: bool = False, curve_object_data=None + ) -> list[ifcopenshell.entity_instance]: + import bonsai.tool as tool + + dim = (lambda v: v.xy) if is_2d else (lambda v: v.xyz) + results = [] + for spline in curve_object_data.splines: + points = spline.bezier_points[:] + spline.points[:] + if spline.use_cyclic_u: + points.append(points[0]) + ifc_points = [self.create_cartesian_point(*dim(point.co)) for point in points] + results.append(tool.Ifc.get().createIfcPolyline(ifc_points)) + return results + + def create_curves_from_curve( + self, is_2d: bool = False, curve_object_data=None + ) -> list[ifcopenshell.entity_instance]: + import bonsai.tool as tool + import numpy as np + + dim = (lambda v: v.xy) if is_2d else (lambda v: v.xyz) + to_units = lambda v: np.array([self.convert_si_to_unit(i) for i in v]) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + results = [] + + for spline in curve_object_data.splines: + points = spline.bezier_points[:] + spline.points[:] + + points = [to_units(dim(p.co)) for p in points] + closed_polyline = spline.use_cyclic_u and len(points) > 1 + results.append(builder.polyline(points, closed=closed_polyline)) + + return results + + def create_cartesian_point( + self, x: float, y: float, z: Optional[float] = None, is_model_coords: bool = True + ) -> ifcopenshell.entity_instance: + """Create IfcCartesianPoint. + + x, y, z coords are provided in SI units. + """ + x = self.convert_si_to_unit(x) + y = self.convert_si_to_unit(y) + z = self.convert_si_to_unit(z) + return self.file.createIfcCartesianPoint((x, y, z)) + + def create_curves_from_mesh(self, geom_data) -> list[ifcopenshell.entity_instance]: + curves = [] + points = self.create_cartesian_point_list_from_vertices(geom_data.vertices) + edge_loops = [] + previous_edge = None + edge_loop = [] + for i, edge in enumerate(geom_data.edges): + if previous_edge is None: + edge_loop = [self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1))] + elif edge.vertices[0] == previous_edge.vertices[1]: + edge_loop.append(self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1))) + else: + edge_loops.append(edge_loop) + edge_loop = [self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1))] + previous_edge = edge + edge_loops.append(edge_loop) + for edge_loop in edge_loops: + curves.append(self.file.createIfcIndexedPolyCurve(points, edge_loop)) + return curves + + def create_curves_from_mesh_ifc2x3(self, geom_data) -> list[ifcopenshell.entity_instance]: + curves = [] + points = [self.create_cartesian_point(v.co.x, v.co.y, v.co.z) for v in geom_data.vertices] + edge_loops = [] + previous_edge = None + edge_loop = [] + for i, edge in enumerate(geom_data.edges): + if previous_edge is None: + edge_loop = [edge.vertices] + elif edge.vertices[0] == previous_edge.vertices[1]: + edge_loop.append(edge.vertices) + else: + edge_loops.append(edge_loop) + edge_loop = [edge.vertices] + previous_edge = edge + edge_loops.append(edge_loop) + for edge_loop in edge_loops: + loop_points = [points[p[0]] for p in edge_loop] + loop_points.append(points[edge_loop[-1][1]]) + curves.append(self.file.createIfcPolyline(loop_points)) + return curves + + def create_cartesian_point_list_from_vertices(self, vertices) -> ifcopenshell.entity_instance: + import numpy as np + from ifcopenshell.util.shape_builder import ifc_safe_vector_type + + # Catch values as floats to benefit from fast buffer copy. + coords = np.empty(len(vertices) * 3, dtype="f") + vertices.foreach_get("co", coords) + coords = coords.reshape(-1, 3) + coords_class = "IfcCartesianPointList3D" + return self.file.create_entity(coords_class, ifc_safe_vector_type(self.convert_si_to_unit(coords))) + + def convert_si_to_unit(self, co): + return co / self.unit_scale diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py index 07184d1cb2..a673bf377c 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py @@ -84,7 +84,8 @@ class Patcher: import bonsai.tool as tool from math import degrees - bpy.context.scene.BIMProjectProperties.should_use_native_meshes = True + props = tool.Project.get_project_props() + props.should_use_native_meshes = True bpy.ops.bim.load_project(filepath=self.filepath) old_history_size = tool.Ifc.get().history_size @@ -95,7 +96,8 @@ class Patcher: angle_threshold = 0.3 for obj in bpy.data.objects: - if not obj.BIMObjectProperties.ifc_definition_id or not obj.data: + ifc_id = tool.Blender.get_ifc_definition_id(obj) + if not ifc_id or not obj.data: continue if not obj.data.polygons: continue diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index 04d3cfa6ce..d4ef3feba9 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -34,21 +34,29 @@ import ifcopenshell.util.placement import ifcopenshell.util.schema import ifcopenshell.util.shape import ifcopenshell.util.unit -from typing import Any +from pathlib import Path +from ifcopenshell.geom import ShapeType +from typing import Any, TYPE_CHECKING, Literal SQLTypes = typing.Literal["SQLite", "MySQL"] -try: +if TYPE_CHECKING: import sqlite3 -except: - print("No SQLite support") - SQLTypes = typing.Literal["MySQL"] - -try: import mysql.connector -except: - print("No MySQL support") - SQLTypes = typing.Literal["SQLite"] +else: + try: + import sqlite3 + except: + print("No SQLite support") + SQLTypes = typing.Literal["MySQL"] + + try: + import mysql.connector + except: + print("No MySQL support") + SQLTypes = typing.Literal["SQLite"] + +DEFAULT_DATABASE_NAME = "database" class Patcher: @@ -60,7 +68,7 @@ class Patcher: host: str = "localhost", username: str = "root", password: str = "pass", - database: str = "test", + database: str = f"{DEFAULT_DATABASE_NAME}.sqlite", full_schema: bool = True, is_strict: bool = False, should_expand: bool = False, @@ -71,29 +79,29 @@ class Patcher: ): """Convert an IFC-SPF model to SQLite or MySQL. - There are certain controls which are hardcoded in this recipe that you - may modify, including: - - - full_schema: if True, will create tables for all IFC classes, - regardless if they are used or not in the dataset. If False, will - only create tables for classes in the dataset. - - is_strict: whether or not to enforce null or not null. If your - dataset might contain invalid data, set this to False. - - should_expand: if True, entities with attributes containing lists of - entities will be separated into multiple rows. This means the ifc_id - is no longer a unique primary key. If False, lists will be stored as - JSON. - - should_get_psets: if True, a separate psets table will be created to - make it easy to query properties. This is in addition to regular IFC - tables like IfcPropertySet. - - should_get_geometry: Whether or not to process and store explicit - geometry data as a blob in a separate geometry and shape table. - - should_skip_geometry_data: Whether or not to also create tables for - IfcRepresentation and IfcRepresentationItem classes. These tables are - unnecessary if you are not interested in geometry. - :param sql_type: Choose between "SQLite" or "MySQL" - :type sql_type: typing.Literal["SQLite", "MySQL"] + :param database: Database path to save the SQL database to (already existing or not). + Could also be a directory, then the database will be stored + using default filename (e.g. 'database.sqlite'). + :filter_glob database: *.db;*.sqlite + :param full_schema: if True, will create tables for all IFC classes, + regardless if they are used or not in the dataset. If False, will + only create tables for classes in the dataset. + :param is_strict: whether or not to enforce null or not null. If your + dataset might contain invalid data, set this to False. + :param should_expand: if True, entities with attributes containing lists of + entities will be separated into multiple rows. This means the ifc_id + is no longer a unique primary key. If False, lists will be stored as + JSON. + :param should_get_psets: if True, a separate psets table will be created to + make it easy to query properties. This is in addition to regular IFC + tables like IfcPropertySet. + :param should_get_geometry: Whether or not to process and store explicit + geometry data as a blob in a separate geometry and shape table. + :param should_skip_geometry_data: Whether or not to also create tables for + IfcRepresentation and IfcRepresentationItem classes. These tables are + unnecessary if you are not interested in geometry. + Example: @@ -106,7 +114,7 @@ class Patcher: """ self.file = file self.logger = logger - self.sql_type = sql_type.lower() + self.sql_type: Literal["sqlite", "mysql"] = sql_type.lower() self.host = host self.username = username self.password = password @@ -122,19 +130,26 @@ class Patcher: self.should_skip_geometry_data = should_skip_geometry_data def patch(self) -> None: - self.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.file.schema) + suffix = ".db" if self.sql_type == "SQLite" else ".sqlite" + database = Path(self.database) + if database.is_dir(): + database = (database / DEFAULT_DATABASE_NAME).with_suffix(suffix) + elif not database.parent.exists(): + database.parent.mkdir(parents=True, exist_ok=True) + else: + # Assume it's a filepath - existing or not. + pass + + self.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(self.file.schema_identifier) if self.sql_type == "sqlite": - db_file = self.database # Use the given datapath - self.db = sqlite3.connect(db_file) + self.db = sqlite3.connect(database) self.c = self.db.cursor() - self.file_patched = db_file elif self.sql_type == "mysql": self.db = mysql.connector.connect( - host=self.host, user=self.username, password=self.password, database=self.database + host=self.host, user=self.username, password=self.password, database=str(database) ) self.c = self.db.cursor() - self.file_patched = None self.create_id_map() self.create_metadata() @@ -231,20 +246,22 @@ class Patcher: checkpoint = time.time() shape = iterator.get() if shape: - if shape.geometry.id not in self.geometry_rows: - v = np.array(shape.geometry.verts).tobytes() - e = np.array(shape.geometry.edges).tobytes() - f = np.array(shape.geometry.faces).tobytes() - mids = np.array(shape.geometry.material_ids).tobytes() + shape_id = shape.id + geometry_id = shape.geometry.id + if geometry_id not in self.geometry_rows: + geometry: ShapeType + geometry = shape.geometry + v = geometry.verts_buffer + e = geometry.edges_buffer + f = geometry.faces_buffer + mids = geometry.material_ids_buffer m = json.dumps([m.instance_id() for m in shape.geometry.materials]) - self.geometry_rows[shape.geometry.id] = [shape.geometry.id, v, e, f, mids, m] + self.geometry_rows[geometry_id] = [geometry_id, v, e, f, mids, m] # Copy required since otherwise it is read-only m = ifcopenshell.util.shape.get_shape_matrix(shape).copy() - m[0][3] /= self.unit_scale - m[1][3] /= self.unit_scale - m[2][3] /= self.unit_scale - x, y, z = m[:, 3][0:3] - self.shape_rows[shape.id] = [shape.id, float(x), float(y), float(z), m.tobytes(), shape.geometry.id] + m[:3, 3] /= self.unit_scale + x, y, z = m[:, 3][0:3].tolist() + self.shape_rows[shape_id] = [shape_id, x, y, z, m.tobytes(), geometry_id] if not iterator.next(): break @@ -469,10 +486,10 @@ class Patcher: pset_rows.append([element.id(), pset_name, prop_name, value]) if self.should_get_geometry: - if element.id() not in self.shape_rows and getattr(element, "ObjectPlacement", None): - m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) - x, y, z = m[:, 3][0:3] - self.shape_rows[element.id()] = [element.id(), float(x), float(y), float(z), m.tobytes(), None] + if element.id() not in self.shape_rows and (placement := getattr(element, "ObjectPlacement", None)): + m = ifcopenshell.util.placement.get_local_placement(placement) + x, y, z = m[:, 3][0:3].tolist() + self.shape_rows[element.id()] = [element.id(), x, y, z, m.tobytes(), None] if self.sql_type == "sqlite": if rows: diff --git a/src/ifcpatch/ifcpatch/recipes/MergeStyles.py b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py new file mode 100644 index 0000000000..5af2807c8f --- /dev/null +++ b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py @@ -0,0 +1,52 @@ +# IfcPatch - IFC patching utiliy +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcPatch. +# +# IfcPatch 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. +# +# IfcPatch 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 IfcPatch. If not, see . + +import ifcopenshell +import ifcopenshell.guid + + +class Patcher: + def __init__(self, file, logger): + """Merge identical styles together + + Some software may create an obscene number of styles instead of reusing + them properly. This patch merges all IfcPresentationStyle, + IfcSurfaceStyleShading, and IfcColourRgb if they are identical. + + Example: + + .. code:: python + + ifcpatch.execute({"file": model, "recipe": "MergeStyles", "arguments": []}) + """ + self.file = file + self.logger = logger + + def patch(self): + for ifc_class in ("IfcColourRgb", "IfcSurfaceStyleShading", "IfcPresentationStyle"): + uniques = {} + i = 0 + for element in self.file.by_type(ifc_class): + data = "-".join([str(a) for a in element]) + if unique := uniques.get(data, None): + ifcopenshell.util.element.replace_element(element, unique) + self.file.remove(element) + i += 1 + else: + uniques[data] = element + print(f"Replaced {i} {ifc_class}") diff --git a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py index aa335320ba..a02ba69285 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py @@ -16,9 +16,12 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . +import ifcopenshell +import logging + class Patcher: - def __init__(self, file, logger, ifc_class="IfcSite"): + def __init__(self, file: ifcopenshell.file, logger: logging.Logger, ifc_class: str = "IfcSite"): """Resets the location of a spatial element to 0,0,0 Another more specialised patch to fix incorrect coordinate usage is to @@ -26,7 +29,6 @@ class Patcher: to 0,0,0. :param ifc_class: The class of spatial element to reset coordinates for. - :type ifc_class: str Example: @@ -39,13 +41,15 @@ class Patcher: self.logger = logger self.ifc_class = ifc_class - def patch(self): + def patch(self) -> None: project = self.file.by_type("IfcProject")[0] spatial_elements = self.find_decomposed_ifc_class(project, self.ifc_class) for spatial_element in spatial_elements: self.patch_placement_to_origin(spatial_element) - def find_decomposed_ifc_class(self, element, ifc_class): + def find_decomposed_ifc_class( + self, element: ifcopenshell.entity_instance, ifc_class: str + ) -> list[ifcopenshell.entity_instance]: results = [] rel_aggregates = element.IsDecomposedBy if not rel_aggregates: @@ -57,7 +61,7 @@ class Patcher: results.extend(self.find_decomposed_ifc_class(part, ifc_class)) return results - def patch_placement_to_origin(self, element): + def patch_placement_to_origin(self, element: ifcopenshell.entity_instance) -> None: element.ObjectPlacement.RelativePlacement.Location.Coordinates = (0.0, 0.0, 0.0) if element.ObjectPlacement.RelativePlacement.Axis: element.ObjectPlacement.RelativePlacement.Axis.DirectionRatios = (0.0, 0.0, 1.0) diff --git a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py index a2cee58a08..1d0b4c02f0 100644 --- a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py +++ b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py @@ -32,6 +32,7 @@ class Patcher: 0 and {name} is the name of the storey. :param output_dir: Specifies an output directory where the new IFC models will be saved. + :filter_glob output_dir: *.ifc;*.ifczip;*.ifcxml Example: @@ -49,9 +50,11 @@ class Patcher: from shutil import copyfile if self.output_dir is None: - output_dir = None + output_dir = Path() else: output_dir = Path(self.output_dir) + if output_dir.is_file(): + raise ValueError(f"Provided path is a file, not a directory: {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) temp_file = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) diff --git a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py index 5becec4264..8a264967d7 100644 --- a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py +++ b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py @@ -89,7 +89,7 @@ class Patcher: shape: Union[ifcopenshell.geom.ShapeType, ifcopenshell.geom.ShapeElementType], ) -> None: geometry = getattr(shape, "geometry", shape) - v = [[x.tolist() for x in ifcopenshell.util.shape.get_vertices(geometry)]] + v = [ifcopenshell.util.shape.get_vertices(geometry).tolist()] f = [ifcopenshell.util.shape.get_faces(geometry).tolist()] replacements[element] = (v, f) diff --git a/src/ifcpatch/test/test_ExtractElements.py b/src/ifcpatch/test/test_ExtractElements.py index 10b55bad09..97f416ae3f 100644 --- a/src/ifcpatch/test/test_ExtractElements.py +++ b/src/ifcpatch/test/test_ExtractElements.py @@ -75,6 +75,14 @@ class TestExtractElements(test.bootstrap.IFC4): assert output.by_type("IfcWall") assert not output.by_type("IfcSlab") + def test_extracting_non_standard_schema_version(self): + self.file = ifcopenshell.file(schema_version=(4, 3, 0, 0)) + project = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcProject") + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + output = ifcpatch.execute({"file": self.file, "recipe": "ExtractElements", "arguments": ["IfcWall"]}) + assert output.by_type("IfcProject")[0].GlobalId == project.GlobalId + assert output.by_type("IfcWall")[0].GlobalId == wall.GlobalId + class TestExtractElementsIFC2X3(test.bootstrap.IFC2X3, TestExtractElements): pass diff --git a/src/ifcpatch/test/test_Ifc2Sql.py b/src/ifcpatch/test/test_Ifc2Sql.py index 3d20c8ba97..e5c40fe245 100644 --- a/src/ifcpatch/test/test_Ifc2Sql.py +++ b/src/ifcpatch/test/test_Ifc2Sql.py @@ -34,16 +34,15 @@ from pathlib import Path class TestIfc2Sql: def test_run(self): TEST_FILE = Path(__file__).parent / "files" / "basic.ifc" - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".ifcsqlite") - sqlite_path = ifcpatch.execute( + temp = tempfile.NamedTemporaryFile(delete=False, suffix=".ifcsqlite") + sqlite_path = Path(temp.name) + ifcpatch.execute( { "file": ifcopenshell.open(TEST_FILE), "recipe": "Ifc2Sql", - "arguments": ["sqlite", None, None, None, tmp.name], + "arguments": ["sqlite", None, None, None, sqlite_path], } ) - assert isinstance(sqlite_path, str) - assert sqlite_path.endswith(".ifcsqlite") # Ensure file is valid. ifc_sqlite = ifcopenshell.open(sqlite_path) diff --git a/src/ifcpatch/test/test_ifcpatch.py b/src/ifcpatch/test/test_ifcpatch.py new file mode 100644 index 0000000000..53013cffe5 --- /dev/null +++ b/src/ifcpatch/test/test_ifcpatch.py @@ -0,0 +1,34 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# 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 ifcpatch +from pathlib import Path + + +class Test: + def test_parsing_docs(self): + recipes = Path(ifcpatch.__file__).parent / "recipes" + + for f in recipes.glob("*.py"): + if f.stem in "__init__": + continue + docs = ifcpatch.extract_docs(f.stem, "Patcher", "__init__", ("src", "file", "logger", "args")) + assert docs is not None + expected_keys = ("class_", "description", "output", "inputs") + for key in expected_keys: + assert key in docs diff --git a/src/ifcsverchok/__init__.py b/src/ifcsverchok/__init__.py index d1608a0d72..97fe04c5e0 100644 --- a/src/ifcsverchok/__init__.py +++ b/src/ifcsverchok/__init__.py @@ -32,6 +32,7 @@ import importlib import logging import types import bpy +from bpy_extras.io_utils import ExportHelper logger = logging.getLogger("sverchok.ifc") @@ -147,7 +148,10 @@ from ifcsverchok.ifcstore import SvIfcStore class IFC_Sv_UpdateCurrent(bpy.types.Operator): - """Update current Sverchok node tree""" + """Update current Sverchok node tree. + + Will reset transient IFC file. + """ bl_idname = "ifc.sverchok_update_current" bl_label = "Update Current Node Tree" @@ -161,9 +165,12 @@ class IFC_Sv_UpdateCurrent(bpy.types.Operator): # infra-related spatial structure elements, such as IfcBridge. # https://github.com/IfcOpenShell/IfcOpenShell/pull/2576#discussion_r1016261407 def execute(self, context): + import sverchok.node_tree + self.file = SvIfcStore.purge() node_tree = context.space_data.node_tree if node_tree: + assert isinstance(node_tree, sverchok.node_tree.SverchCustomTree) if self.force_mode or node_tree.sv_process: try: bpy.context.window.cursor_set("WAIT") @@ -174,14 +181,15 @@ class IFC_Sv_UpdateCurrent(bpy.types.Operator): return {"FINISHED"} -class IFC_Sv_write_file(bpy.types.Operator): +class IFC_Sv_write_file(bpy.types.Operator, ExportHelper): bl_idname = "ifc.write_file_panel" bl_label = "Write File" bl_options = {"REGISTER", "UNDO"} - bl_description = "File path to write to." - filepath: bpy.props.StringProperty(subtype="FILE_PATH") + bl_description = "Save transient IFC file to the provided path." + filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"}) node_group: bpy.props.StringProperty(default="") force_mode: bpy.props.BoolProperty(default=False) + filename_ext = ".ifc" @classmethod def poll(cls, context): @@ -257,10 +265,6 @@ class IFC_Sv_write_file(bpy.types.Operator): self.report({"INFO"}, f"File written to: {self.filepath}") return {"FINISHED"} - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - class IFC_PT_write_file_panel(bpy.types.Panel): bl_idname = "IFC_PT_write_file_panel" diff --git a/src/ifcsverchok/helper.py b/src/ifcsverchok/helper.py index edcd723836..411809e025 100644 --- a/src/ifcsverchok/helper.py +++ b/src/ifcsverchok/helper.py @@ -17,13 +17,28 @@ # along with IfcSverchok. If not, see . import bpy +import ifcopenshell +import sverchok.core.sockets from sverchok.data_structure import zip_long_repeat +from typing import Any, Union, TypeVar -ifc_files = {} +T_socket = TypeVar("T_socket", bound=sverchok.core.sockets.SvSocketCommon) + +ifc_files: dict[str, ifcopenshell.file] = {} class SvIfcCore: - def process(self): + sv_input_names: list[str] + + def process(self) -> None: + """Process inputs from `self.sv_input_names` and call `process_ifc()`. + + For `process()` inputs are supposed to be double nested. + E.g. file input should have type `list[list[ifcopenshell.file]]`. + + Similarly, outputs should also be double nested, + so they can be easily passed as inputs to other nodes. + """ sv_inputs_nested = [] for name in self.sv_input_names: sv_inputs_nested.append(self.inputs[name].sv_get()) @@ -31,3 +46,43 @@ class SvIfcCore: for sv_input in zip_long_repeat(*sv_input_nested): sv_input = list(sv_input) self.process_ifc(*sv_input) + + def process_ifc(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError + + +def get_selected_nodes() -> list[bpy.types.Node]: + """Get nodes selected in the currently opened editor. + + Mainly for debugging. + """ + screen = bpy.context.screen + assert screen + area = next(a for a in screen.areas if a.type == "NODE_EDITOR") + space = area.spaces.active + assert isinstance(space, bpy.types.SpaceNodeEditor) + node_tree = space.node_tree + assert node_tree + return [n for n in node_tree.nodes if n.select] + + +def create_socket( + inputs_or_outputs: Union[bpy.types.NodeInputs, bpy.types.NodeOutputs], + name: str, + *, + description: str = "", + data_type: str = "", + prop_name: str = "", + socket_type: type[T_socket] = sverchok.core.sockets.SvStringsSocket, +) -> T_socket: + socket = inputs_or_outputs.new(socket_type.bl_idname, name) + assert isinstance(socket, socket_type) + + if data_type: + data_type = f"Type: `{data_type}`." + description = "\n\n".join(filter(None, [description, data_type])) + socket.description = description + + if prop_name: + socket.prop_name = prop_name + return socket diff --git a/src/ifcsverchok/ifcstore.py b/src/ifcsverchok/ifcstore.py index e8c167bb52..d89ff83830 100644 --- a/src/ifcsverchok/ifcstore.py +++ b/src/ifcsverchok/ifcstore.py @@ -21,7 +21,7 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.util.representation from ifcopenshell import template -from typing import Union +from typing import Union, Any class SvIfcStore: @@ -30,7 +30,8 @@ class SvIfcStore: schema = None cache = None cache_path = None - id_map = {} + id_map: dict[str, Any] = {} + """Mapping `{node_id: Any}`""" guid_map = {} deleted_ids = set() edited_objs = set() @@ -79,7 +80,6 @@ class SvIfcStore: # TODO change units to imperial pass model = ifcopenshell.util.representation.get_context(file, context="Model") - print("model: ", model) context = ifcopenshell.api.run( "context.add_context", file, diff --git a/src/ifcsverchok/nodes/ifc/add.py b/src/ifcsverchok/nodes/ifc/add.py index ec312f1de0..5aab0da1f1 100644 --- a/src/ifcsverchok/nodes/ifc/add.py +++ b/src/ifcsverchok/nodes/ifc/add.py @@ -19,6 +19,7 @@ import bpy import ifcopenshell import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode @@ -27,24 +28,41 @@ from sverchok.data_structure import updateNode class SvIfcAdd(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcAdd" bl_label = "IFC Add" + bl_description = "Add an entity to the provided IFC file." file: StringProperty(name="file", update=updateNode) entity: StringProperty(name="entity", update=updateNode) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "file").prop_name = "file" - self.inputs.new("SvStringsSocket", "entity").prop_name = "entity" - self.outputs.new("SvStringsSocket", "file") - self.outputs.new("SvStringsSocket", "entity") + helper.create_socket( + self.inputs, + "file", + description="File to add entity to.", + data_type="list[list[ifcopenshell.file]]", + prop_name="file", + ) + helper.create_socket( + self.inputs, + "entity", + description="Entity to add to file.", + data_type="list[list[ifcopenshell.entity_instance]]", + prop_name="entity", + ) + helper.create_socket( + self.outputs, "file", description="File with added entity.", data_type="list[list[ifcopenshell.file]]" + ) + helper.create_socket( + self.outputs, "entity", description="Added entity.", data_type="list[list[ifcopenshell.entity_instance]]" + ) def process(self): self.sv_input_names = ["file", "entity"] - self.file_out = [] - self.entity_out = [] + self.file_out: list[ifcopenshell.file] = [] + self.entity_out: list[ifcopenshell.entity_instance] = [] super().process() self.outputs["file"].sv_set([self.file_out]) self.outputs["entity"].sv_set([self.entity_out]) - def process_ifc(self, file, entity): + def process_ifc(self, file: ifcopenshell.file, entity: ifcopenshell.entity_instance) -> None: self.entity_out.append(file.add(entity)) self.file_out.append(file) diff --git a/src/ifcsverchok/nodes/ifc/add_pset.py b/src/ifcsverchok/nodes/ifc/add_pset.py index 73f5e24d37..4e6f0d7674 100644 --- a/src/ifcsverchok/nodes/ifc/add_pset.py +++ b/src/ifcsverchok/nodes/ifc/add_pset.py @@ -19,12 +19,15 @@ import bpy import ifcopenshell +import sverchok.core.sockets import ifcsverchok.helper +import ifcsverchok.helper as helper from ifcsverchok.ifcstore import SvIfcStore -import bpy import json import ifcopenshell +import ifcopenshell.api +import ifcopenshell.api.pset from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode @@ -34,6 +37,7 @@ from sverchok.data_structure import updateNode, flatten_data class SvIfcAddPset(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcAddPset" bl_label = "IFC Add Pset" + bl_description = "Add/edit a property set for the provided element ids in the transient IFC file." Name: StringProperty( name="Name", description="Name of the property set. Eg. Pset_WallCommon.", @@ -49,10 +53,26 @@ class SvIfcAddPset(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIf Elements: StringProperty(name="Element Ids", update=updateNode) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "Name").prop_name = "Name" - self.inputs.new("SvTextSocket", "Properties").prop_name = "Properties" - self.inputs.new("SvStringsSocket", "Elements").prop_name = "Elements" - self.outputs.new("SvStringsSocket", "Entity") + helper.create_socket( + self.inputs, "Name", description="Name of the property set.", data_type="list[list[str]]", prop_name="Name" + ) + helper.create_socket( + self.inputs, + "Properties", + description="Properties in a JSON format.", + data_type="list[list[str]]", + prop_name="Properties", + socket_type=sverchok.core.sockets.SvTextSocket, + ) + helper.create_socket( + self.inputs, "Elements", description="Element Ids.", data_type="list[list[str]]", prop_name="Elements" + ) + helper.create_socket( + self.outputs, + "Entity", + description="Added/edited psets.", + data_type="list[list[ifcopenshell.entity_instance]]]", + ) def draw_buttons(self, context, layout): layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False).tooltip = ( @@ -80,12 +100,13 @@ class SvIfcAddPset(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIf self.outputs["Entity"].sv_set([element]) - def create(self, name, properties, elements): + def create( + self, name: str, properties: str, elements: list[ifcopenshell.entity_instance] + ) -> list[ifcopenshell.entity_instance]: results = [] for element in elements: - result = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name=name) - ifcopenshell.api.run( - "pset.edit_pset", + result = ifcopenshell.api.pset.add_pset(self.file, product=element, name=name) + ifcopenshell.api.pset.edit_pset( self.file, pset=result, properties=json.loads(properties), @@ -94,13 +115,14 @@ class SvIfcAddPset(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIf results.append(result) return results - def edit(self, name, properties, elements): + def edit( + self, name: str, properties: str, elements: list[ifcopenshell.entity_instance] + ) -> list[ifcopenshell.entity_instance]: result_ids = SvIfcStore.id_map[self.node_id] - results = [] + results: list[ifcopenshell.entity_instance] = [] for result_id in result_ids: result = self.file.by_id(result_id) - ifcopenshell.api.run( - "pset.edit_pset", + ifcopenshell.api.pset.edit_pset( self.file, pset=result, name=name, diff --git a/src/ifcsverchok/nodes/ifc/add_spatial_element.py b/src/ifcsverchok/nodes/ifc/add_spatial_element.py index 8015b4168c..77cb1fba3f 100644 --- a/src/ifcsverchok/nodes/ifc/add_spatial_element.py +++ b/src/ifcsverchok/nodes/ifc/add_spatial_element.py @@ -221,7 +221,6 @@ class SvIfcAddSpatialElement(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h try: del SvIfcStore.id_map[self.node_id] del self.node_dict[hash(self)] - # print('Node was deleted') except KeyError or AttributeError: pass diff --git a/src/ifcsverchok/nodes/ifc/api.py b/src/ifcsverchok/nodes/ifc/api.py index 7429c8ac58..af0b8766dd 100644 --- a/src/ifcsverchok/nodes/ifc/api.py +++ b/src/ifcsverchok/nodes/ifc/api.py @@ -19,17 +19,19 @@ import bpy import ifcopenshell import ifcopenshell.api +import sverchok.core.sockets import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty, EnumProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode import logging +from typing import Union, Any logger = logging.getLogger("sverchok.ifc") -def update_usecase(self, context): - print("API - running update usecase!") +def update_usecase(self: "SvIfcApi", context: bpy.types.Context) -> None: module_usecase = self.get_module_usecase() if module_usecase: self.generate_node(*module_usecase) @@ -37,8 +39,9 @@ def update_usecase(self, context): class SvIfcTooltip(bpy.types.Operator): bl_idname = "node.sv_ifc_tooltip" - bl_label = "IFC Info" + bl_label = "" tooltip: bpy.props.StringProperty() + bl_options = {"INTERNAL"} @classmethod def description(cls, context, properties): @@ -55,26 +58,37 @@ class SvIfcApi(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCor usecase: StringProperty(name="Usecase", update=update_usecase) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "usecase").prop_name = "usecase" - self.outputs.new("SvVerticesSocket", "file") + helper.create_socket( + self.inputs, + "usecase", + description="Usecase to run.", + data_type="list[list[str]]", + prop_name="usecase", + ) + helper.create_socket( + self.outputs, + "file", + description="Use case output.", + data_type="list[Any]", + socket_type=sverchok.core.sockets.SvVerticesSocket, + ) def draw_buttons(self, context, layout): op = layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False) op.tooltip = self.tooltip def process(self): - print("process") module_usecase = self.get_module_usecase() if module_usecase: self.sv_input_names = [i.name for i in self.inputs] super().process() - def get_module_usecase(self): + def get_module_usecase(self) -> Union[list[str], None]: usecase = self.inputs["usecase"].sv_get()[0][0] if usecase: return usecase.split(".") - def generate_node(self, module, usecase): + def generate_node(self, module: str, usecase: str) -> None: try: node_data = ifcopenshell.api.extract_docs(module, usecase) except: @@ -93,7 +107,7 @@ class SvIfcApi(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCor self.tooltip = f"{name}: {data['description']}\n" self.tooltip = self.tooltip.strip() - def process_ifc(self, usecase, *setting_values): + def process_ifc(self, usecase: Union[str, None], *setting_values: Any) -> None: if usecase: settings = dict(zip(self.sv_input_names[1:], setting_values)) settings = {k: v for k, v in settings.items() if v != ""} diff --git a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py index 1c6f37644d..6e8cb3df05 100644 --- a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py @@ -23,6 +23,8 @@ import bpy import ifcopenshell import ifcsverchok.helper import ifcopenshell.api +import ifcopenshell.api.geometry +import ifcopenshell.util.representation from ifcsverchok.ifcstore import SvIfcStore import bonsai.tool as tool import bonsai.core.geometry as core @@ -40,12 +42,14 @@ from sverchok.data_structure import zip_long_repeat, node_id from sverchok.core.socket_data import sv_get_socket from itertools import chain, cycle +from mathutils import Matrix class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): """ Triggers: BMesh to Ifc Repr - Tooltip: Blender mesh to Ifc Shape Representation + Tooltip: Add Blender mesh objects as IfcShapeRepresentations + to the transient IFC file. """ bl_idname = "SvIfcBMeshToIfcRepr" @@ -162,9 +166,9 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help self.outputs["Representations"].sv_set(representations) self.outputs["Locations"].sv_set(locations) - def create(self, blender_objects): - representations_ids = [] - locations = [] + def create(self, blender_objects: list[bpy.types.Object]) -> tuple[list[list[list[int]]], list[list[list[Matrix]]]]: + representations_ids: list[list[list[int]]] = [] + locations: list[list[list[Matrix]]] = [] context = self.get_context() for blender_object in blender_objects: bpy.context.view_layer.objects.active = blender_object @@ -183,8 +187,8 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help bpy.ops.mesh.separate( type="LOOSE" ) # This isn't a great solution bc it creates new objects in the scene, thus changing the users model - representations_ids_obj = [] - locations_obj = [] + representations_ids_obj: list[list[int]] = [] + locations_obj: list[list[Matrix]] = [] try: bpy.ops.object.mode_set(mode="OBJECT") except Exception as e: @@ -192,16 +196,16 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help pass for obj in bpy.context.selected_objects: if blender_object.type == "MESH": - representation = ifcopenshell.api.run( - "geometry.add_representation", + representation = ifcopenshell.api.geometry.add_representation( self.file, - should_run_listeners=False, blender_object=obj, geometry=obj.data, context=context, + should_run_listeners=False, ) if not representation: raise Exception("Couldn't create representation. Possibly wrong context.") + assert isinstance(representation, ifcopenshell.entity_instance) representations_ids_obj.append([representation.id()]) locations_obj.append([obj.matrix_world]) @@ -215,7 +219,7 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help bpy.ops.object.select_all(action="DESELECT") return representations_ids, locations - def edit(self): + def edit(self) -> None: if "Representations" not in SvIfcStore.id_map[self.node_id]: return for obj in SvIfcStore.id_map[self.node_id]["Representations"]: @@ -229,7 +233,7 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help del SvIfcStore.id_map[self.node_id]["Locations"] return - def get_context(self): + def get_context(self) -> ifcopenshell.entity_instance: context = ifcopenshell.util.representation.get_context( self.file, self.context_type, self.context_identifier, self.target_view ) @@ -248,7 +252,7 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help SvIfcStore.id_map.setdefault(self.node_id, {}).setdefault("Contexts", []).append(context.id()) return context - def sv_free(self): + def sv_free(self) -> None: try: self.file = SvIfcStore.get_file() if "Representations" in SvIfcStore.id_map[self.node_id]: @@ -273,7 +277,6 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help SvIfcStore.id_map[self.node_id]["Contexts"].remove(context_id) del SvIfcStore.id_map[self.node_id] del self.node_dict[hash(self)] - # print('Node was deleted') except KeyError or AttributeError: pass diff --git a/src/ifcsverchok/nodes/ifc/by_guid.py b/src/ifcsverchok/nodes/ifc/by_guid.py index 65c5196fad..43bea96c84 100644 --- a/src/ifcsverchok/nodes/ifc/by_guid.py +++ b/src/ifcsverchok/nodes/ifc/by_guid.py @@ -21,6 +21,7 @@ import itertools import bpy import ifcopenshell import ifcsverchok.helper +import ifcsverchok.helper as helper from ifcsverchok.ifcstore import SvIfcStore from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode @@ -30,13 +31,19 @@ from sverchok.data_structure import updateNode, flatten_data class SvIfcByGuid(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcByGuid" bl_label = "IFC By Guid" + bl_description = "Get IFC elements by guid from the transient IFC file." n_id: StringProperty(default="") guid: StringProperty(name="Guid(s)", update=updateNode) id_iter = itertools.count() + guids: list[str] def sv_init(self, context): - self.inputs.new("SvStringsSocket", "guid").prop_name = "guid" - self.outputs.new("SvStringsSocket", "Entities") + helper.create_socket( + self.inputs, "guid", description="Entities guids.", data_type="list[list[str]]", prop_name="guid" + ) + helper.create_socket( + self.outputs, "Entities", description="Entities", data_type="list[list[ifcopenshell.entity_instance]]" + ) def draw_buttons(self, context, layout): layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False).tooltip = ( diff --git a/src/ifcsverchok/nodes/ifc/by_id.py b/src/ifcsverchok/nodes/ifc/by_id.py index b42060fcf5..e985f4d0e6 100644 --- a/src/ifcsverchok/nodes/ifc/by_id.py +++ b/src/ifcsverchok/nodes/ifc/by_id.py @@ -19,6 +19,7 @@ import bpy import ifcsverchok.helper +import ifcsverchok.helper as helper from ifcsverchok.ifcstore import SvIfcStore from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode @@ -28,14 +29,21 @@ from sverchok.data_structure import updateNode, flatten_data class SvIfcById(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcById" bl_label = "IFC By Id" + bl_description = "Get IFC elements by step id from the transient IFC file." id: StringProperty( name="Id(s)", update=updateNode, ) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "id").prop_name = "id" - self.outputs.new("SvStringsSocket", "Entities") + helper.create_socket(self.inputs, "id", description="Step ids.", data_type="list[list[str]]", prop_name="id") + helper.create_socket( + self.outputs, + "Entities", + description="Entities.", + data_type="list[ifcopenshell.entity_instance]", + prop_name="Entities", + ) def draw_buttons(self, context, layout): layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False).tooltip = ( diff --git a/src/ifcsverchok/nodes/ifc/by_query.py b/src/ifcsverchok/nodes/ifc/by_query.py index 703de5dd8d..95f9ca654f 100644 --- a/src/ifcsverchok/nodes/ifc/by_query.py +++ b/src/ifcsverchok/nodes/ifc/by_query.py @@ -42,7 +42,7 @@ class SvIfcByQuery(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIf self.sv_input_names = ["query"] super().process() - def process_ifc(self, query): + def process_ifc(self, query: str) -> None: selector = ifcopenshell.util.selector.Selector() self.outputs["Entity"].sv_set([selector.parse(self.file, query)]) diff --git a/src/ifcsverchok/nodes/ifc/by_type.py b/src/ifcsverchok/nodes/ifc/by_type.py index 96c9d84942..c4c6f9bdb7 100644 --- a/src/ifcsverchok/nodes/ifc/by_type.py +++ b/src/ifcsverchok/nodes/ifc/by_type.py @@ -68,7 +68,7 @@ def get_ifc_classes(self, context): file = SvIfcStore.get_file() if not file: return [] - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(file.schema) + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(file.schema_identifier) declaration = schema.declaration_by_name(self.ifc_product) def get_classes(declaration): diff --git a/src/ifcsverchok/nodes/ifc/create_entity.py b/src/ifcsverchok/nodes/ifc/create_entity.py index f17b02a3c0..0bfb88b786 100644 --- a/src/ifcsverchok/nodes/ifc/create_entity.py +++ b/src/ifcsverchok/nodes/ifc/create_entity.py @@ -19,7 +19,12 @@ import bpy from mathutils import Matrix import ifcopenshell +import ifcopenshell.api +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.util.schema import ifcsverchok.helper +import ifcsverchok.helper as helper from ifcsverchok.ifcstore import SvIfcStore from bpy.props import StringProperty, BoolProperty @@ -75,7 +80,7 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper self.inputs.new("SvStringsSocket", "Representations").prop_name = "Representations" self.inputs.new("SvMatrixSocket", "Locations").is_mandatory = False # self.inputs.new("SvStringsSocket", "Properties").prop_name = "Properties" - self.outputs.new("SvStringsSocket", "Entities") + helper.create_socket(self.outputs, "Entities", description="Created entities ids.", data_type="list[list[int]]") self.node_dict[hash(self)] = {} def draw_buttons(self, context, layout): @@ -148,21 +153,20 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper self.outputs["Entities"].sv_set(entities) - def create(self, index=None): - entities_ids = [] + def create(self, index=None) -> list[list[int]]: + entities_ids: list[list[int]] = [] iterator1 = range(len(self.names)) if index is not None: iterator1 = [index[0]] for i in iterator1: group = self.names[i] - group_entities_ids = [] + group_entities_ids: list[int] = [] iterator2 = range(len(group)) if index is not None: iterator2 = [index[1]] for j in iterator2: try: - entity = ifcopenshell.api.run( - "root.create_entity", + entity = ifcopenshell.api.root.create_entity( self.file, ifc_class=self.ifc_class, name=self.names[i][j], @@ -171,8 +175,7 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper try: for repr in self.representations[i][j]: if repr: - ifcopenshell.api.run( - "geometry.assign_representation", + ifcopenshell.api.geometry.assign_representation( self.file, product=entity, representation=repr, @@ -182,8 +185,7 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper try: for loc in self.locations[i][j]: if isinstance(loc, Matrix): - ifcopenshell.api.run( - "geometry.edit_object_placement", + ifcopenshell.api.geometry.edit_object_placement( self.file, product=entity, matrix=loc, @@ -197,11 +199,11 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper entities_ids.append(group_entities_ids) return entities_ids - def edit(self): - entities_ids = [] + def edit(self) -> list[list[int]]: + entities_ids: list[list[int]] = [] id_map_copy = SvIfcStore.id_map[self.node_id].copy() for i, group in enumerate(self.names): - group_entities_ids = [] + group_entities_ids: list[int] = [] for j, _ in enumerate(group): try: step_id = id_map_copy[i][j] @@ -218,8 +220,7 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper if repr and repr.is_a("IfcProductDefinitionShape"): entity.Representation = repr elif repr: - ifcopenshell.api.run( - "geometry.assign_representation", + ifcopenshell.api.geometry.assign_representation( self.file, product=entity, representation=repr, @@ -229,8 +230,7 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper try: for loc in self.locations[i][j]: if isinstance(loc, Matrix): - ifcopenshell.api.run( - "geometry.edit_object_placement", + ifcopenshell.api.geometry.edit_object_placement( self.file, product=entity, matrix=loc, @@ -262,7 +262,6 @@ class SvIfcCreateEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper try: del SvIfcStore.id_map[self.node_id] del self.node_dict[hash(self)] - # print('Node was deleted') except KeyError or AttributeError: pass diff --git a/src/ifcsverchok/nodes/ifc/create_file.py b/src/ifcsverchok/nodes/ifc/create_file.py index f57c9825ed..50a77a5198 100644 --- a/src/ifcsverchok/nodes/ifc/create_file.py +++ b/src/ifcsverchok/nodes/ifc/create_file.py @@ -20,6 +20,8 @@ import bpy import ifcopenshell import ifcopenshell.guid import ifcsverchok.helper +import ifcsverchok.helper as helper +import sverchok.core.sockets from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode @@ -27,13 +29,15 @@ from sverchok.data_structure import updateNode class SvIfcCreateFileRefresh(bpy.types.Operator): bl_idname = "node.sv_ifc_create_file_refresh" - bl_label = "LB Out" + bl_label = "File Refresh" + bl_description = "Create new IFC file." tree_name: StringProperty(default="") node_name: StringProperty(default="") has_baked: bpy.props.BoolProperty(name="Has Baked", default=False) def execute(self, context): + node: SvIfcCreateFile node = bpy.data.node_groups[self.tree_name].nodes[self.node_name] node.process() return {"FINISHED"} @@ -42,11 +46,20 @@ class SvIfcCreateFileRefresh(bpy.types.Operator): class SvIfcCreateFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcCreateFile" bl_label = "IFC Create File" + bl_description = "Create a new IFC file." schema: StringProperty(name="schema", update=updateNode, default="IFC4") def sv_init(self, context): - self.inputs.new("SvStringsSocket", "schema").prop_name = "schema" - self.outputs.new("SvVerticesSocket", "file") + helper.create_socket( + self.inputs, "schema", description="IFC schema to use.", data_type="str", prop_name="schema" + ) + helper.create_socket( + self.outputs, + "file", + description="Opened IFC file.", + data_type="list[list[ifcopenshell.file]]", + socket_type=sverchok.core.sockets.SvVerticesSocket, + ) def draw_buttons(self, context, layout): self.wrapper_tracked_ui_draw_op(layout, "node.sv_ifc_create_file_refresh", icon="FILE_REFRESH", text="Refresh") @@ -55,7 +68,7 @@ class SvIfcCreateFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.S self.sv_input_names = ["schema"] super().process() - def process_ifc(self, schema): + def process_ifc(self, schema: str) -> None: guid = ifcopenshell.guid.new() ifcsverchok.helper.ifc_files[guid] = ifcopenshell.file(schema=schema) self.outputs["file"].sv_set([[ifcsverchok.helper.ifc_files[guid]]]) diff --git a/src/ifcsverchok/nodes/ifc/create_project.py b/src/ifcsverchok/nodes/ifc/create_project.py index 3048f7f936..d228267d88 100644 --- a/src/ifcsverchok/nodes/ifc/create_project.py +++ b/src/ifcsverchok/nodes/ifc/create_project.py @@ -20,6 +20,7 @@ import bpy import ifcopenshell import ifcopenshell.api import ifcsverchok.helper +import ifcsverchok.helper as helper from sverchok.node_tree import SverchCustomTreeNode @@ -28,17 +29,21 @@ class SvIfcCreateProject(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helpe bl_label = "IFC Create Project" def sv_init(self, context): - input_socket = self.inputs.new("SvStringsSocket", "file") - input_socket.tooltip = "ifc file to add the project to" - input_socket = self.inputs.new("SvStringsSocket", "project_name") - input_socket.tooltip = "Project name" - self.outputs.new("SvVerticesSocket", "file") + helper.create_socket( + self.inputs, "file", description="IFC file to add the project to", data_type="list[list[ifcopenshell.file]]" + ) + helper.create_socket(self.inputs, "project_name", description="Project name", data_type="list[list[str]]") + helper.create_socket( + self.outputs, + "file", + description="IFC file with the project added", + data_type="list[list[ifcopenshell.file]]", + ) def draw_buttons(self, context, layout): op = layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False).tooltip = ( "Adds project, unit and context to IFC file" ) - # op.tooltip = self.tooltip def process(self): # file @@ -52,7 +57,7 @@ class SvIfcCreateProject(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helpe project_name = self.inputs["project_name"].sv_get()[0][0] self.process_ifc(file, project_name) - def process_ifc(self, file, project_name): + def process_ifc(self, file: ifcopenshell.file, project_name: str) -> None: # create project project = ifcopenshell.api.run("root.create_entity", file, ifc_class="IfcProject", name=str(project_name)) lengthunit = ifcopenshell.api.run("unit.add_si_unit", file, unit_type="LENGTHUNIT") diff --git a/src/ifcsverchok/nodes/ifc/create_shape.py b/src/ifcsverchok/nodes/ifc/create_shape.py index 2795ef2dcd..381fc1e11c 100644 --- a/src/ifcsverchok/nodes/ifc/create_shape.py +++ b/src/ifcsverchok/nodes/ifc/create_shape.py @@ -19,6 +19,7 @@ import bpy import logging import ifcopenshell +import ifcopenshell.geom import ifcsverchok.helper from ifcsverchok.ifcstore import SvIfcStore import bonsai.bim.import_ifc diff --git a/src/ifcsverchok/nodes/ifc/pick_ifc_class.py b/src/ifcsverchok/nodes/ifc/pick_ifc_class.py index 5cf24b9602..3cbbf03af0 100644 --- a/src/ifcsverchok/nodes/ifc/pick_ifc_class.py +++ b/src/ifcsverchok/nodes/ifc/pick_ifc_class.py @@ -64,7 +64,7 @@ def get_ifc_classes(self, context): file = SvIfcStore.get_file() if not file: return [] - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(file.schema) + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(file.schema_identifier) declaration = schema.declaration_by_name(self.ifc_product) def get_classes(declaration): diff --git a/src/ifcsverchok/nodes/ifc/quick_project_setup.py b/src/ifcsverchok/nodes/ifc/quick_project_setup.py index 696d6d6515..49c621d8ba 100644 --- a/src/ifcsverchok/nodes/ifc/quick_project_setup.py +++ b/src/ifcsverchok/nodes/ifc/quick_project_setup.py @@ -20,7 +20,9 @@ from email.mime import application import bpy import ifcopenshell +import sverchok.core.sockets import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode @@ -38,32 +40,37 @@ class SvIfcQuickProjectSetup(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h def sv_init(self, context): input_socket = self.inputs.new("SvStringsSocket", "filename") - input_socket.tooltip = "Ifc file name" + input_socket.description = "Ifc file name" input_socket = self.inputs.new("SvStringsSocket", "timestring") - input_socket.tooltip = "Timestring, default = current time" + input_socket.description = "Timestring, default = current time" input_socket = self.inputs.new("SvStringsSocket", "organization") - input_socket.tooltip = "Organization" + input_socket.description = "Organization" input_socket = self.inputs.new("SvStringsSocket", "creator") - input_socket.tooltip = "creator" + input_socket.description = "creator" input_socket = self.inputs.new("SvStringsSocket", "schema_identifier") - input_socket.tooltip = "Schema, default = 'IFC4'" + input_socket.description = "Schema, default = 'IFC4'" input_socket = self.inputs.new("SvStringsSocket", "application_version") - input_socket.tooltip = "Application version" + input_socket.description = "Application version" input_socket = self.inputs.new("SvStringsSocket", "timestamp") - input_socket.tooltip = "Timestamp, default = current time" + input_socket.description = "Timestamp, default = current time" input_socket = self.inputs.new("SvStringsSocket", "application") - input_socket.tooltip = "Application, default = 'IfcOpenShell'" + input_socket.description = "Application, default = 'IfcOpenShell'" input_socket = self.inputs.new("SvStringsSocket", "project_globalid") - input_socket.tooltip = "Project GlobalId" + input_socket.description = "Project GlobalId" input_socket = self.inputs.new("SvStringsSocket", "project_name") - input_socket.tooltip = "Project name" - self.outputs.new("SvVerticesSocket", "file") + input_socket.description = "Project name" + helper.create_socket( + self.outputs, + "file", + description="New IFC file with the project added.", + data_type="list[list[ifcopenshell.file]]", + socket_type=sverchok.core.sockets.SvVerticesSocket, + ) def draw_buttons(self, context, layout): op = layout.operator("node.sv_ifc_tooltip", text="", icon="QUESTION", emboss=False).tooltip = ( "Quick Project Setup: creates Ifc file and sets up a basic project" ) - # op.tooltip = self.tooltip def process(self): self.sv_input_names = [i.name for i in self.inputs] diff --git a/src/ifcsverchok/nodes/ifc/read_entity.py b/src/ifcsverchok/nodes/ifc/read_entity.py index fb581d1b63..9ea2fa3490 100644 --- a/src/ifcsverchok/nodes/ifc/read_entity.py +++ b/src/ifcsverchok/nodes/ifc/read_entity.py @@ -58,7 +58,7 @@ class SvIfcReadEntity(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.S ifc_class = entity.is_a() file = SvIfcStore.get_file() if file: - schema_name = file.wrapped_data.schema + schema_name = file.schema_identifier else: schema_name = "IFC4" self.schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema_name) diff --git a/src/ifcsverchok/nodes/ifc/read_file.py b/src/ifcsverchok/nodes/ifc/read_file.py index a6b39763d0..ab754d7278 100644 --- a/src/ifcsverchok/nodes/ifc/read_file.py +++ b/src/ifcsverchok/nodes/ifc/read_file.py @@ -20,6 +20,7 @@ import bpy import ifcopenshell import ifcopenshell.guid import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode @@ -28,17 +29,22 @@ from sverchok.data_structure import updateNode class SvIfcReadFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcReadFile" bl_label = "IFC Read File" + bl_description = "Read an IFC file from the provided path." path: StringProperty(name="path", update=updateNode) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "path").prop_name = "path" - self.outputs.new("SvVerticesSocket", "file") + helper.create_socket( + self.inputs, "path", description="Path to IFC file.", data_type="list[list[str]]", prop_name="path" + ) + helper.create_socket( + self.outputs, "file", description="Opened IFC file.", data_type="list[list[ifcopenshell.file]]" + ) def process(self): self.sv_input_names = ["path"] super().process() - def process_ifc(self, path): + def process_ifc(self, path: str) -> None: guid = ifcopenshell.guid.new() ifcsverchok.helper.ifc_files[guid] = ifcopenshell.open(path) self.outputs["file"].sv_set([[ifcsverchok.helper.ifc_files[guid]]]) diff --git a/src/ifcsverchok/nodes/ifc/remove.py b/src/ifcsverchok/nodes/ifc/remove.py index 58c2d8a5c3..2e76d28896 100644 --- a/src/ifcsverchok/nodes/ifc/remove.py +++ b/src/ifcsverchok/nodes/ifc/remove.py @@ -19,9 +19,11 @@ import bpy import ifcopenshell import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode +from typing import Union class SvIfcRemove(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): @@ -31,17 +33,42 @@ class SvIfcRemove(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfc entity: StringProperty(name="entity", update=updateNode) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "file").prop_name = "file" - self.inputs.new("SvStringsSocket", "entity").prop_name = "entity" - self.outputs.new("SvStringsSocket", "file") + helper.create_socket( + self.inputs, + "file", + description="IFC file to remove entity from.", + data_type="list[list[ifcopenshell.file]]", + prop_name="file", + ) + helper.create_socket( + self.inputs, + "entity", + description="Entity to remove from IFC file.", + data_type="list[list[ifcopenshell.entity_instance]]", + prop_name="entity", + ) + helper.create_socket( + self.outputs, + "file", + description="New IFC file with the entity removed.", + data_type="list[list[ifcopenshell.file]]", + ) def process(self): + file: ifcopenshell.file file = self.inputs["file"].sv_get()[0][0] self.new_file = ifcopenshell.file.from_string(file.wrapped_data.to_string()) self.remove_entity(self.inputs["entity"].sv_get()) self.outputs["file"].sv_set([[self.new_file]]) - def remove_entity(self, entity): + def remove_entity( + self, + entity: Union[ + list[list[ifcopenshell.entity_instance]], + list[ifcopenshell.entity_instance], + ifcopenshell.entity_instance, + ], + ) -> None: if isinstance(entity, (tuple, list)): for e in entity: self.remove_entity(e) diff --git a/src/ifcsverchok/nodes/ifc/select_blender_objects.py b/src/ifcsverchok/nodes/ifc/select_blender_objects.py index 66bd27a4a0..5067db7ccf 100644 --- a/src/ifcsverchok/nodes/ifc/select_blender_objects.py +++ b/src/ifcsverchok/nodes/ifc/select_blender_objects.py @@ -19,11 +19,12 @@ import bpy import ifcopenshell import ifcopenshell.util.selector +import bonsai.tool as tool import ifcsverchok.helper +import ifcsverchok.helper as helper from bpy.props import StringProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode -from bonsai.bim.ifc import IfcStore class SvIfcSelectBlenderObjectsRefresh(bpy.types.Operator): @@ -35,6 +36,7 @@ class SvIfcSelectBlenderObjectsRefresh(bpy.types.Operator): node_name: StringProperty(default="") def execute(self, context): + node: SvIfcSelectBlenderObjects node = bpy.data.node_groups[self.tree_name].nodes[self.node_name] node.process() return {"FINISHED"} @@ -43,29 +45,37 @@ class SvIfcSelectBlenderObjectsRefresh(bpy.types.Operator): class SvIfcSelectBlenderObjects(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): bl_idname = "SvIfcSelectBlenderObjects" bl_label = "IFC Select Blender Objects" + bl_description = "Select Blender objects based on IFC entities." file: StringProperty(name="file", update=updateNode) + # TODO: never used. query: StringProperty(name="query", update=updateNode) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "entities").prop_name = "entities" + helper.create_socket( + self.inputs, + "entities", + description="IFC entities to select Bonsai Blender objects for (selects only objects by matching GlobalId).", + data_type="list[list[ifcopenshell.entity_instance]]", + prop_name="entities", + ) def draw_buttons(self, context, layout): self.wrapper_tracked_ui_draw_op( layout, "node.sv_ifc_select_blender_objects_refresh", icon="FILE_REFRESH", text="Refresh" ) - def process(self): - self.file = IfcStore.get_file() + def process(self) -> None: self.sv_input_names = ["entities"] - self.guids = [] + self.guids: list[str] = [] super().process() for obj in bpy.context.visible_objects: - if not obj.BIMObjectProperties.ifc_definition_id: + element = tool.Ifc.get_entity(obj) + if not element: continue - if self.file.by_id(obj.BIMObjectProperties.ifc_definition_id).GlobalId in self.guids: + if getattr(element, "GlobalId", None) in self.guids: obj.select_set(True) - def process_ifc(self, entities): + def process_ifc(self, entities: ifcopenshell.entity_instance) -> None: self.guids.append(entities.GlobalId) diff --git a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py index a2b092aa79..f9a6701432 100644 --- a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py @@ -16,15 +16,14 @@ # You should have received a copy of the GNU General Public License # along with IfcSverchok. If not, see . -from copy import deepcopy import bpy import ifcopenshell import ifcsverchok.helper import ifcopenshell.api +import ifcopenshell.api.context +import ifcopenshell.api.geometry import ifcopenshell.util.representation from ifcsverchok.ifcstore import SvIfcStore -import bonsai.tool as tool -import bonsai.core.geometry as core from bpy.props import StringProperty, EnumProperty, IntProperty, FloatVectorProperty from sverchok.node_tree import SverchCustomTreeNode from sverchok.data_structure import updateNode, ensure_min_nesting @@ -33,7 +32,8 @@ from sverchok.data_structure import updateNode, ensure_min_nesting class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): """ Triggers: Sv to Ifc Repr - Tooltip: Sverchok geometry to Ifc Shape Representation + Tooltip: Add Sverchok geometry as IfcShapeRepresentations + to the transient IFC file. """ bl_idname = "SvIfcSverchokToIfcRepr" @@ -142,8 +142,7 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h for obj in geo_data: representations_ids_obj = [] for item in obj: - representation = ifcopenshell.api.run( - "geometry.add_mesh_representation", + representation = ifcopenshell.api.geometry.add_mesh_representation( self.file, should_run_listeners=False, context=self.context, @@ -165,8 +164,7 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h return for obj in SvIfcStore.id_map[self.node_id]["Representations"]: for step_id in obj: - ifcopenshell.api.run( - "geometry.remove_representation", + ifcopenshell.api.geometry.remove_representation( self.file, representation=self.file.by_id(step_id[0]), ) @@ -180,9 +178,8 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h if not context: parent = ifcopenshell.util.representation.get_context(self.file, self.context_type) if not parent: - parent = ifcopenshell.api.run("context.add_context", self.file, context_type=self.context_type) - context = ifcopenshell.api.run( - "context.add_context", + parent = ifcopenshell.api.context.add_context(self.file, context_type=self.context_type) + context = ifcopenshell.api.context.add_context( self.file, context_type=self.context_type, context_identifier=self.context_identifier, @@ -197,8 +194,7 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h self.file = SvIfcStore.get_file() if "Representations" in SvIfcStore.id_map[self.node_id]: for step_id in SvIfcStore.id_map[self.node_id]["Representations"]: - ifcopenshell.api.run( - "geometry.remove_representation", + ifcopenshell.api.geometry.remove_representation( self.file, representation=self.file.by_id(step_id[0]), ) @@ -209,15 +205,13 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h if not self.file.get_inverse(context): if self.file.by_id(context_id).ParentContext: parent = self.file.by_id(context_id).ParentContext - ifcopenshell.api.run("context.remove_context", self.file, context=context) + ifcopenshell.api.context.remove_context(self.file, context=context) if parent: if not self.file.get_inverse(parent): - ifcopenshell.api.run("context.remove_context", self.file, context=parent) - # print("Removed context with step ID: ", context_id) + ifcopenshell.api.context.remove_context(self.file, context=parent) SvIfcStore.id_map[self.node_id]["Contexts"].remove(context_id) del SvIfcStore.id_map[self.node_id] del self.node_dict[hash(self)] - # print('Node was deleted') except KeyError or AttributeError: pass diff --git a/src/ifcsverchok/nodes/ifc/write_file.py b/src/ifcsverchok/nodes/ifc/write_file.py index b9f03c6e1a..b277ccadf3 100644 --- a/src/ifcsverchok/nodes/ifc/write_file.py +++ b/src/ifcsverchok/nodes/ifc/write_file.py @@ -22,6 +22,7 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.util.element import ifcsverchok.helper +import ifcsverchok.helper as helper from ifcsverchok.ifcstore import SvIfcStore from bpy.props import StringProperty, BoolProperty from sverchok.node_tree import SverchCustomTreeNode @@ -31,7 +32,7 @@ from sverchok.data_structure import updateNode, flatten_data class SvIfcWriteFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.SvIfcCore): """ Triggers: Ifc write to file - Tooltip: Write active Sverchok Ifc file to path + Tooltip: Write transient Ifc file to path """ def refresh_node_local(self, context): @@ -39,7 +40,9 @@ class SvIfcWriteFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv self.process() self.refresh_local = False - refresh_local: BoolProperty(name="Write", description="Write to file", update=refresh_node_local) + refresh_local: BoolProperty( + name="Write", description="Write to file when changed to True.", update=refresh_node_local + ) bl_idname = "SvIfcWriteFile" bl_label = "IFC Write File" @@ -50,8 +53,20 @@ class SvIfcWriteFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv ) def sv_init(self, context): - self.inputs.new("SvStringsSocket", "path").prop_name = "path" - self.outputs.new("SvStringsSocket", "output") + helper.create_socket( + self.inputs, + "path", + description="File path to write to. Can be relative.", + data_type="str", + prop_name="path", + ) + helper.create_socket( + self.outputs, + "output", + description="Node result output message.", + data_type="str", + prop_name="output", + ) def draw_buttons(self, context, layout): row = layout.row(align=True) @@ -77,7 +92,8 @@ class SvIfcWriteFile(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv file.write(path) self.outputs["output"].sv_set(f"File written successfully to: {path}.") - def ensure_hirarchy(self, file): + def ensure_hirarchy(self, file: ifcopenshell.file) -> None: + # TODO: same code as ifc.write_file_panel? elements_in_buildings = [] if not 0 <= 0 < len(file.by_type("IfcBuilding")): my_building = ifcopenshell.api.run("root.create_entity", file, ifc_class="IfcBuilding", name="My Building") diff --git a/src/ifctester/README.md b/src/ifctester/README.md index 98c82ec22f..d97685161d 100644 --- a/src/ifctester/README.md +++ b/src/ifctester/README.md @@ -1,63 +1,3 @@ # ifctester -With **IfcTester**, you can author and read **Information Delivery Specification** - **IDS** - files and validate your IFC models against IDS to see if your model is compliant. After the audit, you can generate reports in console, as a web page, JSON or BCF file. It works from the command line, as a web app, or as a library. - -## How to use it - -### Command line use - - -```bash -# run console reporter -python -m ifctester example.ids example.ifc -python -m ifctester example.ids example.ifc -r Html -o report.html -``` - -Available flags: - -- ``-r`` / ``--reporter``: The reporting method to view audit results. Availabe reporters: Console, Txt, Json, Html, Ods, Bcf -- ``--no-color``: Disable colour output (supported by Console reporting). -- ``--excel-safe``: Make sure exported ODS is safely exported for Excel. -- ``-o`` / ``--output``: Output file (supported for all types of reporting except Console). - -### Code example - -```python -import ifcopenshell -from ifctester import ids, reporter - - -# create new IDS -my_ids = ids.Ids(title="My IDS") - -# add specification to it -my_spec = ids.Specification(name="My first specification") -my_spec.applicability.append(ids.Entity(name="IFCWALL")) -property = ids.Property( - baseName="IsExternal", - value="TRUE", - propertySet="Pset_WallCommon", - dataType="IfcBoolean", - uri="https://identifier.buildingsmart.org/uri/.../prop/LoadBearing", - instructions="Walls need to be load bearing.", - cardinality="required") -my_spec.requirements.append(property) -my_ids.specifications.append(my_spec) - -# Save such IDS to file -result = my_ids.to_xml("SampleIDS.xml") - -# open IFC file: -my_ifc = ifcopenshell.open("MyIfcModel.ifc") - -# validate IFC model against IDS requirements: -my_ids.validate(my_ifc) - -# show results: -reporter.Console(my_ids).report() -``` - - -### ifctester web app - -Can be started by `cd webapp && python app.py`. +Experimental webapp can be started by `cd webapp && python app.py`. diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 44b2bedfe9..7ea972087f 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -257,7 +257,7 @@ class Attribute(Facet): return super().filter(ifc_file, elements) results = [] - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema) + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier) entities = {entity.name(): entity for entity in schema.entities()} def ignore_subtypes(entity): diff --git a/src/ifctester/pyproject.toml b/src/ifctester/pyproject.toml index 04f67b5770..16ca9b1333 100644 --- a/src/ifctester/pyproject.toml +++ b/src/ifctester/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", ] -dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy"] +dependencies = ["ifcopenshell", "python-dateutil", "xmlschema", "numpy", "odfpy", "pystache", "bcf-client"] [project.urls] Homepage = "http://ifcopenshell.org" diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index c0dd62c04f..16dc6e1081 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -260,6 +260,7 @@ namespace { %include "../serializers/SvgSerializer.h" %include "../serializers/HdfSerializer.h" %include "../serializers/WavefrontObjSerializer.h" +%include "../serializers/ColladaSerializer.h" %include "../serializers/XmlSerializer.h" %include "../serializers/GltfSerializer.h" %include "../serializers/TtlWktSerializer.h" @@ -394,6 +395,9 @@ assign_matrix_access(revolve); std::vector setting_names() { return $self->setting_names(); } + std::string get_type(const std::string& name) { + return $self->get_type(name); + } } %extend ifcopenshell::geometry::SerializerSettings { @@ -418,6 +422,9 @@ assign_matrix_access(revolve); std::vector setting_names() { return $self->setting_names(); } + std::string get_type(const std::string& name) { + return $self->get_type(name); + } } #ifdef IFOPSH_WITH_OPENCASCADE diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index 53678b42e7..c7b86150fd 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -149,6 +149,9 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas void write(const std::string& fn) { std::ofstream f(IfcUtil::path::from_utf8(fn).c_str()); + if (!f.good()) { + throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions."); + } f << (*$self); } diff --git a/src/ifcwrap/IfcPython.i b/src/ifcwrap/IfcPython.i index b6f344da1a..7140481c2b 100644 --- a/src/ifcwrap/IfcPython.i +++ b/src/ifcwrap/IfcPython.i @@ -54,6 +54,11 @@ %include "exception.i" %include "std_shared_ptr.i" +%{ + #include +%} +%template(DoubleArray3) std::array; + %ignore IfcGeom::NumberNativeDouble; %ignore ifcopenshell::geometry::Converter; @@ -86,7 +91,7 @@ %ignore curve_to_loop_upgrade_impl; %ignore edge_to_loop_upgrade_impl; %ignore curve_to_face_upgrade_impl; -%ignore loop_to_piecewise_function_upgrade_impl; +%ignore loop_to_function_item_upgrade_impl; // settings, can this done more generally? %ignore UseElementNames; @@ -181,6 +186,7 @@ #include "../serializers/SvgSerializer.h" #include "../serializers/WavefrontObjSerializer.h" + #include "../serializers/ColladaSerializer.h" #include "../serializers/HdfSerializer.h" #ifdef HAS_SCHEMA_2x3 @@ -277,6 +283,7 @@ constexpr bool is_std_vector_vector_v = is_std_vector_vector::value; #include "../serializers/SvgSerializer.h" #include "../serializers/WavefrontObjSerializer.h" + #include "../serializers/ColladaSerializer.h" #include "../serializers/HdfSerializer.h" #include "../serializers/XmlSerializer.h" #include "../serializers/GltfSerializer.h" diff --git a/src/pyodide/demo-app/index.html b/src/pyodide/demo-app/index.html index 555b83bb99..63ec0f0e65 100644 --- a/src/pyodide/demo-app/index.html +++ b/src/pyodide/demo-app/index.html @@ -73,10 +73,11 @@ document.querySelector("#status2").innerHTML = "Loading dependencies"; await pyodide.loadPackage("micropip"); await pyodide.loadPackage("numpy"); + await pyodide.loadPackage("shapely"); const micropip = pyodide.pyimport("micropip"); await micropip.install("typing-extensions"); document.querySelector("#status2").innerHTML = "Loading IfcOpenShell"; - await micropip.install("wheels/ifcopenshell-0.8.1+latest-cp312-cp312-emscripten_3_1_58_wasm32.whl"); + await micropip.install("wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl"); document.body.className = ''; diff --git a/src/pyodide/demo-app/wheels b/src/pyodide/demo-app/wheels index 33b437e5fd..6b5bfb4bdc 160000 --- a/src/pyodide/demo-app/wheels +++ b/src/pyodide/demo-app/wheels @@ -1 +1 @@ -Subproject commit 33b437e5fd5425e606f34aff602c42034ff5e6dc +Subproject commit 6b5bfb4bdc364f859643a624bd69bf9f471d45c0 diff --git a/src/serializers/GltfSerializer.cpp b/src/serializers/GltfSerializer.cpp index 8f694801be..8b1c0ec03b 100644 --- a/src/serializers/GltfSerializer.cpp +++ b/src/serializers/GltfSerializer.cpp @@ -141,13 +141,12 @@ size_t write_accessor(json& j, std::ofstream& ofs, It begin, It end, int bufferV accessor["componentType"] = component_type::value; accessor["count"] = num; - if (N == 1) { + if constexpr (N == 1) { j["bufferViews"].push_back({ {"buffer", 0}, {"byteOffset", (size_t)ofs.tellp()}, { "byteLength", num * 4}, {"target", ELEMENT_ARRAY_BUFFER} }); } else { j["bufferViews"].push_back({ {"buffer", 0}, {"byteStride", 12}, { "byteOffset", (size_t)ofs.tellp()}, { "byteLength", num * 12}, {"target", ARRAY_BUFFER}}); } - std::array min, max; min.fill(std::numeric_limits::max()); max.fill(std::numeric_limits::lowest()); diff --git a/src/serializers/schema_dependent/XmlSerializer.cpp b/src/serializers/schema_dependent/XmlSerializer.cpp index 8288a09e19..590fdcb2fa 100644 --- a/src/serializers/schema_dependent/XmlSerializer.cpp +++ b/src/serializers/schema_dependent/XmlSerializer.cpp @@ -82,9 +82,10 @@ boost::optional format_attribute(ifcopenshell::geometry::abstract_m } switch(argument_type) { - case IfcUtil::Argument_BOOL: { - const bool b = argument; - value = b ? "true" : "false"; + case IfcUtil::Argument_BOOL: + case IfcUtil::Argument_LOGICAL:{ + const boost::logic::tribool b = argument; + value = b.value == boost::logic::tribool::indeterminate_value ? "unknown" : b ? "true" : "false"; break; } case IfcUtil::Argument_DOUBLE: { const double d = argument;